用 SvelteKit 做一个 Markdown 片段收藏夹

要做什么

一个 Markdown 片段收藏夹:收集常用命令、代码模板、配置片段,支持新建、编辑、删除、标签筛选,详情页把 Markdown 渲染成 HTML。技术栈就是 Svelte 系列前两篇讲的组合——Svelte 5 runes 写界面,SvelteKit 2 的 form actions 与 load 承担数据流。”单机单人使用”这个约束会让存储选型变轻,但 SSR 首屏与 XSS 消毒这两条底线不会跟着轻——后者恰好是 {@html} 类应用最容易出事故的地方。机制层面的疑问(load 怎么重跑、action 是什么)不在这里重复,见路由与数据加载详解,条目速查见参考字典。

指标 目标 怎么测
SSR 首屏 关掉 JavaScript 也能看列表、提交表单 浏览器禁用 JS 后走一遍增删改查;view-source 里能看到片段标题
校验回显 空标题提交不出库,错误信息留在表单旁 空标题点提交,URL 不变、提示出现、已填内容不丢
XSS 消毒 注入脚本不执行 存一条正文为 <script>alert(1)</script> 的片段,详情页 view-source 无 script 标签
落盘可靠性 每次保存后 data/fragments.json 都是合法 JSON 保存后用 node -e “JSON.parse(…)” 校验文件
筛选记忆 筛选状态进 URL,可分享可回退 点标签后浏览器后退,列表恢复全量

选型:三处都要有”为什么不选”

存储是最先要定的事:

方案 优点 缺点 适用
JSON 文件 零依赖、肉眼可查、备份就是复制文件 并发写会互相覆盖,数据量大了全量读写 单机单人工具(本项目)
SQLite 查询与约束齐全,单文件仍是零运维 要引入原生依赖,示例复杂度上升一档 多用户或上千条目
独立数据库 并发、备份、多实例部署全解决 为一个收藏夹装 MySQL 不成比例 多人协作服务

Markdown 渲染选 marked 加 sanitize-html:marked 只负责转 HTML,官方明确声明它不做消毒,所以输出必须过白名单;sanitize-html 是纯服务端实现。不选 DOMPurify 是因为它的服务端形态要拖着 jsdom;不选 mdsvex 是因为它面向编译期内容文件,而这里的正文存在数据文件里、每次改动都要即时生效。渲染时机选”存原文、读取时渲染消毒”——渲染器将来升级不用迁移历史数据。

提交方案是与”前端框架加 REST 接口”习惯分歧最大的一处:

方案 优点 缺点
form actions + use:enhance(本项目) 无 JS 可用;校验错误回填是一等公民;少写一层 API 响应不是 JSON,跨端复用要另起 +server.js
+server.js 写 REST + fetch 接口可给 App、脚本复用 要自己处理错误结构、失效刷新与加载态
纯客户端状态(不落服务端) 交互最跟手 刷新即丢,不符合收藏夹的本质需求

最终结构只有两个页面:

src/
├── lib/server/store.js        # 数据文件读写(仅服务端可达,编译器强制)
├── routes/
│   ├── +page.svelte           # 列表 + 新建表单 + 标签筛选
│   ├── +page.server.js        # load 全量数据;actions: create / delete
│   └── [id]/
│       ├── +page.svelte       # 详情(渲染后的 HTML)+ 编辑 + 删除
│       └── +page.server.js    # load 单条(渲染消毒);action: update

依赖安装与启动:

npx sv create fragments   # 模板选 minimal,类型检查选 TypeScript
npm install marked sanitize-html
npm run dev               # http://localhost:5173

存储层:原子写与单写者边界

// src/lib/server/store.js
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';

const FILE = resolve('data/fragments.json');

export function load() {
  try {
    return JSON.parse(readFileSync(FILE, 'utf8'));
  } catch {
    return [];   // 首次运行没有数据文件,空列表即可,不必预建文件
  }
}

export function save(items) {
  mkdirSync(dirname(FILE), { recursive: true });
  const tmp = FILE + '.tmp';
  writeFileSync(tmp, JSON.stringify(items, null, 2));
  renameSync(tmp, FILE);   // rename 在同一文件系统内是原子的,崩溃也不会留下半个 JSON
}

