Blog迁移的一些事
最近把Axis Blog从Ghost迁移到了HUGO,经过了一段时间的完善与小修小补,总算是接近尾声了,顺便还整理了图片、博文和评论。发现有些评论没及时回复,真的抱歉。
slug调整
Blog建立初期,写了就发,完全不知道slug这回事。Ghost的默认行为是将汉语的标题转换成拼音并且用短横线分隔作为slug,一眼也看不来是什么含义。
逐渐发现很多博客的url都有自己的规则,才知道slug指的是带有语义的url。后来会在发布博文之前会手动修改Ghost自动生成的slug,一般是"时间-描述",新发布的文章得以规范,以前的就随它去了,懒得改,随意修改也会导致评论URL改变无法加载评论。
迁移工具ghostToHugo会把slug作为markdown的文件名,看着千奇百怪的文件名终于还是下决心统一改了一遍🤣。
评论区
评论区基于Waline,之前因为DNS配置的关系坏了一段时间,无法留言,目前已经修复了。
Waline的评论读取基于文章的URL,改slug自然会导致部分文章的URL变更,评论无法加载。只能把数据导出,根据新老URL进行替换。好在我评论并不多,否则可能要写个脚本来做这件事。
Waline的评论默认存在Learncloud中,控制台左边结构化数据->有个Class叫做Comment中保存了全部的评论。我勾选了它导里面的所有数据并下载到本地,这是个JSON文件。修改替换URL之后再导回了Learncloud就大功告成了。不过有以下几个点要注意:
- 导出后要删掉原来的Comment对象,导入时设置Class名称为Comment,否则无法导入。
- 通过勾选->下载得到的JSON数据中,时间是以
2017-05-02T14:42:40.000Z
格式存储的,没有带类型信息。导回去之时这一列的类型变成了String,后续再新增评论时会报类型转换错误。createdAt、insertedAt、updatedAt这三列会受到影响。解决方法就是导入时补全时间类型,如下
{
"QQAvatar": "",
"comment": "<p>哈哈哈,有缘人!</p>",
"createdAt": "2017-05-21T12:29:54.000Z",
"insertedAt": "2017-05-21T12:29:54.000Z",
"ip": "",
"link": "",
"mail": "",
"nick": "Kasai",
"objectId": "3317233467",
"pid": "3317216815",
"rid": "3317216815",
"status": "approved",
"ua": "",
"updatedAt": "2017-05-21T12:29:54.000Z",
"url": "/posts/20170512-songjiang-landscape/"
}
改成
{
"QQAvatar": "",
"comment": "<p>哈哈哈,有缘人!</p>",
"createdAt": {
"__type": "Date",
"iso": "2017-05-21T12:29:54.000Z"
},
"insertedAt": {
"__type": "Date",
"iso": "2017-05-21T12:29:54.000Z"
},
"ip": "",
"link": "",
"mail": "",
"nick": "Kasai",
"objectId": "3317233467",
"pid": "3317216815",
"rid": "3317216815",
"status": "approved",
"ua": "",
"updatedAt": {
"__type": "Date",
"iso": "2017-05-21T12:29:54.000Z"
},
"url": "/posts/20170512-songjiang-landscape/"
}
这可能需要编写一些脚本来处理数据。
后来发现使用“导入导出”->“数据导出”够避免类型丢失的问题。
图片存储
Axis迁移了几次,从搬瓦工到vultr,再签到vultr东京机房,再到这次使用HUGO重建,图片一直都是个问题。
先是使用了Ghost的上传功能;后来怕流量不足,使用了七牛云;再后来,将一部分全尺寸大图通过500px.com/图虫外链的方式引入,形成了本地、七牛云、图床缝合怪;到了2021年引入了backblaze,新的博文图片全部上传到backblaze,更多地缝合。
折腾了一大圈。
图片作为我的Blog的主要流量消耗源,解决方案也并不是说哪种好或者哪种不好,而是随着图片尺寸、VPS提供商的免费流量、CDN等因素综合考虑的。
Ghost的图片上传功能其实不错,会自动生成多个尺寸的缩略图,还保留了原图,就我这点图片量和vultr提供的2TB免费流量,完全可以一用。但是它的多图添加完全不像单图片一样支持添加链接,而是直接上传到vps上,常见的对象存储倒也都支持,backblaze、七牛云,都有adapter,我这样的有时候从500px或者图虫外链过来的就没什么办法,所以我经常手写html代码。另外就是它的编辑器,插入图片的方式总让我觉得我的文章被打断了,中间强行插入了一个相册,然后再在下面插入一个文章片段。
换到了HUGO的过程中,我在调试主题的时候居然直接把backblaze一天的流量刷没了😂。1GB流量不套CDN实在是太小了,而我这样没有备案的BLOG自然不可能用到国内的CDN,cloud flare又实在是太慢。思来想去,既然VPS已经有了2TB的流量,存储空间是25GB还比blackblaze的免费10GB大,那干脆还是用VPS的本地存储吧,如果是“大片”,那依然引用图虫的链接。
又回到了使用backblaze之前的状态。
backblaze我一直是手动上传,并且按照博文的slug作为目录,所以只要把图片打包下载,扔到static目录系下,再统一把图片的域名修改成一个本站的路径就OK了,也没浪费什么功夫。倒是整理博文,补全缺失的图片,花了很长时间,参考七牛云跨区同步找回了因测试域名过期而丢失的图片,很高兴。
HUGO只是个静态网站生成器,并不会对图片进行处理。写了个nodejs的脚本来压缩整个static目录下的图片,自动压缩并生成宽度为1080p的小尺寸图片或者是webp格式的图片,不过本Blog的webp尚未实装。1080p的图片的文件大小已经很小了,我也并未生成更小尺寸的缩略图。
const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
const MAX_EDGE_SIZE = 1080;
const QUALITY = 80;
const DIRECTORY_PATH = './static/images';
const logFileName = `logs/log_${new Date().toISOString().replace(/[:.]/g, '_')}.txt`;
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function processDirectory(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await processDirectory(fullPath);
} else if (entry.isFile()) {
await processFile(fullPath);
}
}
}
async function processFile(filePath) {
const fileExt = path.extname(filePath).toLowerCase();
const fileName = path.basename(filePath, fileExt);
const directory = path.dirname(filePath);
// 排除以 "_c" 结尾的文件
if (fileName.endsWith('_c')) {
console.log(`Skipping file: ${filePath}`);
return;
}
const compressedFilePath = path.join(directory, `${fileName}_c${fileExt}`);
const webpFilePath = path.join(directory, `${fileName}.webp`);
if (['.jpg', '.jpeg', '.png'].includes(fileExt)) {
console.log(`Processing file: ${filePath}`);
try {
// Condition 1: Generate compressed file if it does not exist
if (!await fileExists(compressedFilePath)) {
const { width, height } = await sharp(filePath).metadata();
let newWidth, newHeight;
if (Math.max(width, height) > MAX_EDGE_SIZE) {
const scalingFactor = MAX_EDGE_SIZE / Math.max(width, height);
newWidth = Math.floor(width * scalingFactor);
newHeight = Math.floor(height * scalingFactor);
} else {
newWidth = width;
newHeight = height;
}
await sharp(filePath)
.resize(newWidth, newHeight)
.jpeg({ quality: QUALITY })
.toFile(compressedFilePath);
}
// Condition 2: Generate webp only if it does not exist
// if (!await fileExists(webpFilePath)) {
// await sharp(compressedFilePath)
// .webp({ quality: QUALITY })
// .toFile(webpFilePath);
// }
} catch (err) {
console.error(`Error processing ${filePath}: ${err}`);
await fs.appendFile(logFileName, `Error processing ${filePath}: ${err}\n`);
}
}
}
(async () => {
await fs.mkdir('logs', { recursive: true });
await processDirectory(DIRECTORY_PATH);
})();
多图展示
然后写了个shortcode作为多图添加的快捷方式。多图展示最麻烦是如何排布,像是500px那样的瀑布流ChatGPT给我的解答是需要复杂的计算。我找了几个瀑布流的开源库,也都要填入宽度,对于我这样图片尺寸混乱情况并没有很好的效果。索性直接用flex布局,设置成可换行。
外层是gallery.html
{{ $id := .Get "id"}}
{{ $desc := .Get "desc" | safeHTML}}
<div class="axis-gallery">
<figure class="axis-gallery-inner">
{{ with $id}}
<div class="axis-gallery-images" id="{{ $id }}">
{{ else }}
<div class="axis-gallery-images">
{{ end }}
{{ .Inner }}
</div>
{{ with $desc}}
<hr>
<figcaption>{{ $desc }}</figcaption>
{{ end }}
</figure>
</div>
内部是img.html,通过接收入参中的w参数,在生成静态网站时设置样式指定宽度的百分比,对于未传参数的图片直接用98%作为宽度值。使用媒体查询,当屏幕尺寸过小时,不再设置宽度,直接98%。
另外,使用了_c作为压缩后的预览图地址,这需要配合上面的js脚本使用,否则会找不到图片。
{{ $src := .Get "src" }}
{{ $ext := path.Ext $src }}
{{ $base := strings.TrimSuffix $ext $src }}
{{ $webp := printf "%s.webp" $base }}
{{ $compressed := printf "%s_c%s" $base $ext }}
{{ $width := .Get "w" | default "98" -}}
{{ with .Parent }}
<div class="axis-gallery-image img-width-{{$width}}">
{{ if or (strings.HasPrefix $src "https") (strings.HasPrefix $src "http") }}
<img src="{{ $src }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox style="max-width: 100%; height: auto;">
{{ else }}
<!-- <source srcset="{{ $webp }}" type="image/webp"> -->
<img src="{{ $compressed }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox style="max-width: 100%; height: auto;">
{{ end }}
</div>
{{- else -}}
<div class="axis-gallery-image" style="width: 98%;">
<!-- <source srcset="{{ $webp }}" type="image/webp"> -->
<img src="{{ $compressed }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox>
</div>
{{ end }}
<style>
.img-width-{{$width}}{
width: 100%;
}
@media (min-width: 576px) {
.img-width-{{$width}}{
width: {{$width}}%;
}
}
</style>
然后是一些CSS,很简单,给flex布局设置了flex-wrap: wrap;
来允许换行。当宽度之和超过100%时,自动换行。
.axis-gallery-image{
display: flex;
width: 98%;
}
.axis-gallery{
max-width: $content-max-width;
width: 100%;
.axis-gallery-inner{
figcaption{
font-size: 0.8rem;
color: var(--extra-info);
margin: 0 0.5em;
}
hr{
margin: 0.2em;
}
.axis-gallery-images{
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap; /* 允许换行 */
.axis-gallery-image{
margin: 0.5em auto;
}
}
}
}
写作的时候需要简单计算宽度值,通过手动调整宽度值来使得几张照片的高度大致相等,并不算麻烦。
用法是这样。
我追寻着她,从江苏如东的风车海岸到西北腹地的广袤沙漠。
{{< gallery desc="沙丘上简洁的s曲线" >}}
{{< img src="https://photo.tuchong.com/1358305/f/310303977.jpg">}}
{{< /gallery >}}
{{< gallery desc="沙漠上空的一朵小云" >}}
{{< img src="https://photo.tuchong.com/1358305/f/434429562.jpg">}}
{{< /gallery >}}
{{< gallery desc="阿拉善左旗的大漠风光" >}}
{{< img src="https://photo.tuchong.com/1358305/f/280092526.jpg" w="96">}}
{{< img src="https://photo.tuchong.com/1358305/f/118611490.jpg" w="34">}}
{{< img src="https://photo.tuchong.com/1358305/f/130538892.jpg" w="30">}}
{{< img src="https://photo.tuchong.com/1358305/f/210034271.jpg" w="30">}}
{{< img src="https://photo.tuchong.com/1358305/f/373612056.jpg" w="18">}}
{{< img src="https://photo.tuchong.com/1358305/f/553377931.jpg" w="40">}}
{{< img src="https://photo.tuchong.com/1358305/f/279961121.jpg" w="40">}}
{{< /gallery >}}
可以设置每张图片的宽度来达到不同的排布效果,在img标签上配置data-ngsrc="{{ $src }}" data-nanogallery2-lightbox
来初始化nanogallery2的lightbox,点击后展示大图,参考效果。
为vscode添加一个snippet,可以快速输入。
"hugo gallery shortcode": {
"prefix": "agallery",
"body": [
"{{< gallery desc=\"$1图组描述\" >}}",
" {{< img src=\"$2图片链接\" w=\"$3宽度占比\">}}",
"{{< /gallery >}}"
],
"description": "Insert hugo gallery shortcode"
}
并在vscode的设置中启用对markdown文件的提示
"[markdown]": {
"editor.quickSuggestions": {
"comments": "on",
"strings": "on",
"other": "on"
}
},
这样就解本地图片的尺寸问题和博文中照片的排列问题。像这样定义自己代码块,并且快捷输入,Ghost的编辑器是做不到的。
同理,还可以自定义各种各样的shortcode,可以参考来写一些好玩的 Hugo 短代码吧,这是个非常精美的Blog。
自动构建
开始的几个版本,我手动执行HUGO,然后zip压缩后用SFTP上传到服务器,这个阶段主要是在测试HUGO的功能。正式使用时就感受到这一点都不Geek,也很麻烦,图片多了之后全量上传也很浪费时间。最后还是搞了一个自动化构建发布。
乞丐版CI/CD了属于是。
- 在家中的NAS上初始化了一个git bare仓库,将写完的代码push到bare仓库。这也省去了备份的功夫。
- 在NAS上的仓库中添加post-receive脚本,脚本内容是接收到内容就checkout到一个临时目录,调用hugo构建站点。
- 在构建输出的public目录中创建一个新的本地git仓库,push到VPS上的仓库中,这一步也是在上面的post-receive脚本中进行。
- 在VPS上初始化一个git仓库,用于接收3中的推送。同时也添加一个post-receive脚本,接收到内容时checkout到/var/www/html/目录中。
搞这么复杂的初衷是希望通过git来管理,只上传改动过的文件。
结果事与愿违,跑了一下发现原来HUGO并不是增量构建的,每次构建完后git commit时发现所有文件都变了😩。于是把3、4步改成了用rsync,在windows的gitbash中安装了rsync,然后用rsync –checksum同步到vps的/var/www/html中,这样既能通过校验和按需同步,也简化了步骤,最重要的是节省了VPS上用于版本管理的空间。
当然了,其实完全可以把bare仓库放到VPS上,流程会更简单。但这样又要存源文件,又要编译输出,相当于存储了双份的图片,剩下的20GB空间不久就会捉襟见肘,想了想还是算了。
于是写作流程就搞完了,也比之前用Ghost的编辑器稍微Geek一点点🤣。
文章发布日期
过了几天,又碰到了两个关于时间的问题。
如果不小心把文章的frontmatter数据中的date设置成一个未来的时间,那运行hugo -D server是看不到这篇文章的。这个问题坑了我很久。一度认为必须用hugo new posts/xxx.md生成出来的文章才能被HUGO识别,这不科学。其实是执行hugo new posts/xxx.md生成出来的文章用的是本机的时区和当前时间,不会超过当前时间。
另一个问题是ghostToHugo这个工具会把ghost中的创建时间(还是发布时间)作为frontmatter的date字段,是形如2016-08-02T19:05:49Z
的UTC时间。在HUGO中,即使在config.toml中设置了时区,或者是date字段本身带了一个Z后缀表示这是UTC时间,到了模板中使用{{ .Date.Format "2006年01月02日" }}
时,如果不指定时区那还是会将2016-08-02T19:05:49Z
中的Z
忽略,作为本地时间来处理。这就导致了我所有博文的时间都提前了8个小时。我一直在疑惑,我的第一篇文章应该是生日当天写的,怎么还早了一天呢。
于是又只能靠脚本来读取所有的文章,统一将时间修改成UTC+8的时间。
const fs = require('fs');
const matter = require('gray-matter');
const toml = require('@iarna/toml');
const {glob} = require("glob");
const dir = "./content/**/*.md"
// 用于将UTC日期转换为UTC+8
function convertToUTC8(dateString) {
console.log(dateString + '转换为UTC+8');
const date = new Date(dateString);
if (date.toISOString().endsWith('Z')) {
date.setHours(date.getHours() + 8);
const iso = date.toISOString()
const newDate = iso.slice(0, 19) + '+08:00';
return newDate;
}
return date;
}
// 遍历指定目录中的所有Markdown文件
glob(dir).then(function (files) {
files.forEach(file => {
// 读取文件内容
const fileContent = fs.readFileSync(file, 'utf8');
try {
// 解析Front Matter
const content = matter(fileContent,{
engines: {
toml: toml.parse.bind(toml),
},
language: 'toml',
delimiters: '+++'
});
const data = content.data
if (data.date) {
data.date = convertToUTC8(data.date);
}
const updatedContent = matter.stringify(content, data,{
engines: {
toml: {
parse: toml.parse.bind(toml),
stringify: toml.stringify.bind(toml)
}
},
language: 'toml',
delimiters: '+++'
});
// remove quota
const fixedFrontMatter = updatedContent.replace(/date = "([^"]+)"/, 'date = $1');
console.log(`Updated ${file} with ${fixedFrontMatter}` );
fs.writeFileSync(file, fixedFrontMatter, 'utf8');
} catch (error) {
console.log(file,error);
}
});
});
这样一来,类似2016-08-02T19:05:49Z
的时间都就都改成了2016-08-03T03:05:49+08:00
了。
PS:不得感叹AI的强大,很多不知道怎么搜索的问题只要问ChatGPT就可以了,还完全不用看令人头大的HUGO文档。