From hexo to astro
从 hexo 到 astro

Posted on techjavascriptcode代码

I have been using astro for this blog for quite a long time now. I will write here how I migrated from hexo.

我现在在这个 blog 用 astro 已经挺长时间了。 来写写我怎么从 hexo 迁移的。

This will not be an introduction to astro. Instead, it covers some major challenges I faced when I tried to implement in astro the same thing I have in my original hexo blog, and how I resolved them.

本文不是对 astro 的介绍。相反地,它包括了一些我试图在 astro 里实现我原来的 hexo 博客里面有的东西的时候遇到的挑战,跟我是怎么解决的。

The start 起始

I used to have a couple of blogs before this one a long time ago using jekyll and wordpress, but this hexo blog was started in 2019, from the commit log, and I started using NexT theme 1 in 2020. I added multi-language support two months later, and it ran for a couple of years, till the end of 2023, when I read Beiyan Yunyi's blog post about astro. She describes why hexo is frowned upon, and why astro is preferred. Indeed, astro solves some problems I was facing at that time, for example, I did break the theme by accidentally removing a closing tag while dealing with conflicts. After researching on it for quite a while, I finally decided to switch to astro.

我很久以前有过一些用 jekyll 跟 wordpress 的博客,但是根据提交记录看, 这个 hexo 的博客是 2019年开始的。我在 2020年开始用 NexT主题 1。 两个月之后,我添加了多语言支持,运行了好几年, 直到 2023年底,我读到北雁云依的关于astro的博客文章。 她描述了为什么 hexo 让人皱眉,为什么 astro 更好。确实,astro 解决了一些我当时面临的问题, 比如,我之前搞坏了主题,因为我在解决冲突的时候不小心删掉了一个结束标签。 研究了好一会儿之后,我终于决定换到 astro 了。

Goals for the migration 迁移的目标

I made several goals for the migration, including:

我指定了几个迁移的目标,包括:

  • Multi-language support is retained. 多语言支持要保留。
  • RSS should work, including tag-specific RSS. RSS 得工作,包括标签的 RSS。
  • Link-to-link compatibility, including hash routing. 链接到链接的兼容性,包括 # 后面的东西。
  • Able to work without JavaScript. 没得 JavaScript 也要能工作。
  • Responsive and convergent. 要响应式,也得各平台同一。

Multi-language support 多语言支持

This is actually easier than hexo. Just define components. Add proper CSS to it. No more remembering the template language. Just use MDX/JSX, and it will work.

这个其实比 hexo 更简单。定义组件就行了。添加对的 CSS。 不用再去记模板语言了。就用 MDX 跟 JSX,就能运作了。

I want to explain why even I want this. Because the website has been there for quite some time, and there are already links in the wild leading to existing pages of the blog, and I from time to time link to my poems page. That page is very long, and each heading links to one poem. It would be very frustrated if one clicks on the link, but does not get which poem I refer to.

我想解释一下为什么我要这个。因为这个网站在那块好久了, 已经有好多野生的链接指向博客现有的页面了,而且我时不时链接到我的诗的页面。 那个页面非常长,每一个标题指向一首诗。 要是有人点开链接,但又不晓得我指的是哪首诗,那会非常抓狂。

To achieve page link compatibility, I structure /src/pages the way exactly same as the original blog will do (either hexo or NexT theme), so / for article listing with partial content, /archives/ for article listing with only titles, appending pages/X/ for pages after the first, /Y/M/D/filename for individual posts, and three individual pages: /about/, /poems/ and /tags/. List of tag-associated posts is under /tags/X/.

为了达成页面链接的兼容性,我把 /src/pages 构建成了跟原来的博客一模一样的方式(要么是 hexo 的,要么是 NexT 主题的),即:/ 是带片段的文章列表,/archives/ 是只有标题的文章列表,第一页之后加上 pages/X//Y/M/D/filename 是一个一个的文章,还有三个单独的页面:/about//poems//tags/。 有某个标签的文章的列表在 /tags/X/ 底下。

The fact that the first page has no pages/X/ suffix but all others do means that I cannot directly use astro's built-in pagination support, because apparently it will generate pages/1/ for the first page, which is not what I want. I ended up writing my own pagination helpers. The main point is to use [...page].astro as the file name (not [page].astro), and use undefined for params.page of the first page and "pages/X/" for the others. The ... allows you to have / in the params.

由于 pages/X/ 在第一页没有,而后面的页都有,我没得办法直接用 astro 内建的分页支持, 因为显然它就给第一页生成 pages/1/,这就不是我想要的了。 我最后写了自己的分页助手。主要是用 [...page].astro 作为文件名(不是 [page].astro), 然后把第一页的 params.page 设成 undefined,别的页的设成 "pages/X/"... 允许你在 params 里头有 /