反模式点名:直接 writeFileSync(FILE, ...) 覆盖目标文件。写一半进程被杀(或掉电),留下的就是截断的 JSON,下次启动 JSON.parse 直接抛错,收藏夹全灭。”先写临时文件再 rename”是文件存储的最低限度仪式。另一个要说破的边界:读-改-写在多标签页同时保存时会丢更新(后保存者覆盖先保存者),单机单人可接受;一旦要多人用,把这一层换成 SQLite,actions 里的调用代码几乎不用动——这是把存储隔离在 $lib/server 的全部意义。$lib/server 目录有编译器强制:客户端代码 import 它会直接构建失败,密钥类代码放这里才睡得着觉。

列表页:action、校验回显与筛选

// src/routes/+page.server.js
import { fail, error } from '@sveltejs/kit';
import { load, save } from '$lib/server/store.js';

export function load() {
  return { fragments: load() };
}

export const actions = {
  create: async ({ request }) => {
    const form = await request.formData();
    const title = String(form.get('title') ?? '').trim();
    const body = String(form.get('body') ?? '');
    const tags = String(form.get('tags') ?? '')
      .split(/[,,\s]+/).filter(Boolean).slice(0, 5);

    const errors = {};
    if (!title) errors.title = '标题必填';
    if (body.length > 20000) errors.body = '正文超出 2 万字符';
    if (Object.keys(errors).length) {
      // 4xx 且带数据:用 fail。它把数据交回表单页,而不是跳错误页
      return fail(400, { errors, values: { title, body } });
    }
    const items = load();
    items.unshift({ id: crypto.randomUUID(), title, body, tags, createdAt: new Date().toISOString() });
    save(items);
    return { success: true };
  },

  delete: async ({ request }) => {
    const id = String((await request.formData()).get('id') ?? '');
    save(load().filter(f => f.id !== id));
    return { success: true };
  }
};
<!-- src/routes/+page.svelte(骨架) -->
<script>
  import { enhance } from '$app/forms';
  let { data, form } = $props();   // form 是本次 action 的返回值
</script>

