Files
blog/src/lib/rehype-class-whitelist.ts
T
wait4xandGLM-5.3 2cab243891 feat(typography): 可视化排版选择器(Sveltia inline 组件)+ 颜色扩充 + 方案 10
- 可视化选择(核心痛点解决):Sveltia 原生支持 registerEditorComponent
  的 inline 形态且 onInsert 能拿到 selectedText → 实现「选中文字 → Insert ▸
  排版 → 选字体/颜色 → 自动包 span class」完整链路;因 inline 组件只能进
  Insert 菜单(field-based 虽可上工具栏但拿不到 selectedText),两个独立组件
  会互相抢 pattern 匹配,故收敛为单入口 + 18 项分组面板(键盘可达/Esc 取消/
  onEdit 回填当前类)
- public/admin/typography-classes.js 为单一事实源(扩展与文档共用)
- 颜色扩充 4 类:ink-warm / ink-cool / ink-faint / ink-accent-soft,全部亮暗
  双态实测 ≥4.5:1(ink-faint 取白底达标的最淡值 4.69/4.75)
- 字体:体积账否决 webfont 新增(@fontsource zcool-xiaowei 用到即 +647.7KB,
  远超 150KB 阈值;smiley-sans 无可用版本、sarasa 包为空壳),改为落地 4 个
  **系统回退类**(font-song/font-kai-sys/font-hei-sys/font-fangsong,零下载,
  文档注明依赖访客设备)
- 白名单 17 类同步;docs/写作指南.md 三表更新
- docs/research/10-页面后台化方案.md:五方案对比 + 推荐方案 E(页面骨架 blocks
  / 区块字段 / 局部自由区 / 样式即数据四层)+ 关于页九类区块 + 消毒与验收断言
  + 分阶段清单(约 7.2d)

验证: 16/16 颜色 computed 与对比度实测;选择器 7/7(桩 CMS 覆盖注册/插入/
      编辑三层);构建 48 页 exit 0;E2E 37/37

Spec: none (排版能力 + 方案文档)

Co-authored-by: GLM-5.3
2026-11-02 16:57:54 +08:00

77 lines
2.2 KiB
TypeScript

/**
* rehype 排版类白名单:文章里允许用 `<span class="...">` / `<p class="fig-cap">` 做受限排版,
* 但**只放行白名单类名**,其余 class 一律剔除;同时剥离 style 与 on* 事件属性(纵深防御)。
*
* 为什么需要:Astro 默认不 sanitize markdown 里的 raw HTML——若直接放行 class,作者(或
* Sveltia 后台协作者)可写入任意类名/颜色破坏灰阶骨架,甚至借 style/on* 注入。
* 白名单与 global.css 的「受限排版类」段一一对应,加类时两处同改。
*/
const ALLOWED = new Set([
// 颜色(走 tokens,明暗双态可读)
'ink-1',
'ink-2',
'ink-3',
'ink-muted',
'ink-accent',
'ink-accent-soft',
'ink-warm',
'ink-cool',
'ink-faint',
'c-blue',
'c-blue-soft',
'c-teal',
'c-teal-soft',
'c-green',
'c-green-soft',
'c-yellow',
'c-yellow-soft',
'c-orange',
'c-orange-soft',
'c-red',
'c-red-soft',
'c-pink',
'c-pink-soft',
'c-purple',
'c-purple-soft',
// 字族(与分轨排版共存的四个既有字族)
'font-serif',
'font-kai',
'font-sans',
'font-mono',
// 系统字族回退类(不下载字体,命中访客本机字体)
'font-song',
'font-kai-sys',
'font-hei-sys',
'font-fangsong',
'font-kai-light',
// 文学排版
'drop-cap',
'quote-line',
'fig-cap',
]);
export default function rehypeClassWhitelist() {
return (tree: any) => {
const walk = (node: any) => {
if (node?.type === 'element') {
const props = node.properties ?? (node.properties = {});
// 剥离内联样式与事件属性:排版能力只经白名单类提供
delete props.style;
for (const key of Object.keys(props)) {
if (/^on/i.test(key)) delete props[key];
}
const raw = props.className;
if (raw !== undefined) {
const list = Array.isArray(raw) ? raw : String(raw).split(/\s+/);
const kept = list.map((c: string) => String(c).trim()).filter((c: string) => ALLOWED.has(c));
if (kept.length > 0) props.className = kept;
else delete props.className;
}
}
for (const child of node?.children ?? []) walk(child);
};
walk(tree);
};
}