For anchor compatibiliy, I directly used the slugize function by hexo. Plugging it as a rehype plugin I made by modifying the official rehype-slug, it just works.

为了锚点的兼容性,我直接用了 hexo 的 slugize 函数。 把它作为一个 rehype 插件加入进去(我自己根据官方的 rehype-slug 改的),它就能用了。

Index with partial content 有部分内容的索引

In hexo, the index page has partial content with a "read more" link, and this is achieved through a comment <!-- more --> in the post markdown. Two things are needed: first, obtain the content before the <!-- more --> comment, and render it on the page; second, have an element in the rendered post html with the id more at the place of the comment, and make a link to #more.

在 hexo 里头,索引页有一部分内容,还有「阅读更多」的链接,这是通过文章的 markdown 里头的 <!-- more --> 注释达成的。需要两件事:首先,要获取 <!-- more --> 注释前头的内容, 并且渲染到页面高头;其次,在文章的 html 里头要有一个 id 是 more 的项目,正正好好就在那个注释的地方, 然后做一个到 #more 的链接。

The first one is rather tricky, as it involves one level of indirectness. astro's getCollection gives you an array that you can call the render function of the entries and get a astro component called Content. Normally, we will use it directly in the JSX as <Content />.

第一个挺难的,因为涉及一层间接的东西。 astro 的 getCollection 给你一个数组,可以用元素的 render 函数来获得一个叫 Content 的 astro 组件。一般来讲,我们会把它直接用在 JSX 里头,用 <Content />

---
// ...
const { Content } = await post.render();
---
<Content />

However, this will render the whole post, not allowing us to render only a part of the post. On the bright side, astro provides a function that can render a slot to html string. This means we can first pass <Content /> as a slot of another astro component, and then get the html string, and manipulate the string directly:

然而,这会渲染整个文章,不让我们只渲染文章的一部分。 但好的地方是,astro 给了一个函数,可以把一个 slot 渲染成 html 字符串。 这就意味到可以先把 <Content /> 作为 slot 传给另一个 astro 组件,然后获取 html 字符串, 再直接操纵字符串:

---
// PostExcerpt.astro
import PostExcerptImpl from './PostExcerptImpl.astro';
const { post } = Astro.props;
const { Content } = await post.render();
---
<PostExcerptImpl>
  <Content />
</PostExcerptImpl>
---
// PostExcerptImpl.astro
// ...
const postHtml = await Astro.slots.render('default');
const excerpt = getExcerpt(postHtml);
---
<Fragment set:html={excerpt} />
<a href={...}>(Read more | 阅读全文)</a>

So, how to implement getExcerpt here? We add a rehype plugin that finds the <!-- more --> comment (or {/* more */} in MDX), and converts it into <a id="more"></a> in html, and then find this exact string in html. Everything before it is the excerpt we are looking for.

那么,怎么实现 getExcerpt 呢?添加一个 rehype 插件,寻找 <!-- more --> 注释(或者,MDX 里头的 {/* more */}),并且把它转换成 html 里的 <a id="more"></a>。再在 html 里头找这个字符串就好了。它前面的东西,就是我们要找的片段。

RSS

astro has official support for RSS, but it is very limited. It does not by default include post content, and it does not support injecting the post content directly at all. Instead, it recommends users to use another markdown renderer in the RSS. This means you cannot render MDX, JSX, or astro components into the RSS. This is not acceptable for me, because what I want is to allow people read my blog directly in the RSS reader.

astro 官方支持 RSS,但非常有限。它默认不包括文章内容,而且也完全不支持直接插入文章内容。 相反地,它在 RSS 里推荐用户用另一个 markdown 渲染器。 这就意味到你在 RSS 里头没得办法渲染 MDX,JSX,或者 astro 组件了。 这是不能接受的,因为我想要允许我博客的读者直接在 RSS 阅读器里看它。

An apparent trick is to use what I have already used for the post excerpt: post.render() and <Content />. However, this is only for astro components. Moreover, astro components can currently be used to generate html outputs -- it adds the doctype header, making it unsuitable for xml outputs. There is an issue in astro, which can be eventually traced to this merge request on Container API. At the time of writing this post, it is not yet available in astro. There is also another person's work on how to generate RSS for astro. I think I probably saw it when I was adding RSS support for this blog, but I eventually took a slightly different approach.

一个显而易见的技巧是,用我之前用来处理文章节选的 post.render()<Content />。但是,这只能在 astro 组件里用。更何况,astro 组件只能生成 html 输出——它会添加 doctype 头部,所以是不适用于 xml 输出的。 astro 里有一个 issue 相关,最终可以追溯到这个关于 Container API 的合并请求。 在写这篇文章的时候,在 astro 里面还并不可用。又有另外一个人做了一些关于怎么给 astro 生成 RSS 的工作。 我觉得我当年给这个博客添加 RSS 支持的时候可能看过它,但是我最终采取了一个稍微不一样的方法。