<!-- 无 action 名即 default?这里用具名 action:URL 写成 ?/create -->
<form method="POST" action="?/create" use:enhance>
  <input name="title" value={form?.values?.title ?? ''}>
  {#if form?.errors?.title}<p>{form.errors.title}</p>{/if}
  <textarea name="body">{form?.values?.body ?? ''}</textarea>
  <input name="tags" placeholder="用逗号分隔">
  <button>收藏</button>
</form>

<!-- 标签筛选:状态进 URL -->
<a href="/">全部</a>
{#each data.tags as t (t)}
  <a href="?tag={t}">{t}</a>
{/each}

<ul>
  {#each data.fragments as f (f.id)}
    <li>
      <a href="/{f.id}">{f.title}</a>
      <form method="POST" action="?/delete" use:enhance>
        <input type="hidden" name="id" value={f.id}>
        <button>删除</button>
      </form>
    </li>
  {/each}
</ul>

三个行为都来自框架而不是手写代码:提交成功后 use:enhance 的默认行为会自动重跑本页 load 并回填 form,新片段无需手动刷列表;禁用 JS 时表单退化为普通 POST,action 照样执行(这是选 form actions 的理由);校验失败时 fail(400, {...}) 的数据落在组件的 form 属性里,values 把用户已输入的内容送回输入框。

反模式点名:把校验错误写成 error(400, '标题必填')。error 会把用户送进 +error.svelte 错误页,表单内容全部丢失——它服务于”请求本身无法满足”,校验失败属于”正常流程里的坏输入”,该用 fail。第二个反模式是嫌 use:enhance 不够灵活,自己 fetch('?/create')——那要手动处理 CSRF(SvelteKit 会校验 Origin 头)、错误结构与数据失效,标准答案是自定义 use:enhance 的回调或用 applyAction,别绕开框架再造一层。

筛选的实现演示了路由篇讲过的重跑规则。列表页放两个 load:server load 返回全量,universal load 读查询串建立依赖:

// src/routes/+page.js —— universal load
export function load({ data, url }) {
  const tag = url.searchParams.get('tag');   // 读了它,?tag= 变化才会触发重跑
  return {
    ...data,
    fragments: tag
      ? data.fragments.filter(f => f.tags.includes(tag))
      : data.fragments
  };
}

数据只有几十条,先全量取回再在 universal load 里筛,而不是给 server load 加查询参数——因为客户端切换 ?tag= 时 server load 不会重跑(路由篇讲过的规则),筛选放 universal 层才能只靠浏览器本地完成,点标签是零网络往返的即时切换。

详情页:渲染与消毒是同一步

// src/routes/[id]/+page.server.js
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
import { load, save } from '$lib/server/store.js';

export function load({ params }) {
  const item = load().find(f => f.id === params.id);
  if (!item) error(404, '片段不存在');
  // 存原文、读取时渲染;marked 不消毒,输出必须立刻过白名单
  const html = sanitizeHtml(marked.parse(item.body));
  return { item, html };
}

export const actions = {
  update: async ({ params, request }) => {
    const form = await request.formData();
    const title = String(form.get('title') ?? '').trim();
    const body = String(form.get('body') ?? '');
    if (!title) return fail(400, { errors: { title: '标题必填' } });
    const items = load();
    const i = items.findIndex(f => f.id === params.id);
    if (i === -1) error(404, '片段不存在');
    items[i] = { ...items[i], title, body };
    save(items);
    return { success: true };
  }
};
<!-- src/routes/[id]/+page.svelte(骨架) -->
<script>
  let { data } = $props();
</script>

<h1>{data.item.title}</h1>
<!-- html 已在 server load 消毒,这里才是 {@html} 的安全位置 -->
<article>{@html data.html}</article>

<!-- 编辑表单同样 method POST action="?/update",textarea 预填 data.item.body,略 -->

反模式点名:跳过消毒直接 {@html marked.parse(item.body)}。marked 的文档明确说它输出的 HTML 是”未消毒”的,<script>、内联事件、javascript: 链接全部原样通过。收藏夹的正文迟早会粘贴自网页——粘贴物里藏一行脚本,渲染页面的每一次打开都在替攻击者执行。消毒后该长什么样:<script>alert(1)</script> 进去,出来只剩正文里什么都没有的空壳,[点我](javascript:alert(1)) 渲染成一个不带 href 的 a 标签。验收表里那条”view-source 无 script 标签”就是为这一步准备的。

验证:五条验收怎么过

验收项 操作 预期
禁用 JS 走全流程 DevTools 禁 JS,新建、筛选、删除各一次 全部成功,页面整页刷新但不丢数据
校验回显 清空标题点提交 停留原页,提示”标题必填”,正文还在输入框里
XSS 消毒 新建正文为 script 标签的片段并打开详情 无弹窗,view-source 中 script 被剥掉
落盘校验 每次保存后 node -e “console.log(JSON.parse(require(‘fs’).readFileSync(‘data/fragments.json’,’utf8′)).length)” 打印条数,无解析错误
URL 状态 点标签再按浏览器后退 列表恢复全量;复制带 ?tag= 的链接给别人能直接看到同样筛选

用 curl 直接打 action 会被 403 拦下——SvelteKit 默认校验 POST 的 Origin 头,这是框架送的 CSRF 防线,不是故障;要脚本测试就带上 -H "Origin: http://localhost:5173"。

性能说明:单机单人、数据几十条,没有压测口径,本篇未做性能实测;本地文件读写毫秒级,条目上万先换 SQLite 再谈性能。

总结

这个项目教会的事:form actions 让”无 JS 可用的 Web 应用”成为默认而不是附加题;fail 与 error 是两种语义,混用会把用户赶进错误页;marked 与消毒是绑定购买,拆开卖就是 XSS;原子写决定了一个玩具能不能活过第一次意外。它什么时候不够用:多人协作、条目上万、需要全文检索——换存储与检索层即可,路由与表单这层骨架不用动。

想接着深入,框架定位与心智见开山篇Svelte 入门;错误分类的通用思路(不只是 SvelteKit)可对照站内Node.js 错误处理一起看;按学习顺序串起本系列的路线图见Svelte 学习路线。

版本核实(以官方文档为准):截至本文(2025-11-06)稳定线为 Svelte 5.43.x、SvelteKit 2.48.x;form actions、fail()/error() 抛出式语义、use:enhance 默认失效重跑、$lib/server 编译期隔离、CSRF Origin 校验均为 SvelteKit 2 现行文档行为;marked 官方声明不做输出消毒(消毒由调用方负责);sv CLI 为 0.9.x。安装命令不钉版本号。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