松烟阁在做内容设计的时候参考了 Breadcrumbs Design Principle ,除此之外还加了一些彩蛋留给有心人自己去探索和发现,希望有心之人在畅游我的数字花园时能有属于自己的发现。所以在松烟阁里面中有很多隐藏的文章,追求极简设计的松烟阁一直没有很好提示读者去发掘的地方,受到椒盐豆豉和虫洞的启发打算在松烟阁中添加让读者能够发掘文章的入口。
先看一下最终效果:
如图所示,松烟阁添加让读者能够发掘文章的入口的方式:
将托管在 Github 的静态页面部署到 Cloudflare Pages,操作相对简单参考手册即可:Cloudflare Pages docs
静态 HTML 主要显示提示、统计等相关信息:
<div class="container">
<div id='progressbar' class='meter-snippet'>
<span id='percentage' class='percentage' style="width: 0%;"></span>
</div>
<canvas id="c"></canvas>
<div id="content">
<h3>即将奔赴 <b id="name"></b> 的十年</h3>
<p>您是第 <span id="refer"></span> 位通过虫洞穿梭到该博客的旅客!<br/><span style="color:#606c84">(统计日期始于2022年1月20日)</span></p>
<div id="vortex"></div>
<div class="meta">
<p>穿梭时间: <span id="time"></span></p>
<p id="message-header">博主寄语</p>
<p id="message">
<span class="message-left">“</span>
<span class="text"></span>
<span class="message-right">”</span>
</p>
</div>
<div class="footer">
Tips: <b>走心的留言更能打动人心</b><br/>
<a href="https://www.foreverblog.cn/" target="_blank">
<img id="logo" src="https://img.foreverblog.cn/logo_en_default.png">
</a>
<div class="time">Idea 源自 ©十年之约 2017 -
<script>document.write((new Date()).getFullYear())</script>
</div>
</div>
</div>
<div class="dialog-box" style="display: none;">
<p class="dialog-message">恭喜你,第 <span id="dialog-refer" class="dialog-message-refer"></span> 幸运儿!你被彩蛋砸中了,输入邮箱接收一份小礼品吧!</p>
<form id="user-form">
<input type="text" id="dialog-name" name="name" placeholder="请输入您的姓名" required>
<input type="email" id="dialog-email" name="email" placeholder="请输入您的邮箱" required>
<div class="button-container">
<button type="button" class="cancel-btn">取消</button>
<button type="submit" class="submit-btn">提交</button>
</div>
</form>
</div>
</div>
与 Cloudflare Worker 交互逻辑比较简单:
具体 javascript 代码如下(时间代码没有任何优化不是很优雅,轻喷~~):
let easterEggs = false;
function randomRgbaColor() {
var r = Math.floor(Math.random() * (255 - 50 + 1) + 50);
var g = Math.floor(Math.random() * (255 - 50 + 1) + 50);
var b = Math.floor(Math.random() * (255 - 50 + 1) + 50);
return 'rgb(' + r +', ' + g + ', ' + b + ', .95)';
}
function showDialog(data) {
$('.dialog-box').fadeIn().css('display', 'block');
$('#dialog-refer').text(data);
easterEggs = true;
console.log("easter eggs");
document.getElementById('user-form').addEventListener('submit', function(event) {
event.preventDefault();
var name = document.getElementById('dialog-name').value;
var email = document.getElementById('dialog-email').value;
// 关闭对话框
document.querySelector('.dialog-box').style.display = 'none';
// 发送通知
$.ajax({
url: "https://webhook.worker.edony.ink/notify?X-Telegram-Bot-Api-Secret-Token="+TOKEN,
type: "POST",
headers: {
'Access-Control-Allow-Origin': "*",
'Access-Control-Allow-Methods': "*",
'Access-Control-Allow-Headers': '*',
'Content-Type': "application/json"
},
data: JSON.stringify({
"name": name,
"email": email,
"description": data,
}),
dataType: 'json',
success: function() {
console.log("send notification successfully");
},
error: function() {
alert('出错啦,发邮件到 edonyzpc@edony.ink 试试吧~')
}
})
// ajax request wormhole
$.ajax({
url: 'https://webhook.worker.edony.ink/wormhole',
type: "GET",
headers: {
'Access-Control-Allow-Origin': "*",
'Access-Control-Allow-Methods': "*",
'Access-Control-Allow-Headers': '*',
'Content-Type': "text/xml"
},
success: function (data) {
if (data) {
var urls = $('url', data);
const rand = Math.floor(Math.random() * urls.length);
var url = $('loc', urls[rand-1]);
var time = $('lastmod', urls[rand-1]);
$('#name').text("Shadow Walker 松烟阁");
$('#time').text(time[0].innerHTML);
$('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
$('#content').fadeIn().css('display', 'flex');
setTimeout(function () {
window.location = url[0].innerHTML;
}, 5000);
} else {
alert(response.message)
}
},
error: function () {
alert('出错啦,请稍后再试~')
}
})
});
document.querySelector('.cancel-btn').addEventListener('click', function() {
document.querySelector('.dialog-box').style.display = 'none';
// ajax request
$.ajax({
url: 'https://webhook.worker.edony.ink/wormhole',
type: "GET",
headers: {
'Access-Control-Allow-Origin': "*",
'Access-Control-Allow-Methods': "*",
'Access-Control-Allow-Headers': '*',
'Content-Type': "text/xml"
},
success: function (data) {
if (data) {
var urls = $('url', data);
const rand = Math.floor(Math.random() * urls.length);
var url = $('loc', urls[rand-1]);
var time = $('lastmod', urls[rand-1]);
$('#name').text("Shadow Walker 松烟阁");
$('#time').text(time[0].innerHTML);
$('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
$('#content').fadeIn().css('display', 'flex');
setTimeout(function () {
window.location = url[0].innerHTML;
}, 5000);
} else {
alert(response.message)
}
},
error: function () {
alert('出错啦,请稍后再试~')
}
})
});
}
$.ajax({
url: 'https://webhook.worker.edony.ink/counter',
type: 'POST',
headers: {
'Access-Control-Allow-Origin': "*",
'Access-Control-Allow-Methods': "*",
'Access-Control-Allow-Headers': '*',
'Content-Type': "text/xml"
},
success: function(data) {
if (data) {
$('#refer').text(data);
if (data === '1024') {
// 1024th use get his lucky
showDialog(data);
}
}
},
error: function(err) {
alert("获取计数失败~")
}
})
if (!easterEggs) {
$.ajax({
url: 'https://webhook.worker.edony.ink/wormhole',
type: "GET",
headers: {
'Access-Control-Allow-Origin': "*",
'Access-Control-Allow-Methods': "*",
'Access-Control-Allow-Headers': '*',
'Content-Type': "text/xml"
},
success: function (data) {
if (data) {
var urls = $('url', data);
const rand = Math.floor(Math.random() * urls.length);
var url = $('loc', urls[rand-1]);
var time = $('lastmod', urls[rand-1]);
$('#name').text("Shadow Walker 松烟阁");
$('#time').text(time[0].innerHTML);
$('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
$('#content').fadeIn().css('display', 'flex');
setTimeout(function () {
window.location = url[0].innerHTML;
}, 5000);
} else {
alert(response.message)
}
},
error: function () {
alert('出错啦,请稍后再试~')
}
})
};
利用 Cloudflare worker 实现后端需要的能力:
由于松烟阁支持 RSS 订阅的功能,所以在 sitemap 中提供了 post 列表,所以问题就转换成了随机获取数组元素的问题,代码如下:
if (request.method === 'OPTIONS') {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Headers":"*",
}
let allowHeader = request.headers.get("Access-Control-Request-Headers");
if (!allowHeader) {
allowHeader = "*";
}
let respHeaders: Headers = new Headers({
...corsHeaders,
// Allow all future content Request headers to go back to browser
// such as Authorization (Bearer) or X-Client-Name-Version
});
return new Response(null, {
headers: respHeaders,
})
}
// URL of the XML file you want to proxy
const xmlUrl = 'https://www.edony.ink/sitemap-posts.xml';
const newRequest = new Request(xmlUrl, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS, POST, PUT',
'Access-Control-Allow-Headers': '*',
'Content-Type': "text/xml"
}
});
// Fetch the resource and return the response
const response = await fetch(newRequest, {
method: 'GET',
});
const body = await response.text()
// Return the response with the added CORS headers
let newResp = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
newResp.headers.set("Access-Control-Allow-Origin", "*");
newResp.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
return newResp;
有赛博女菩萨之称的 Cloudflare 提供了免费的 D1 用于持久化数据,worker 的访问计数统计数据就是基于 D1 实现的,具体代码如下:
try {
const list = await env.counter.list();
console.log(list);
const value = await env.counter.get("blog_refer");
if (value === null) {
return new Response("Value not found", { status: 404 });
}
let count = Number(value);
count++;
await env.counter.put("blog_refer", count.toString());
let newResp = new Response(count.toString());
newResp.headers.set("Access-Control-Allow-Origin", "*");
newResp.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
newResp.headers.set("Access-Control-Max-Age", "86400");
newResp.headers.set("Access-Control-Allow-Headers", "*");
return newResp;
} catch (err) {
// In a production application, you could instead choose to retry your KV
// read or fall back to a default code path.
console.error(`KV returned error: ${err}`)
return new Response(`KV returned error: ${err}`, { status: 500 })
}
worker 利用 Telegram Bot 向管理员发送彩蛋用户相关的通知,代码如下:
if (request.method === 'OPTIONS') {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Headers":"*",
}
let allowHeader = request.headers.get("Access-Control-Request-Headers");
if (!allowHeader) {
allowHeader = "*";
}
let respHeaders: Headers = new Headers({
...corsHeaders,
// Allow all future content Request headers to go back to browser
// such as Authorization (Bearer) or X-Client-Name-Version
});
return new Response(null, {
headers: respHeaders,
})
}
// parse request as json object
const notify: Notify = await request.json()
const notification: string = `<b>🔔[notification]🔔</b>
[<b><i>name</i></b>]: ${notify.name}
[<b><i>email</i></b>]: ${notify.email}
[<b><i>description</i></b>]: ${notify.description}`
// Deal with response asynchronously
let query = '?' + new URLSearchParams({
chat_id: ${ID},
parse_mode: 'HTML',
text: notification,
}).toString()
const res = (await fetch(`https://api.telegram.org/bot${TOKEN}/sendMessage${query}`)).json()
let newResp = new Response(JSON.stringify(res, null, 2));
newResp.headers.set("Access-Control-Allow-Origin", "*");
newResp.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
newResp.headers.set("Access-Control-Max-Age", "86400");
newResp.headers.set("Access-Control-Allow-Headers", "*");
return newResp;
在实现当前博客虫洞方案之前还是走了一点弯路的,开始的时候想尝试通过修改主题 NDawn 的模版来实现 post 随机入口,大致的思路就是创建一个 random-post.hbs(模版),然后利用这个模版创建一个 page,示意代码如下:
{{#get "posts" limit="all"}}
{{#foreach posts random=1 limit=6}}
. . .
{{/foreach}}
{{/get}}
顺着这个思路深入下去的时候发现,ghost handlesbar helper 函数没有 random 的功能支持,所以需要在 ghost core 中增加 helper 函数。但是就算有支持 random helper 函数也无法实现我的需求,问题就出在 ghost 是后端模版引擎,这就意味 random helper 是在后端执行完成好了由前端渲染,所以前端拿到的 post 并不是随机的而是固定的,因为 random post 相关的内容已经在后端生成好了,在前端只是渲染。
ghost 提供了 content API,所以可以利用 content API SDK 来实现随机文章的入口,如下代码所示(其中 key 就是 ghost admin 管理中自己创建的 content API key):
<script src="https://unpkg.com/@tryghost/content-api@1.3.2/umd/content-api.min.js"></script>
<script type="text/javascript">
const api = new GhostContentAPI({
url: 'https://example.ghost.test',
key: '86f8c06bb62e02383b5272206d',
version: 'v2'
});
const shuffle = (array) => {
var currentIndex = array.length, temporaryValue, randomIndex;
// While there remain elements to shuffle...
while (0 !== currentIndex) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
};
api.posts
.browse({limit: 'all', fields: 'url, title'})
.then((posts) => {
var randomPosts = shuffle(posts);
$(".random-post-link").replaceWith("<li><a href='" + randomPosts[0].url + "'>" + randomPosts[0].title + "</a></li>");
})
</script>
将上述代码通过 ghost code injection 的方式插入到指定的 page/post 中就可以实现文章随机入口了。这个方法最大的问题就是在代码中暴露了 Content API Key,这会导致很高的安全风险的 —— ghost post 的数据是可以通过 Content API Key 进行增删改查的,所以这个方法也不行。
上面两个不成功的尝试,其实是我这个前端门外汉没弄清楚模版引擎是前后端的:
两者的区别在于,前端模板引擎通常与客户端代码一同工作,而后端模板引擎则与服务器端代码一同工作。因此,前端模板引擎更适合用于动态数据呈现,而后端模板引擎则更适合用于生成静态页面。
第一种方法不可行是因为我搞错了 ghost 其实是一个后端模版引擎,所以前端拿到的渲染数据是后端已经生成好了的,并不是动态随机的;第二种方法其实就是前端模版引擎,但是有致命的安全缺陷。