使用 Noutious 和 SvelteKit 构建一个船新的博客
· 技术让我用一篇文章告诉你:一个年中就挖出来的坑,是如何用了几个月的时间才填满的(
Noutious:内容管理的预备
翻了一下之前写的烂文、重新确认了一下我在 2023 年入门的 Nuxt。在使用 Nuxt 搭建博客的日子里,我都在用 Nuxt Content 管理内容,但就是感觉哪里不太好用——
- 当时 Nuxt Content 还在 v2 版本、强制要求启用 SSR,以便将处理好的数据从服务端传给客户端(API Fetch),但全站预渲染(SSG)出来会有点灾难、一个体现就是渲染时长会变得非常长......
- Nuxt Content 要搭配 MDC 使用,接其它的 Markdown Renderer 非常的不方便,MDC 这玩意我也不是很喜欢。
然后就这么过了很久很久、到了 2025 年,NuxtLabs 终于发布了 Content v3 版本......欸不对,这不就是 Content Collections 吗??
当时已经有用别的框架造网站的想法了(对的,包括今天讲的这个博客),而我早期也尝试过 Content Collections、但真心觉得并不好用,那要不自己写一个算了。于是 Noutious 就这么诞生了——很大程度上受到 Hexo Warehouse 数据结构的启发、融合了一点 Nuxt Content 的用法进去的产物。由于这玩意写出来是给自己用的,这里就不过度鼓吹了。
Next.js (Pages Routes) 和 SvelteKit 有一个我喜欢的点:你可以通过 Props 将在服务端处理好的数据丢给客户端。但在 Nuxt 里,由于它携带了一个完整的后端(Nitro),因此数据传递通常是客户端组件里用 useFetch() 从后端拉下来。鉴于 Nuxt Content v2 那堆神秘的 API Routes 拖慢了预渲染速度,useAsyncData() 和 Nuxt Server Components 都有可能在客户端(浏览器)上运行,我一直在尝试各种方案、让 Noutious 完成数据传递这份工作。显然这是不可取的、多次尝试遭遇失败之后我也老实了,用 useFetch() 就用吧,无所谓了(
将代码 rollback 到 0.0.1 还是 0.0.2 的某个版本、添加一些比较人性化的功能之后,这个各方大神支持下写出来的 CMS 也算能用了,可以进一步推进别的东西了(
SvelteKit:成熟之后的深度尝试
记得好早之前因为新鲜感尝试了 SvelteKit,一年过去之后回去看看最初写的代码、实在是没眼看。正好,在之前想重构博客但飘忽不定的日子里,有群友推荐过 SvelteKit,也算是这次用它构建新博客的一个契机。
读过一遍文档之后,发现 SvelteKit 也提供了很多好用的 API、能完美实现我在 Nuxt 上做的各种实现。那就直接无脑选择、直接开工吧。
从服务端到客户端的博客数据
为了方便调用 Noutious,先在 src/lib 下建一个 noutious.ts、提前做好初始化:
import { createNoutious } from 'noutious';
let instance: Awaited<ReturnType<typeof createNoutious>> | null = null;
export async function initNoutious() {
if (!instance) {
instance = await createNoutious({
baseDir: './',
persist: process.env.NODE_ENV === 'production'
});
}
return instance;
}
SvelteKit 会为 src/lib 下的内容创建 alias、后续可以通过 $lib/noutious 直接导入使用。
或许你和当时的我一样、看不懂 src/routes 下那些 +page.svelte、+layout.svelte、+page.ts、+layout.ts 都是些什么意思。前两个分别是页面组件和布局组件,后面两个则是 SvelteKit 设计的、用于在前两个组件渲染之前获取数据的文件。
命名为
+page.server.ts或+layout.server.ts会让它仅在服务端上运行。
import type { PageServerLoad } from './$types';
import { initNoutious } from '$lib/noutious';
export const load: PageServerLoad = async () => {
const noutious = await initNoutious();
const posts = await noutious.queryPosts({
sort: { date: -1 }
});
return {
title: '首页',
posts
};
};
这里在 +page.server.ts 里调用了 Noutious、获取并返回了从新到旧排序后的文章数据。返回的数据可以在 +page.svelte 组件里用 data Props 接收:
<script lang="ts">
import type { PostSlim } from 'noutious/types';
let { data }: { data: { posts: Record<string, Posts> } } = $props();
</script>
{data.posts}
不知为何、从
dataProps 接到的数据是没有类型的,这里就用到了 Noutious 导出的类型声明解决问题(
简易的 UI 实现
我对 UI 设计基本毫无头绪,也对那些设计非常精美的 UI 不感冒。于是,大道至简为上,UI 就实现得简单一点好了~
UnoCSS 永远的神。
虽然之前沉迷过一段时间 Atomic CSS-in-JS,但不得不承认托尼老师写的 UnoCSS 还是很适合我这种懒人使用。
安装使用实属简单、这里就简单讲一下我用到的一个转换器:transformerDirectives。
import { defineConfig, presetWind4, transformerDirectives } from 'unocss';
export default defineConfig({
presets: [
presetWind4()
],
transformers: [
transformerDirectives({
/* 重点在这里 */
applyVariable: '--apply'
})
]
});
用了 transformerDirectives 之后,我可以在 CSS 文件里这么写、让鼠标悬浮于 <a> 元素时增加下划线:
a {
--apply: "hover:underline"
}
UnoCSS 在最终编译时会帮你转换成最终的 CSS 规则,省去了找 TailwindCSS 文档、并扒 CSS 规则下来再贴上去的步骤。
根据系统偏好切换深色模式
考虑到不是特别多人会去天天调网站的深浅色模式,这里就直接替用户做决定、跟随他们的系统偏好来设定。
UnoCSS 默认自带的 presetMini、presetWind3 和 presetWind4 可以设置根据类名还是 CSS Media 应用深色样式:
import { defineConfig, presetWind4, transformerDirectives } from 'unocss';
export default defineConfig({
presets: [
presetWind4({
/* 重点在这里 */
dark: 'media'
})
],
transformers: [
transformerDirectives()
]
});
CSS 重置
如果不做这一步,浏览器会套一点默认的 CSS 规则给你。这里采用了KazariEX 老师的 通配选择器法。
@humanspeak/svelte-markdown
svelte-markdown 是一个由 pablo-abc (Pablo Berganza) 开发维护的 Svelte Markdown 渲染器,最后一次更新是 2023 年 12 月、并不支持当前的 Svelte 5。这里采用的是由 Humanspeak, Inc. 维护的版本,有着比原先版本更强的特性。
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown';
</script>
<SvelteMarkdown source={} />
因为有过被劫持跳转到 ** 网站、原本好好的链接突然变成 ** 网站的经历, 我在今年额外造了一个 二次链接跳转页,因此需要在渲染 Markdown 内容时对 <a> 作一些修改。而这个包正好可以修改渲染节点:
<script lang="ts">
import SvelteMarkdown, { defaultRenderers } from '@humanspeak/svelte-markdown';
import PostLink from '$lib/components/content/Link.svelte';
const customRenderers = {
...defaultRenderers,
link: PostLink
};
</script>
<SvelteMarkdown source={} renderers={customRenderers} />
Atom RSS 和近期文章 JSON 的生成
为了方便访客订阅文章更新,基础的 RSS 还是要提供的;同时,我的个人主页上要展示博客近期的文章,需要博客输出一个近期文章的 JSON 供个人主页使用。这里使用了 KazariEX 老师的 zfeed 生成 Atom RSS 数据,并通过 SvelteKit 预渲染 RSS 路由文件:
import type { RequestHandler } from './$types';
import { initNoutious } from '$lib/noutious';
import type { PostSlim } from 'noutious/types';
import { createFeed, generateAtom1 } from 'zfeed';
export const prerender = true;
export const GET: RequestHandler = async () => {
const noutious = await initNoutious();
const postsRaw: Record<string, PostSlim> = await noutious.queryPosts({
sort: { date: -1 },
limit: 20
});
const posts = Object.entries(postsRaw).map(([slug, post]) => ({
id: `https://thoughts.gxres.net/post/${slug}`,
title: post.title,
date: post.date,
link: `https://thoughts.gxres.net/post/${slug}`,
description: post.excerpt,
publishedAt: new Date(post.date),
updatedAt: new Date(post.updated),
content: ``,
author: {
name: 'Restent Ou',
email: 'i@gxres.net',
link: 'https://www.gxres.net'
}
}));
const feed = createFeed({
/* zfeed options */
});
return new Response(generateAtom1(feed));
};
和 Hexo Warehouse 不同,Noutious 的所有文章数据类型是
Record<string, PostSlim>,因此要转换成 Array 才能够喂给 zfeed 处理。
由于近期文章 JSON 不是给访客订阅的,因此不需要 zfeed 来处理,直接省去 createFeed 这一步并 return json(posts) 即可。
在 +layout.svelte 接收页面返回的数据
在将文章数据从服务端传递给客户端的部分,我额外返回了一个 title 字段。这个不是摆设,而是 SvelteKit 提供了一个非常好用的模块:$app/state。我可以引入其导出的 page 模块、以 page.data.title 的形式使用在 +page.server.ts 里导出的 title 字段,并最终填充到 +layout.svelte 里。
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>{page.data.title ? `${page.data.title} – R2's thoughts` : "R2's thoughts"}</title>
</svelte:head>
一个动态标题就这样实现了。Meta Description 也可以根据这个方法扔进去 <svelte:head> 里。
最后
R2's thoughts 这个画了几个月的饼最后就是这样实现出来的,后面会完善相关的功能再开始写作。也趁着这次的实现调整了一下 Hyperlink Redirect 和 API。
以后思考相关的内容就会在这个博客发布了,也请各位多多关照。