First, to obtain the post content as html, I added a page in the src/pages/atomRender directory. All it does is to get all posts, and render just the content of those posts as html. In this way, the content is available as local files.

首先,要以 html 的的形式获得文章内容,我在 src/pages/atomRender 目录底下添加了一个页面。 它做的所有事情就是获得所有的文章,然后只把这些文章内容渲染成 html。 这样,内容就以本地文件的形式可用了。

Second, we generate some metadata for the RSS builder. The metadata should at least include which posts should be rendered, and the title, date, and anything that you want to include in the RSS. This is achieved by creating a file called X.js, where X is the output file name, and exporting a function called GET from the js file. This, of course, does not include the actual rendered post content, because it is not an astro component, and the <Content /> is only available in astro components. I created two of them, one for the whole blog and one for tags.

其次,给 RSS 构建器生成一些元数据。元数据要至少包括有哪些文章需要被渲染,跟它的标题,日期, 还有你想要在 RSS 里放的任何东西。可以创建一个叫 X.js 的文件,其中 X 是输出的文件名, 然后从这个 js 文件里导出一个叫 GET 的函数。当然了,这个文件当然不会包括文章的实际内容, 因为它不是 astro 组件,而只有 astro 组件里才能用 <Content />。 我创建了两个这样的文件,一个给整个博客,另一个[给标签][matadatajsonTag]。

The final step is to combine the metadata and the actual post content after they are all generated. My approach is to add an astro plugin that builds the RSS as a hook. An apparent disadvantage is that it is not built when we are running the local dev server, and it is not suitable if your site is not statically generated. But for my purpose, it is sufficient.

最后一步就是在把元数据跟实际文章内容都生成了之后,把它们给组合起来了。 我的方法是添加一个 astro 插件来用钩子构建 RSS。 一个显然的劣势是,在运行本地开发服务器的时候,RSS 是没得被构建的, 而且如果你的网站不是静态生成的,那它就没得办法用。但是对我来讲,就够了。

Responsive design without JavaScript requirement 不强制要 JavaScript 的响应式设计

The NexT theme of hexo does not actually meet this requirement, because on mobile, the sidebar is either completely hidden (not convergent) or is only activable via JavaScript. I want my site to function even without JavaScript (it can still contain JavaScript, but all functionalities should still be available when it is not available).

hexo 的 NexT 主题实际上不满足这个要求,因为在移动端,侧边栏要么是完全隐藏的(各平台不同一), 要么就只能经由 JavaScript 启用。我希望我的站点就算没有 JavaScript 也能工作(它还是可以包括 JavaScript,但所有功能在 JavaScript 不可用的时候仍然应该可用)。

There are three responsive parts on the site: the navigation bar, the site info panel, and the table of contents for each page. The site info panel is shown as a sidebar when the page is wide enough, and at the bottom otherwise. The navigation bar is shown horizontally when the page is wide enough, and vertically as a drop-down menu when it is not wide enough. When JavaScript is available, the drop-down menu is hidden at page load, and shown when the activating button is clicked. When JavaScript is not available, it is always displayed. The table of contents is similar, displaying in the sidebar when the page is wide enough. Otherwise, it is displayed at the top of the post, collapsed by default if JavaScript is available, and always expanded if JavaScript is not available.

站点上有三个响应式的部分:导航栏,站点信息面板,和每页的目录。 站点信息面板在页面够宽的时候显示为侧边栏,否则就显示在最底部。 导航栏要是页面够宽,就显示为横向的列表,不然就显示成竖的下拉菜单。 当 JavaScript 可用的时候,下拉菜单在页面加载的时候是隐藏的,按下激活按钮就会显示。 当 JavaScript 不可用的时候,它就一直都显示了。 目录是类似的,在页面够宽的时候显示在侧边栏里。不然,就显示在文章的最上面, 要是 JavaScript 可用就默认折叠,JavaScript 不可用就一直展开。

See below | 见下

Navigation bar on a wide screen, showing the entries horizontally 宽屏上的导航栏,横向显示项目

See below | 见下

Navigation bar on a narrow screen, with a button to toggle the vertical list 窄屏上的导航栏,有一个按钮来开关纵向列表

See below | 见下

Navigation bar on a narrow screen, with an always-displayed vertical list when JavaScript is not available 窄屏上的导航栏,当 JavaScript 不可用时,有一个始终显示的纵向列表

Footnotes

  1. This theme has gone through many forks and has various versions. The linked one is the one I use.
    这个主题经历了很多复刻,有多个版本。链接了的是我当年用的版本。
    2