From 0ab7284b2556c7b89bc364577ef278b66e367a33 Mon Sep 17 00:00:00 2001 From: shellRaining Date: Tue, 11 Aug 2026 09:00:45 +0800 Subject: [PATCH 1/2] feat(ubb): add generic renderer registry --- ARCHITECTURE.md | 4 +- docs/exec-plans/README.md | 3 +- .../2026-08-10-ubb-generic-renderer.md | 80 +++++ packages/ubb/README.md | 72 +++++ packages/ubb/package.json | 2 +- packages/ubb/src/index.ts | 37 +-- packages/ubb/src/node-utils.ts | 8 + packages/ubb/src/parser.ts | 26 +- packages/ubb/src/registry.ts | 105 +++++++ packages/ubb/src/renderer.ts | 133 +++++++++ packages/ubb/src/tags.ts | 12 +- packages/ubb/src/to-html.ts | 255 ++++++++-------- packages/ubb/src/to-markdown.ts | 276 ++++++++---------- .../ubb/tests/parse-error-handling.test.ts | 3 - packages/ubb/tests/registry-renderer.test.ts | 232 +++++++++++++++ 15 files changed, 912 insertions(+), 336 deletions(-) create mode 100644 docs/exec-plans/completed/2026-08-10-ubb-generic-renderer.md create mode 100644 packages/ubb/README.md create mode 100644 packages/ubb/src/node-utils.ts create mode 100644 packages/ubb/src/registry.ts create mode 100644 packages/ubb/src/renderer.ts create mode 100644 packages/ubb/tests/registry-renderer.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d9e871..7ea5009 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -40,7 +40,7 @@ graph TD - `apps/website`:面向用户的 Web 应用(Vue 3.6 SPA)。内部分层见 `docs/frontend.md` - `apps/docs`:面向论坛用户的 VitePress 帮助站,使用默认主题和独立静态构建,不依赖主站运行 - `packages/api`:CC98 API 的 Zod schema、operation registry、OpenAPI JSON 和验证工具 -- `packages/ubb`:UBB 解析器,核心产出是 AST(`parseUbb`),附带 HTML 和 Markdown 两个导出器。只读不做编辑器 +- `packages/ubb`:框架无关的 UBB 解析和输出工具。标签注册器定义 UBB 方言,泛型 renderer 把 AST 转成调用方选择的输出类型,包内提供 HTML 和 Markdown 预设。只读不做编辑器 - `packages/utils`:TypeScript 工具包脚手架,当前仅有占位代码,尚未投入使用 网站的富内容渲染分为语法适配层和共享 UI 层: @@ -56,7 +56,7 @@ flowchart LR remark --> universe ``` -`packages/ubb` 只负责解析和标签契约,不依赖 Vue。`apps/website` 解释 UBB AST 和 Markdown MDAST,并集中处理 URL 安全、图片计数、媒体开关等渲染策略。Markdown 编辑器使用 Milkdown,编辑和阅读共享 remark 语法体系。 +`packages/ubb` 不依赖 Vue。`createUbbRegistry` 登记标签名和解析模式,`createRenderer` 登记每个标签的输出 handler。默认 HTML 和 Markdown 导出器都是泛型 renderer 的字符串预设。`apps/website` 继续用自己的 Vue handler 解释 UBB AST,并集中处理 URL 安全、图片计数和媒体开关。Markdown 编辑器使用 Milkdown,编辑和阅读共享 remark 语法体系。 ## 依赖方向 diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 7483d4c..ac3eaa7 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -27,12 +27,13 @@ ## 进行中的计划 -当前没有进行中的计划。 +当前没有进行中的执行计划。 ## 已完成的计划 | 执行计划 | 说明 | | ------------------------------------------------------------------- | ------------------------------------------------------ | +| `completed/2026-08-10-ubb-generic-renderer.md` | UBB 标签注册器与泛型输出 renderer 重构已完成 | | `completed/2026-07-25-color-token-audit.md` | 全站颜色字面量迁移、自动检查和亮暗模式回归已完成 | | `completed/2026-07-25-installable-pwa.md` | 可安装 PWA、可靠首页应用外壳和访问后路由缓存已完成 | | `completed/2026-07-24-seasonal-dark-themes.md` | 春季、夏季和秋季暗色调色板与视觉回归已完成 | diff --git a/docs/exec-plans/completed/2026-08-10-ubb-generic-renderer.md b/docs/exec-plans/completed/2026-08-10-ubb-generic-renderer.md new file mode 100644 index 0000000..aab2191 --- /dev/null +++ b/docs/exec-plans/completed/2026-08-10-ubb-generic-renderer.md @@ -0,0 +1,80 @@ +# UBB 泛型输出注册器执行计划 + +## 背景 + +`packages/ubb` 的 HTML 和 Markdown 导出器各自解析源码、递归遍历 AST,再用一组硬编码分支输出字符串。调用方无法在使用前登记新标签,也无法复用同一套 UBB 语法生成字符串以外的结果。 + +这次重构把标签语法和输出方式拆开。标签注册器负责标签名和 `TagMode`,泛型 renderer 负责把 AST 节点转换成调用方选择的输出类型。HTML 和 Markdown 变成两份 `Renderer` 预设。 + +## 目标 + +- 提供链式 `createUbbRegistry().register(name, mode)`,注册结果直接参与解析。 +- 提供 `registry.createRenderer()`,由调用方登记文本转换、兄弟节点合并和每个标签的 handler。 +- handler 能读取已渲染 children、纯文本内容、属性、调用上下文,并能渲染任意子树。 +- 标签名通过 registry 泛型传递给 handlers,IDE 能补全标签,漏写 handler 时产生类型错误。 +- HTML 和 Markdown 共用遍历器,保留各自的格式规则和安全辅助函数。 + +## 非目标 + +- 首版不开放正则标签族注册;内置表情标签继续由默认 registry 的 fallback resolver 处理。 +- 不把网站的 Vue renderer 迁入 `packages/ubb`。 +- 不在本次重构中调整 Markdown 转义、HTML URL 白名单或 CSS 值校验。 +- renderer 保持同步,不支持 Promise 输出。 + +## 方案 + +注册器保存精确标签到模式的只读快照。每次 `register` 返回新实例,旧实例不受影响。默认 registry 由 `UBB_STATIC_TAG_MODES` 构建,并继续识别内置正则标签族。 + +renderer 通过两个泛型参数描述输出值和调用上下文: + +```ts +const renderer = registry.createRenderer({ + text: (value, context) => output, + concat: (parts, context) => output, + handlers: { + tag: ({ node, attrs, children, text, context, render }) => output, + }, +}); +``` + +`children` 是惰性缓存 getter,只在 handler 读取时递归渲染。`render(nodes)` 使用同一 renderer 和 context,但不执行顶层 finalizer。公开的 `render(source)` 和 `renderNodes(nodes)` 在根输出完成后执行 finalizer。 + +## 实施步骤 + +- [x] 新增 AST 文本提取工具、标签注册器和泛型 renderer。 +- [x] 让 parser 接受 tag mode resolver,并保留默认标签行为。 +- [x] 把 HTML 和 Markdown 导出器改成 `Renderer` 预设。 +- [x] 导出公共类型、默认 registry 和两个预设 renderer。 +- [x] 补充运行时、类型推导、上下文、实例隔离和自定义标签测试。 +- [x] 更新架构说明,执行格式、类型、测试和构建验证。 + +## 验证 + +- `vp run ready` 通过,覆盖全仓格式、lint、类型检查、knip、测试和构建。 +- UBB 共 9 个测试文件、197 条测试通过;新增用例覆盖自定义 `recursive`、`text`、`empty`、`autoclose` 标签。 +- Website 共 31 个测试文件、291 条测试通过;API 21 条、Utils 1 条测试通过。 +- docs、packages 和 website 构建通过。 +- 非字符串输出、context 传递、惰性 children、finalizer、任意子树渲染和 registry 隔离均有定向测试。 + +## 进展与调整 + +- 2026-08-10:方案确定,开始实现。 +- 2026-08-10:实现、文档和全仓验证完成,计划归档。 + +## 结果 + +- `createUbbRegistry().register(name, mode)` 提供不可变的链式注册 API,注册后的标签会立即参与解析,标签键会传入 renderer 的 handlers 类型。 +- `createRenderer()` 统一处理 AST 遍历,并向 handler 提供 `node`、`attrs`、惰性 `children`、纯文本 `text`、`context` 和子树 `render()`。 +- HTML、Markdown 已迁为两个 `Renderer` 预设,同时保留快捷函数;默认 HTML 预设维持原有文本、属性转义和 URL 协议过滤。 +- 公共 API、包说明和仓库架构文档已同步。 + +## 遗留项 + +- 正则标签族仍只由默认 registry 识别;自定义正则族注册可在出现真实需求后单独设计。 +- 网站现有 Vue renderer 暂不迁移。若未来允许不受信任的第三方 HTML handler,需要另行引入受限 builder 或最终 sanitizer。 + +## 决策记录 + +- registry 管标签语法,renderer 管输出,避免为每种输出重复声明 `TagMode`。 +- 泛型 renderer 公开,Vue 等非字符串输出可以直接复用;现有网站 renderer 暂不迁移。 +- handler 返回的 HTML 字符串属于受信任代码。默认 HTML 预设继续执行现有文本、属性和 URL 处理。 diff --git a/packages/ubb/README.md b/packages/ubb/README.md new file mode 100644 index 0000000..b1a313d --- /dev/null +++ b/packages/ubb/README.md @@ -0,0 +1,72 @@ +# @cc98/ubb + +框架无关的 CC98 UBB 解析和输出工具。标签注册器决定一段 UBB 怎样建成 AST,泛型 renderer 决定 AST 怎样变成字符串、VNode 或其他结果。 + +## 注册标签并创建 renderer + +```ts +import { createUbbRegistry } from "@cc98/ubb"; + +const ubb = createUbbRegistry() + .register("b", "recursive") + .register("code", "text") + .register("line", "empty") + .register("spoiler", "recursive"); + +const html = ubb.createRenderer({ + text: escapeHtml, + concat: (parts) => parts.join(""), + handlers: { + b: ({ children }) => `${children}`, + code: ({ children }) => `
${children}
`, + line: () => "
", + spoiler: ({ attrs, children }) => { + const title = escapeHtml(attrs.positionals[0] ?? "剧透"); + return `
${title}${children}
`; + }, + }, +}); + +html.render("[spoiler=注意][b]隐藏内容[/b][/spoiler]"); +``` + +`register()` 返回新的 registry,原实例不会改变。标签名会转成小写,重复注册会抛错。四种解析模式分别是: + +- `recursive`:内部继续解析 UBB。 +- `text`:内部保持纯文本。 +- `empty`:自闭合,不读取 children。 +- `autoclose`:结束标签可省略,有结束标签时仍可包裹内容。 + +## Handler 参数 + +每个 handler 会收到同一组参数: + +```ts +({ node, attrs, children, text, context, render }) => output; +``` + +- `children` 是子节点的渲染结果,首次读取时计算并缓存。 +- `text` 是子树的纯文本内容。 +- `context` 是调用 `render(source, context)` 时传入的值。 +- `render(nodes)` 使用当前 renderer 和 context 渲染任意子树,不执行根级 `finalize`。 + +`createRenderer()` 的 `Output` 不限于字符串。调用方只需提供 `text()` 和 `concat()`,就能输出 VNode、ReactNode 或自己的 AST。 + +## 默认预设 + +包内提供完整的 CC98 标签表以及 HTML、Markdown 两个字符串 renderer: + +```ts +import { + defaultUbbRegistry, + parseUbb, + ubbHtmlRenderer, + ubbMarkdownRenderer, + ubbToHtml, + ubbToMarkdown, +} from "@cc98/ubb"; +``` + +`ubbToHtml()` 和 `ubbToMarkdown()` 是两个预设 renderer 的快捷函数。`parseUbb()` 使用默认 CC98 标签表;自定义 registry 使用自己的标签表解析。 + +自定义 HTML handler 直接返回字符串,属于受信任代码。默认 HTML preset 会转义文本和属性并过滤 URL 协议,但无法约束调用方自行拼接的 HTML。 diff --git a/packages/ubb/package.json b/packages/ubb/package.json index eaa0684..797c1c1 100644 --- a/packages/ubb/package.json +++ b/packages/ubb/package.json @@ -2,7 +2,7 @@ "name": "@cc98/ubb", "version": "0.0.0", "private": true, - "description": "CC98 UBB 渲染器(只读)与 UBB→Markdown 转换器", + "description": "CC98 UBB 解析器、泛型输出注册器与 HTML/Markdown 预设", "license": "MIT", "files": [ "dist" diff --git a/packages/ubb/src/index.ts b/packages/ubb/src/index.ts index ed1269e..93dcb8b 100644 --- a/packages/ubb/src/index.ts +++ b/packages/ubb/src/index.ts @@ -4,33 +4,24 @@ export type { UbbNode, UbbTextNode, UbbTagNode, UbbAttrs } from "./types.ts"; export { resolveUbbEmotionTag } from "./emotion.ts"; export type { UbbEmotionDescriptor } from "./emotion.ts"; +export { getUbbTextContent } from "./node-utils.ts"; export { parseUbb } from "./parser.ts"; +export type { ParseUbbOptions } from "./parser.ts"; +export { createUbbRegistry, defaultUbbRegistry } from "./registry.ts"; +export type { UbbRegistry, UbbRegistryOptions, UbbTagModes } from "./registry.ts"; +export type { + UbbRenderer, + UbbRendererOptions, + UbbTagHandler, + UbbTagHandlerProps, + UbbTagHandlers, +} from "./renderer.ts"; export { UBB_REGEX_TAG_FAMILIES, UBB_STATIC_TAG_MODES, UBB_STATIC_TAG_NAMES, matchUbbRegexTagFamily, } from "./tags.ts"; -export type { TagMode, UbbRegexTagFamily, UbbStaticTagName } from "./tags.ts"; -export { ubbToMarkdown } from "./to-markdown.ts"; -export { ubbToHtml } from "./to-html.ts"; - -export type UbbRenderOptions = { - allowImage: boolean; - allowExternalUrl: boolean; - allowMediaContent: boolean; -}; - -export const defaultUbbOptions: UbbRenderOptions = { - allowImage: true, - allowExternalUrl: true, - allowMediaContent: true, -}; - -export function createUbbEngine(_options: Partial = {}): { - options: UbbRenderOptions; -} { - return { - options: { ...defaultUbbOptions, ..._options }, - }; -} +export type { TagMode, UbbRegexTagFamily, UbbStaticTagName, UbbTagModeResolver } from "./tags.ts"; +export { ubbMarkdownRenderer, ubbToMarkdown } from "./to-markdown.ts"; +export { ubbHtmlRenderer, ubbToHtml } from "./to-html.ts"; diff --git a/packages/ubb/src/node-utils.ts b/packages/ubb/src/node-utils.ts new file mode 100644 index 0000000..536a690 --- /dev/null +++ b/packages/ubb/src/node-utils.ts @@ -0,0 +1,8 @@ +import type { UbbNode } from "./types.ts"; + +/** 递归提取一组 UBB AST 节点中的纯文本。 */ +export function getUbbTextContent(nodes: readonly UbbNode[]): string { + return nodes + .map((node) => (node.type === "text" ? node.value : getUbbTextContent(node.children))) + .join(""); +} diff --git a/packages/ubb/src/parser.ts b/packages/ubb/src/parser.ts index 5b8eda3..23a399e 100644 --- a/packages/ubb/src/parser.ts +++ b/packages/ubb/src/parser.ts @@ -16,7 +16,7 @@ */ import type { UbbNode } from "./types.ts"; import { type ParsedTag, parseTag, extractAttrs } from "./tag-data.ts"; -import { getTagMode } from "./tags.ts"; +import { getTagMode, type UbbTagModeResolver } from "./tags.ts"; /** 文本 segment。 */ interface TextSeg { @@ -43,7 +43,11 @@ type Seg = TextSeg | TagSeg; * @param src UBB 原始文本。 * @returns AST 节点数组。 */ -export function parseUbb(src: string): UbbNode[] { +export interface ParseUbbOptions { + readonly resolveTagMode?: UbbTagModeResolver; +} + +export function parseUbb(src: string, options: ParseUbbOptions = {}): UbbNode[] { const root: TagSeg = { kind: "tag", tag: null, @@ -54,7 +58,7 @@ export function parseUbb(src: string): UbbNode[] { }; // 预计算小写版本,避免 findEndTag/checkEndTag 重复 toLowerCase(O(n²) → O(n)) const lowerSrc = src.toLowerCase(); - buildSegments(src, lowerSrc, root); + buildSegments(src, lowerSrc, root, options.resolveTagMode ?? getTagMode); closeTag(root); return root.children.map(segToAst); } @@ -67,7 +71,12 @@ export function parseUbb(src: string): UbbNode[] { * @param content 原始文本。 * @param lowerContent content 的小写版本(用于大小写不敏感的结束标签匹配)。 */ -function buildSegments(content: string, lowerContent: string, rootParent: TagSeg): void { +function buildSegments( + content: string, + lowerContent: string, + rootParent: TagSeg, + resolveTagMode: UbbTagModeResolver, +): void { let parent = rootParent; let cursor = 0; @@ -113,7 +122,7 @@ function buildSegments(content: string, lowerContent: string, rootParent: TagSeg continue; } - const mode = getTagMode(tag.tagName); + const mode = resolveTagMode(tag.tagName); if (!mode) { // 未知标签,降级为文本 addText(parent, tag.startTagString); @@ -121,6 +130,8 @@ function buildSegments(content: string, lowerContent: string, rootParent: TagSeg } switch (mode) { + // autoclose 在解析阶段与 recursive 行为一致:都允许包裹内容、递归建树。 + // 两者的差别在 forceClose(见下方 forceClose 函数的 autoclose 分支)。 case "recursive": case "autoclose": { const newTag: TagSeg = { @@ -245,7 +256,10 @@ function forceClose(rootSegment: Seg, newParent: TagSeg): void { continue; } - // 未关闭的 autoclose 标签:保留为空标签节点,子段提升到 newParent + // 未关闭的 autoclose 标签(user/topic/board/pm):保留为空标签节点, + // 子段提升到 newParent。不降级为文本是为了保住站内链接语义—— + // recursive 分支会把 [user=张三] 还原成纯文字,autoclose 这里保留节点, + // 让 to-html/to-markdown 仍能识别并输出链接。 if (segment.tag !== null && segment.mode === "autoclose") { const autocloseTag: TagSeg = { kind: "tag", diff --git a/packages/ubb/src/registry.ts b/packages/ubb/src/registry.ts new file mode 100644 index 0000000..ad43567 --- /dev/null +++ b/packages/ubb/src/registry.ts @@ -0,0 +1,105 @@ +import { parseUbb } from "./parser.ts"; +import { createUbbRenderer, type UbbRenderer, type UbbRendererOptions } from "./renderer.ts"; +import { getTagMode, UBB_STATIC_TAG_MODES, type TagMode, type UbbTagModeResolver } from "./tags.ts"; +import type { UbbNode } from "./types.ts"; + +export type UbbTagModes = Readonly>; + +type NormalizeUbbTagModes = { + readonly [Name in keyof Tags as Name extends string ? Lowercase : never]: Tags[Name]; +}; + +export interface UbbRegistryOptions { + /** 精确标签未命中时使用,可用于内置正则标签族。 */ + readonly resolveUnknownTag?: UbbTagModeResolver; +} + +export interface UbbRegistry { + register( + name: Name, + mode: Mode, + ): UbbRegistry, Mode>>>; + resolveTagMode(tagName: string): TagMode | null; + parse(source: string): UbbNode[]; + createRenderer( + options: UbbRendererOptions, + ): UbbRenderer; +} + +function normalizeTagName(name: string): string { + if ( + !name || + name.trim() !== name || + name.includes("[") || + name.includes("]") || + name.includes("/") + ) { + throw new TypeError(`UBB: 非法标签名 ${JSON.stringify(name)}`); + } + return name.toLowerCase(); +} + +function assertTagMode(mode: unknown): asserts mode is TagMode { + if (mode !== "recursive" && mode !== "text" && mode !== "empty" && mode !== "autoclose") { + throw new TypeError(`UBB: 非法标签模式 ${JSON.stringify(mode)}`); + } +} + +function createRegistry( + tagModes: ReadonlyMap, + options: UbbRegistryOptions, +): UbbRegistry { + const resolveUnknownTag = options.resolveUnknownTag; + + const resolveTagMode = (tagName: string): TagMode | null => { + const normalizedName = tagName.toLowerCase(); + return tagModes.get(normalizedName) ?? resolveUnknownTag?.(normalizedName) ?? null; + }; + + const parse = (source: string): UbbNode[] => parseUbb(source, { resolveTagMode }); + + const registry: UbbRegistry = { + register(name: Name, mode: Mode) { + const normalizedName = normalizeTagName(name); + assertTagMode(mode); + if (tagModes.has(normalizedName)) { + throw new Error(`UBB: 标签 ${normalizedName} 已注册`); + } + + const nextTagModes = new Map(tagModes); + nextTagModes.set(normalizedName, mode); + return createRegistry(nextTagModes, options); + }, + resolveTagMode, + parse, + createRenderer( + rendererOptions: UbbRendererOptions, + ) { + return createUbbRenderer(parse, rendererOptions); + }, + }; + + return Object.freeze(registry); +} + +export function createUbbRegistry( + initialTags?: InitialTags, + options: UbbRegistryOptions = {}, +): UbbRegistry> { + const tagModes = new Map(); + + for (const [name, mode] of Object.entries(initialTags ?? {})) { + const normalizedName = normalizeTagName(name); + assertTagMode(mode); + if (tagModes.has(normalizedName)) { + throw new Error(`UBB: 标签 ${normalizedName} 已注册`); + } + tagModes.set(normalizedName, mode); + } + + return createRegistry>(tagModes, options); +} + +export const defaultUbbRegistry = createUbbRegistry(UBB_STATIC_TAG_MODES, { + resolveUnknownTag: getTagMode, +}); diff --git a/packages/ubb/src/renderer.ts b/packages/ubb/src/renderer.ts new file mode 100644 index 0000000..b389e9d --- /dev/null +++ b/packages/ubb/src/renderer.ts @@ -0,0 +1,133 @@ +import { getUbbTextContent } from "./node-utils.ts"; +import type { UbbTagModes } from "./registry.ts"; +import type { UbbAttrs, UbbNode, UbbTagNode } from "./types.ts"; + +type UbbContextArgs = [Context] extends [void] ? [context?: Context] : [context: Context]; + +export interface UbbTagHandlerProps { + readonly node: Readonly & { readonly tag: Tag }; + readonly attrs: Readonly; + /** 子节点经当前 renderer 转换并合并后的结果,首次读取时才计算。 */ + readonly children: Output; + /** 子节点的纯文本内容,首次读取时才计算。 */ + readonly text: string; + readonly context: Context; + /** 使用当前 renderer 和 context 转换任意子树,不执行顶层 finalizer。 */ + readonly render: (nodes: readonly UbbNode[]) => Output; +} + +export type UbbTagHandler = ( + props: UbbTagHandlerProps, +) => Output; + +export type UbbTagHandlers = Readonly<{ + [Tag in Extract]: UbbTagHandler; +}>; + +interface UbbRendererBaseOptions { + /** 把一个 AST 文本节点转换为目标输出。 */ + readonly text: (value: string, context: Context) => Output; + /** 把兄弟节点的输出合并为一个目标输出。 */ + readonly concat: (parts: readonly Output[], context: Context) => Output; + /** 只在公开的 render/renderNodes 根输出完成后执行。 */ + readonly finalize?: (output: Output, context: Context) => Output; +} + +export type UbbRendererOptions< + Tags extends UbbTagModes, + Output, + Context = void, +> = UbbRendererBaseOptions & + ( + | { + /** 已注册精确标签的输出 handler,键由 registry 泛型推导。 */ + handlers: UbbTagHandlers; + /** 处理动态标签;精确标签已经由 handlers 完整覆盖。 */ + fallback?: UbbTagHandler; + } + | { + /** 提供 fallback 时可以只覆盖部分精确标签。 */ + handlers: Partial>; + /** 处理没有专用 handler 的精确标签和动态标签。 */ + fallback: UbbTagHandler; + } + ); + +export interface UbbRenderer { + render(source: string, ...args: UbbContextArgs): Output; + renderNodes(nodes: readonly UbbNode[], ...args: UbbContextArgs): Output; +} + +type ParseUbb = (source: string) => UbbNode[]; + +export function createUbbRenderer( + parse: ParseUbb, + options: UbbRendererOptions, +): UbbRenderer { + const renderText = options.text; + const concat = options.concat; + const fallback = options.fallback; + const finalizeOutput = options.finalize; + const handlers = Object.freeze({ ...options.handlers }) as unknown as Readonly< + Record> + >; + + function renderNodes(nodes: readonly UbbNode[], context: Context): Output { + return concat( + nodes.map((node) => renderNode(node, context)), + context, + ); + } + + function renderNode(node: UbbNode, context: Context): Output { + if (node.type === "text") return renderText(node.value, context); + + const handler = handlers[node.tag] ?? fallback; + if (!handler) return renderNodes(node.children, context); + + let hasRenderedChildren = false; + let renderedChildren: Output; + let hasTextContent = false; + let textContent = ""; + + const props: UbbTagHandlerProps = { + node, + attrs: node.attrs, + context, + render: (nodes) => renderNodes(nodes, context), + get children() { + if (!hasRenderedChildren) { + renderedChildren = renderNodes(node.children, context); + hasRenderedChildren = true; + } + return renderedChildren; + }, + get text() { + if (!hasTextContent) { + textContent = getUbbTextContent(node.children); + hasTextContent = true; + } + return textContent; + }, + }; + + return handler(props); + } + + function finalize(output: Output, context: Context): Output { + return finalizeOutput ? finalizeOutput(output, context) : output; + } + + const renderer: UbbRenderer = { + render(source: string, ...args: UbbContextArgs) { + const context = args[0] as Context; + return finalize(renderNodes(parse(source), context), context); + }, + renderNodes(nodes: readonly UbbNode[], ...args: UbbContextArgs) { + const context = args[0] as Context; + return finalize(renderNodes(nodes, context), context); + }, + }; + + return Object.freeze(renderer); +} diff --git a/packages/ubb/src/tags.ts b/packages/ubb/src/tags.ts index 9a6de48..b8a9976 100644 --- a/packages/ubb/src/tags.ts +++ b/packages/ubb/src/tags.ts @@ -5,14 +5,22 @@ * 老项目通过 handler 的 getTagMode 返回模式(Recursive/Text/Empty), * 新解析器用静态表 + 正则表替代,避免引入 handler 层。 * - * 三种模式: + * 四种模式: * - recursive:标签内部允许其它 UBB 标签,递归建树。 * - text:标签内部只允许纯文字,内容作为单个文本节点(不递归)。 * - empty:自闭合标签,children 恒为空;紧跟同名结束标签时忽略。 + * - autoclose:可选结束标签。CC98 实际用法中 user/topic/board/pm 常不写 + * 结束标签(`[user=张三]`),故解析阶段按 recursive 处理允许包裹内容; + * forceClose 时若仍未关闭,不像 recursive 那样把 startTagString 降级为 + * 文本(那会丢掉站内链接语义),而是保留为空标签节点、子段提升到父级。 + * 与 empty 的区别:empty 永远无 children,autoclose 只在未关闭时才无。 */ export type TagMode = "recursive" | "text" | "empty" | "autoclose"; +/** 根据已归一化的小写标签名查询解析模式。 */ +export type UbbTagModeResolver = (tagName: string) => TagMode | null; + /** 静态标签名 → 模式。 */ export const UBB_STATIC_TAG_MODES = { // 文字样式(Recursive) @@ -43,7 +51,7 @@ export const UBB_STATIC_TAG_MODES = { quote: "recursive", quotex: "recursive", - // 站内链接(AutoClose:可选结束标签,无结束时自闭合,有结束时包裹内容) + // 站内链接(AutoClose,模式说明见文件顶部) user: "autoclose", topic: "autoclose", board: "autoclose", diff --git a/packages/ubb/src/to-html.ts b/packages/ubb/src/to-html.ts index 020ea61..393413d 100644 --- a/packages/ubb/src/to-html.ts +++ b/packages/ubb/src/to-html.ts @@ -1,7 +1,7 @@ /** * UBB → HTML 导出器。 * - * 先用 parseUbb 解析成 AST,再递归遍历 AST 生成净化后的 HTML 字符串。 + * 通过默认 UBB 注册表创建字符串 renderer,把 AST 转成净化后的 HTML 字符串。 * * 安全策略: * 1. 文本节点转义 HTML 特殊字符(& < > "),防止 XSS。 @@ -9,9 +9,110 @@ * 危险协议(javascript:/data:)替换为 #。 * 3. style/colspan/rowspan 等属性值也转义,防止跳出属性边界。 */ -import type { UbbNode } from "./types.ts"; -import { parseUbb } from "./parser.ts"; -import { getTagMode } from "./tags.ts"; +import { defaultUbbRegistry } from "./registry.ts"; + +/** 默认的 UBB → HTML renderer。 */ +export const ubbHtmlRenderer = defaultUbbRegistry.createRenderer({ + text: escapeHtml, + concat: (parts) => parts.join(""), + handlers: { + // 加粗 / 斜体 / 下划线 / 删除线 + b: ({ children }) => `${children}`, + i: ({ children }) => `${children}`, + u: ({ children }) => `${children}`, + del: ({ children }) => `${children}`, + + // 样式 span / div + english: ({ children }) => `${children}`, + size: ({ attrs, children }) => + `${children}`, + color: ({ attrs, children }) => + `${children}`, + font: ({ attrs, children }) => + `${children}`, + align: ({ attrs, children }) => + `
${children}
`, + left: ({ children }) => `
${children}
`, + center: ({ children }) => `
${children}
`, + right: ({ children }) => `
${children}
`, + cursor: ({ attrs, children }) => + `${children}`, + + // 链接 / 图片 + url: ({ attrs, children, text }) => { + const addr = attrs.positionals[0] ?? text; + const label = children || escapeHtml(addr); + return `${label}`; + }, + img: ({ attrs, text }) => { + const alt = attrs.named.title ?? ""; + return `${escapeAttr(alt)}`; + }, + + // 引用 / 代码 / 分割线 + quote: ({ attrs, children }) => { + const source = attrs.positionals[0]; + return source + ? `
${escapeHtml(source)}:${children}
` + : `
${children}
`; + }, + quotex: ({ attrs, children }) => { + const source = attrs.positionals[0]; + return source + ? `
${escapeHtml(source)}:${children}
` + : `
${children}
`; + }, + code: ({ children }) => `
${children}
`, + line: () => `
`, + + // 表格 + table: ({ children }) => `${children}
`, + tr: ({ children }) => `${children}`, + td: ({ node, attrs, children }) => tableCellToHtml(node.tag, attrs.positionals, children), + th: ({ node, attrs, children }) => tableCellToHtml(node.tag, attrs.positionals, children), + + // Text 模式标签(children 已转义) + md: ({ children }) => `
${children}
`, + noubb: ({ children }) => children, + math: ({ children }) => `${children}`, + m: ({ children }) => `${children}`, + + // 媒体 + audio: ({ text }) => ``, + mp3: ({ text }) => ``, + video: ({ text }) => ``, + bili: ({ text }) => + `bili:${escapeHtml(text)}`, + upload: ({ text }) => `下载文件`, + + // 站内链接 + user: ({ attrs, text }) => { + const name = text || attrs.positionals[0] || ""; + return `@${escapeHtml(name)}`; + }, + topic: ({ attrs, children }) => { + const id = attrs.positionals[0] ?? ""; + const title = children || `帖子 ${escapeHtml(id)}`; + return `${title}`; + }, + board: ({ attrs, children }) => { + const id = attrs.positionals[0] ?? ""; + const title = children || `板块 ${escapeHtml(id)}`; + return `${title}`; + }, + pm: ({ attrs, text }) => { + const name = attrs.positionals[0] ?? text; + return `@${escapeHtml(name)}`; + }, + + // 权限标签剥除为空字符串;表情标签由 fallback 以空 children 自然剥除。 + needreply: () => "", + posteronly: () => "", + allowviewer: () => "", + }, + // 其他标签保留已经渲染的 children 内容。 + fallback: ({ children }) => children, +}); /** * 把 UBB 文本转成净化后的 HTML 字符串。 @@ -20,134 +121,17 @@ import { getTagMode } from "./tags.ts"; * @returns HTML 字符串。 */ export function ubbToHtml(ubb: string): string { - const nodes = parseUbb(ubb); - return nodes.map(nodeToHtml).join(""); + return ubbHtmlRenderer.render(ubb); } -/** - * 递归把单个 AST 节点转成 HTML。 - */ -function nodeToHtml(node: UbbNode): string { - if (node.type === "text") return escapeHtml(node.value); - - const { tag, attrs, children } = node; - const inner = children.map(nodeToHtml).join(""); - - // 加粗 / 斜体 / 下划线 / 删除线 - if (tag === "b") return `${inner}`; - if (tag === "i") return `${inner}`; - if (tag === "u") return `${inner}`; - if (tag === "del") return `${inner}`; - - // 样式 span / div - if (tag === "english") return `${inner}`; - if (tag === "size") { - return `${inner}`; - } - if (tag === "color") { - return `${inner}`; - } - if (tag === "font") { - return `${inner}`; - } - if (tag === "align") { - return `
${inner}
`; - } - if (tag === "left") return `
${inner}
`; - if (tag === "center") return `
${inner}
`; - if (tag === "right") return `
${inner}
`; - if (tag === "cursor") { - return `${inner}`; - } - - // 链接 - if (tag === "url") { - const addr = attrs.positionals[0] ?? getTextContent(children); - const text = inner || escapeHtml(addr); - return `${text}`; - } - - // 图片 - if (tag === "img") { - const alt = attrs.named.title ?? ""; - const addr = getTextContent(children); - return `${escapeAttr(alt)}`; - } - - // 引用 - if (tag === "quote" || tag === "quotex") { - const source = attrs.positionals[0]; - if (source) { - return `
${escapeHtml(source)}:${inner}
`; - } - return `
${inner}
`; - } - - // 代码(Text 模式,inner 已转义) - if (tag === "code") return `
${inner}
`; - - // 分割线 - if (tag === "line") return `
`; - - // 表格 - if (tag === "table") return `${inner}
`; - if (tag === "tr") return `${inner}`; - if (tag === "td" || tag === "th") { - const parts: string[] = []; - if (attrs.positionals.length === 2) { - parts.push(`rowspan="${escapeAttr(attrs.positionals[0])}"`); - parts.push(`colspan="${escapeAttr(attrs.positionals[1])}"`); - } - const attrStr = parts.length > 0 ? " " + parts.join(" ") : ""; - return `<${tag}${attrStr}>${inner}`; +function tableCellToHtml(tag: string, positionals: readonly string[], children: string): string { + const parts: string[] = []; + if (positionals.length === 2) { + parts.push(`rowspan="${escapeAttr(positionals[0])}"`); + parts.push(`colspan="${escapeAttr(positionals[1])}"`); } - - // Text 模式标签(inner 已转义) - if (tag === "md") return `
${inner}
`; - if (tag === "noubb") return inner; - if (tag === "math" || tag === "m") return `${inner}`; - - // 媒体 - if (tag === "audio" || tag === "mp3") { - return ``; - } - if (tag === "video") { - return ``; - } - if (tag === "bili") { - const id = getTextContent(children); - return `bili:${escapeHtml(id)}`; - } - if (tag === "upload") { - return `下载文件`; - } - - // 站内链接 - if (tag === "user") { - const contentName = getTextContent(children); - const name = contentName || attrs.positionals[0] || ""; - return `@${escapeHtml(name)}`; - } - if (tag === "topic") { - const id = attrs.positionals[0] ?? ""; - const title = inner || `帖子 ${escapeHtml(id)}`; - return `${title}`; - } - if (tag === "board") { - const id = attrs.positionals[0] ?? ""; - const title = inner || `板块 ${escapeHtml(id)}`; - return `${title}`; - } - if (tag === "pm") { - const name = attrs.positionals[0] ?? getTextContent(children); - return `@${escapeHtml(name)}`; - } - - // 表情 / 权限标签(Empty 模式)剥除为空字符串 - if (getTagMode(tag) === "empty") return ""; - - // 其他未知标签:保留 children 内容 - return inner; + const attrStr = parts.length > 0 ? " " + parts.join(" ") : ""; + return `<${tag}${attrStr}>${children}`; } /** @@ -163,9 +147,7 @@ function escapeHtml(text: string): string { .replace(/"/g, """); } -/** - * 转义 HTML 属性值中的特殊字符(与 escapeHtml 相同)。 - */ +/** 转义 HTML 属性值中的特殊字符(与 escapeHtml 相同)。 */ function escapeAttr(text: string): string { return escapeHtml(text); } @@ -182,12 +164,3 @@ function safeUrl(url: string): string { } return "#"; } - -/** - * 递归提取节点的纯文本内容。 - */ -function getTextContent(children: UbbNode[]): string { - return children - .map((child) => (child.type === "text" ? child.value : getTextContent(child.children))) - .join(""); -} diff --git a/packages/ubb/src/to-markdown.ts b/packages/ubb/src/to-markdown.ts index 16f423a..213e174 100644 --- a/packages/ubb/src/to-markdown.ts +++ b/packages/ubb/src/to-markdown.ts @@ -1,132 +1,138 @@ /** * UBB → Markdown 导出器。 * - * 先用 parseUbb 解析成 AST,再递归遍历 AST 生成 Markdown 字符串。 - * - * 转换策略: - * - Markdown 能表达的:b/i/del/url/img/quote/code/line → 对应 Markdown 语法。 - * - Markdown 无法表达的:u/size/color/font/align 等 → 剥除样式保留内容。 - * - 富媒体/站内语义:audio/video/bili → 链接;user/topic/board → @提及/站内链接。 - * - 表情标签:转为 CC98 官方资源的标准 Markdown 图片;权限标签剥除为空字符串。 - * - math/m/noubb/md:保留原文或转义后输出。 + * 通过默认 UBB 注册表创建字符串 renderer。Markdown 能表达的标签转成对应语法, + * 样式标签保留内容,富媒体降级为链接,权限标签剥除。 */ import { resolveUbbEmotionTag, type UbbEmotionDescriptor } from "./emotion.ts"; -import { parseUbb } from "./parser.ts"; -import { getTagMode, matchUbbRegexTagFamily } from "./tags.ts"; +import { defaultUbbRegistry } from "./registry.ts"; +import { matchUbbRegexTagFamily } from "./tags.ts"; import type { UbbNode } from "./types.ts"; -/** - * 把 UBB 文本转成 Markdown 字符串。 - * - * @param ubb UBB 原始文本。 - * @returns Markdown 字符串。 - */ +/** 默认的 UBB → Markdown renderer。 */ +export const ubbMarkdownRenderer = defaultUbbRegistry.createRenderer({ + text: (value) => value, + concat: (parts) => parts.join(""), + handlers: { + // 文字样式 + b: ({ children }) => `**${children}**`, + i: ({ children }) => `_${children}_`, + u: ({ children }) => children, + del: ({ children }) => `~~${children}~~`, + english: ({ children }) => children, + left: ({ children }) => children, + center: ({ children }) => children, + right: ({ children }) => children, + size: ({ children }) => children, + color: ({ children }) => children, + font: ({ children }) => children, + align: ({ children }) => children, + cursor: ({ children }) => children, + + // 链接 / 图片 + url: ({ attrs, children }) => { + const address = attrs.positionals[0]; + if (address) return children ? `[${children}](${address})` : `<${address}>`; + return `<${children}>`; + }, + img: ({ attrs, text }) => markdownImage(attrs.named.title ?? "", text), + + // 引用 / 代码 / 分割线 + quote: ({ attrs, children }) => quoteToMarkdown(attrs.positionals[0], children), + quotex: ({ attrs, children }) => quoteToMarkdown(attrs.positionals[0], children), + code: ({ text }) => (text.includes("\n") ? "```\n" + text + "\n```" : "`" + text + "`"), + line: () => "\n---\n", + + // 表格由 table handler 统一处理,结构标签在其他位置保留内容。 + table: ({ node, render }) => tableToMarkdown(node.children, render), + tr: ({ children }) => children, + td: ({ children }) => children, + th: ({ children }) => children, + + // Text 模式标签 + md: ({ text }) => text, + noubb: ({ text }) => text.replace(/([[\]])/g, "\\$1"), + math: ({ text }) => text, + m: ({ text }) => text, + + // 媒体 + audio: ({ node, text }) => `[${node.tag}](${text})`, + mp3: ({ node, text }) => `[${node.tag}](${text})`, + video: ({ node, text }) => `[${node.tag}](${text})`, + bili: ({ node, text }) => `[${node.tag}](${text})`, + upload: ({ text }) => text, + + // 站内链接 + user: ({ attrs, children }) => `@${attrs.positionals[0] ?? children}`, + pm: ({ attrs, children }) => `@${attrs.positionals[0] ?? children}`, + topic: ({ attrs, children }) => { + const id = attrs.positionals[0] ?? ""; + return `[${children || `帖子 ${id}`}](/topic/${id})`; + }, + board: ({ attrs, children }) => { + const id = attrs.positionals[0] ?? ""; + return `[${children || `板块 ${id}`}](/board/${id})`; + }, + + // 权限标签 + needreply: () => "", + posteronly: () => "", + allowviewer: () => "", + }, + fallback: ({ node, children }) => { + const emotion = resolveUbbEmotionTag(node.tag); + if (emotion) return markdownImage(emotionMarkdownAlt(emotion), emotion.src); + + // 标签族已识别但编号无效时保留原始 UBB,避免静默丢内容。 + if (matchUbbRegexTagFamily(node.tag)) return `[${node.tag}]`; + return children; + }, + finalize: (result) => { + let output = result; + if (output.startsWith("\n---")) output = output.slice(1); + if (output.endsWith("---\n")) output = output.slice(0, -1); + return output; + }, +}); + +/** 把 UBB 文本转成 Markdown 字符串。 */ export function ubbToMarkdown(ubb: string): string { - const nodes = parseUbb(ubb); - let result = nodes.map(nodeToMarkdown).join(""); - - // [line] 产生的 \n---\n 在字符串首尾时去掉多余换行 - if (result.startsWith("\n---")) result = result.slice(1); - if (result.endsWith("---\n")) result = result.slice(0, -1); - - return result; + return ubbMarkdownRenderer.render(ubb); } -/** - * 递归把单个 AST 节点转成 Markdown。 - */ -function nodeToMarkdown(node: UbbNode): string { - if (node.type === "text") return node.value; - - const { tag, attrs, children } = node; - const inner = children.map(nodeToMarkdown).join(""); - - // 加粗 / 斜体 / 删除线 - if (tag === "b") return `**${inner}**`; - if (tag === "i") return `_${inner}_`; - if (tag === "del") return `~~${inner}~~`; - - // 链接 - if (tag === "url") { - const addr = attrs.positionals[0]; - if (addr) { - return inner ? `[${inner}](${addr})` : `<${addr}>`; - } - return `<${inner}>`; - } - - // 图片 - if (tag === "img") { - const alt = attrs.named.title ?? ""; - return `![${alt}](${getTextContent(children)})`; - } - - // 引用 - if (tag === "quote" || tag === "quotex") { - const source = attrs.positionals[0]; - const content = source ? `${source}:${inner}` : inner; - return content - .split("\n") - .map((line) => (line.trim() === "" ? ">" : `> ${line}`)) - .join("\n"); - } - - // 代码(单行用行内代码,多行用围栏代码块) - if (tag === "code") { - const content = getTextContent(children); - return content.includes("\n") ? "```\n" + content + "\n```" : "`" + content + "`"; - } - - // 分割线(前后加换行,由 ubbToMarkdown 顶层清理首尾多余换行) - if (tag === "line") return "\n---\n"; - - // Markdown 内容原样输出 - if (tag === "md") return getTextContent(children); - - // noubb 转义方括号 - if (tag === "noubb") { - return getTextContent(children).replace(/([[\]])/g, "\\$1"); - } - - // 媒体降级为链接 - if (tag === "audio" || tag === "mp3" || tag === "video" || tag === "bili") { - return `[${tag}](${getTextContent(children)})`; - } +function quoteToMarkdown(source: string | undefined, children: string): string { + const content = source ? `${source}:${children}` : children; + return content + .split("\n") + .map((line) => (line.trim() === "" ? ">" : `> ${line}`)) + .join("\n"); +} - // upload 降级为纯地址 - if (tag === "upload") return getTextContent(children); +function tableToMarkdown( + children: readonly UbbNode[], + render: (nodes: readonly UbbNode[]) => string, +): string { + const rows: string[][] = []; - // math/m 保留 LaTeX 原文 - if (tag === "math" || tag === "m") return getTextContent(children); + for (const child of children) { + if (child.type !== "tag" || child.tag !== "tr") continue; - // 站内链接 - if (tag === "user" || tag === "pm") { - return `@${attrs.positionals[0] ?? inner}`; - } - if (tag === "topic") { - const id = attrs.positionals[0] ?? ""; - return `[${inner || `帖子 ${id}`}](/topic/${id})`; - } - if (tag === "board") { - const id = attrs.positionals[0] ?? ""; - return `[${inner || `板块 ${id}`}](/board/${id})`; + const cells: string[] = []; + for (const cell of child.children) { + if (cell.type === "tag" && (cell.tag === "td" || cell.tag === "th")) { + cells.push(render(cell.children)); + } + } + if (cells.length > 0) rows.push(cells); } - // 表格 - if (tag === "table") return tableToMarkdown(children); - - const emotion = resolveUbbEmotionTag(tag); - if (emotion) return markdownImage(emotionMarkdownAlt(emotion), emotion.src); - - // 能识别标签族但编号无效时保留原始 UBB,避免迁移时静默丢内容 - if (matchUbbRegexTagFamily(tag)) return `[${tag}]`; - - // 权限标签(Empty 模式)剥除为空字符串 - if (getTagMode(tag) === "empty") return ""; + if (rows.length === 0) return ""; - // 其他已知标签(u/size/color/font/align/left/center/right/english/cursor/tr/td/th): - // 剥除样式保留内容 - return inner; + return [ + `| ${rows[0].join(" | ")} |`, + `| ${rows[0].map(() => "---").join(" | ")} |`, + ...rows.slice(1).map((row) => `| ${row.join(" | ")} |`), + ].join("\n"); } function markdownImage(alt: string, source: string): string { @@ -153,47 +159,3 @@ function emotionMarkdownAlt(emotion: UbbEmotionDescriptor): string { return `麻将脸 ${emotion.code}`; } } - -/** - * 把 table 的子节点(tr/td/th)转成 Markdown 表格。 - */ -function tableToMarkdown(children: UbbNode[]): string { - const rows: string[][] = []; - - for (const child of children) { - if (child.type === "tag" && child.tag === "tr") { - const cells: string[] = []; - for (const cell of child.children) { - if (cell.type === "tag" && (cell.tag === "td" || cell.tag === "th")) { - cells.push(cell.children.map(nodeToMarkdown).join("")); - } - } - if (cells.length > 0) rows.push(cells); - } - } - - if (rows.length === 0) return ""; - - const lines: string[] = []; - // 第一行作表头 - lines.push(`| ${rows[0].join(" | ")} |`); - // 表头分隔行 - lines.push(`| ${rows[0].map(() => "---").join(" | ")} |`); - // 数据行 - for (let i = 1; i < rows.length; i++) { - lines.push(`| ${rows[i].join(" | ")} |`); - } - - return lines.join("\n"); -} - -/** - * 递归提取节点的纯文本内容。 - * - * 用于 Text 模式标签(code/img/audio 等),其 children 为单个文本节点。 - */ -function getTextContent(children: UbbNode[]): string { - return children - .map((child) => (child.type === "text" ? child.value : getTextContent(child.children))) - .join(""); -} diff --git a/packages/ubb/tests/parse-error-handling.test.ts b/packages/ubb/tests/parse-error-handling.test.ts index f74365a..de581cc 100644 --- a/packages/ubb/tests/parse-error-handling.test.ts +++ b/packages/ubb/tests/parse-error-handling.test.ts @@ -1,7 +1,4 @@ /** - * UBB 容错场景单元测试(TDD 红灯阶段)。 - * - * 本文件只写测试。parseUbb 当前为占位实现(throw),测试全部失败属于预期。 * 各场景对应的 Core.tsx(Forum/Ubb/Core.tsx)容错机制: * * 1. 未闭合标签:root.close() 触发 forceClose(Core.tsx:349-376)。未关闭标签的 diff --git a/packages/ubb/tests/registry-renderer.test.ts b/packages/ubb/tests/registry-renderer.test.ts new file mode 100644 index 0000000..0159391 --- /dev/null +++ b/packages/ubb/tests/registry-renderer.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createUbbRegistry, type UbbNode } from "../src/index.ts"; + +const textToString = (value: string): string => value; +const joinStrings = (outputs: readonly string[]): string => outputs.join(""); + +describe("UBB 注册表渲染器", () => { + test("recursive 标签可读取属性、纯文本和递归渲染后的 children", () => { + const renderer = createUbbRegistry() + .register("panel", "recursive") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + panel: ({ attrs, children, text }) => { + const label = attrs.named.title || attrs.positionals[0] || "panel"; + return `<${label} data-text="${text}">${children}`; + }, + }, + }); + + expect(renderer.render("[panel=info,title=提示]外层[panel]内层[/panel][/panel]")).toBe( + '<提示 data-text="外层内层">外层内层', + ); + }); + + test("text 标签把内部 UBB 保留为纯文本", () => { + const renderer = createUbbRegistry() + .register("literal", "text") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + literal: ({ text }) => `{${text}}`, + }, + }); + + expect(renderer.render("[literal][b]不递归[/b][/literal]")).toBe("{[b]不递归[/b]}"); + }); + + test("empty 标签不消费后续正文,并忽略紧邻的同名结束标签", () => { + const renderer = createUbbRegistry() + .register("break", "empty") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + break: ({ node }) => `<${node.tag}>`, + }, + }); + + expect(renderer.render("甲[break]乙[break][/break]丙")).toBe("甲丙"); + }); + + test("autoclose 标签同时支持无结束标签和包裹内容", () => { + const renderer = createUbbRegistry() + .register("mention", "autoclose") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + mention: ({ attrs, text }) => `@${attrs.positionals[0] ?? text}`, + }, + }); + + expect(renderer.render("[mention=alice] 后文;[mention]Bob[/mention]")).toBe( + "@alice 后文;@Bob", + ); + }); + + test("children 是按需计算且只计算一次的 getter", () => { + let textRenderCount = 0; + const renderer = createUbbRegistry() + .register("drop", "recursive") + .register("repeat", "recursive") + .createRenderer({ + text: (value) => { + textRenderCount += 1; + return value; + }, + concat: joinStrings, + handlers: { + drop: ({ text }) => `[已省略 ${text.length} 字]`, + repeat: (input) => `${input.children}|${input.children}`, + }, + }); + + expect(renderer.render("[drop]hidden[/drop][repeat]shown[/repeat]")).toBe( + "[已省略 6 字]shown|shown", + ); + expect(textRenderCount).toBe(1); + }); + + test("renderNodes 支持字符串以外的泛型输出", () => { + type Segment = { + kind: "text" | "tag"; + value: string; + }; + + const renderer = createUbbRegistry() + .register("token", "recursive") + .createRenderer({ + text: (value) => [{ kind: "text", value }], + concat: (outputs) => outputs.flatMap((output) => output), + handlers: { + token: ({ node, attrs, text }) => [ + { + kind: "tag", + value: `${node.tag}:${attrs.positionals[0] ?? "default"}:${text}`, + }, + ], + }, + }); + + expect(renderer.render("a[token=hot]b[/token]c")).toEqual([ + { kind: "text", value: "a" }, + { kind: "tag", value: "token:hot:b" }, + { kind: "text", value: "c" }, + ]); + + const nodes: UbbNode[] = [ + { type: "text", value: "甲" }, + { + type: "tag", + tag: "token", + attrs: { positionals: ["manual"], named: {} }, + children: [{ type: "text", value: "乙" }], + }, + ]; + expect(renderer.renderNodes(nodes)).toEqual([ + { kind: "text", value: "甲" }, + { kind: "tag", value: "token:manual:乙" }, + ]); + }); + + test("Context 会传给文本、拼接、handler 和 finalize,内部 render 不重复 finalize", () => { + interface RenderContext { + prefix: string; + separator: string; + repeat: number; + finalizeCount: number; + } + + const renderer = createUbbRegistry() + .register("repeat", "recursive") + .createRenderer({ + text: (value, context) => `${context.prefix}${value}`, + concat: (outputs, context) => outputs.join(context.separator), + handlers: { + repeat: ({ node, context, render }) => + Array.from({ length: context.repeat }, () => render(node.children)).join("+"), + }, + finalize: (output, context) => { + context.finalizeCount += 1; + return `{${output}}`; + }, + }); + const context: RenderContext = { + prefix: "~", + separator: "/", + repeat: 2, + finalizeCount: 0, + }; + + expect(renderer.render("[repeat]x[/repeat]z", context)).toBe("{~x+~x/~z}"); + expect(context.finalizeCount).toBe(1); + }); + + test("fallback 只处理已注册但没有专用 handler 的标签", () => { + let fallbackCount = 0; + const renderer = createUbbRegistry() + .register("handled", "recursive") + .register("unhandled", "recursive") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + handled: ({ children }) => `${children}`, + }, + fallback: ({ node, attrs, children, text }) => { + fallbackCount += 1; + return `<${node.tag} value="${attrs.positionals[0] ?? ""}" text="${text}">${children}`; + }, + }); + + expect(renderer.render("[handled]甲[/handled][unhandled=x]乙[/unhandled]")).toBe( + '', + ); + expect(fallbackCount).toBe(1); + + expect(renderer.render("[missing]未知[/missing]")).toBe("[missing]未知[/missing]"); + expect(fallbackCount).toBe(1); + }); + + test("不同 registry 的标签定义和 renderer 互不影响", () => { + const leftRenderer = createUbbRegistry() + .register("leftonly", "recursive") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + leftonly: ({ children }) => `${children}`, + }, + }); + const rightRenderer = createUbbRegistry() + .register("rightonly", "recursive") + .createRenderer({ + text: textToString, + concat: joinStrings, + handlers: { + rightonly: ({ children }) => `${children}`, + }, + }); + + expect(leftRenderer.render("[leftonly]甲[/leftonly]")).toBe(""); + expect(rightRenderer.render("[leftonly]甲[/leftonly]")).toBe("[leftonly]甲[/leftonly]"); + expect(rightRenderer.render("[rightonly]乙[/rightonly]")).toBe(""); + expect(leftRenderer.render("[rightonly]乙[/rightonly]")).toBe("[rightonly]乙[/rightonly]"); + }); + + test("未注册标签保持原始文本,不吞掉属性、正文或结束标签", () => { + const renderer = createUbbRegistry().createRenderer({ + text: textToString, + concat: joinStrings, + handlers: {}, + }); + + expect(renderer.render("前[missing=1,title=标题]正文[/missing]后")).toBe( + "前[missing=1,title=标题]正文[/missing]后", + ); + }); +}); From ff3c60d742637c2938dfa2bffc2a6184006ccef9 Mon Sep 17 00:00:00 2001 From: shellRaining Date: Thu, 13 Aug 2026 22:43:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(ubb):=20=E7=BD=91=E7=AB=99=20Vue?= =?UTF-8?q?=20renderer=20=E8=BF=81=E7=A7=BB=E5=88=B0=E6=B3=9B=E5=9E=8B?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/website 的 UBB 渲染层改为复用 @cc98/ubb 的默认注册器与泛型 renderer,输出 VNodeChild 数组。删除手写遍历与标签分派,移除重复的 getUbbTextContent;quote 拉平改用 render(layer.children)。textStyle 组惰性渲染子节点,顺带修复图片重复计数隐患。同步架构与前端文档。 --- ARCHITECTURE.md | 2 +- .../rich-content/ContentRenderer.vue | 6 +- .../src/components/rich-content/text.ts | 8 -- .../rich-content/ubb/UbbRenderer.vue | 6 +- .../rich-content/ubb/emotion/index.ts | 6 +- .../src/components/rich-content/ubb/link.ts | 31 +++----- .../components/rich-content/ubb/literal.ts | 10 +-- .../src/components/rich-content/ubb/media.ts | 74 +++++++++--------- .../components/rich-content/ubb/permission.ts | 6 +- .../components/rich-content/ubb/registry.ts | 35 ++++----- .../rich-content/ubb/renderUbbNode.ts | 20 ----- .../components/rich-content/ubb/structure.ts | 50 ++++++------ .../components/rich-content/ubb/textStyle.ts | 70 +++++++++-------- .../src/components/rich-content/ubb/types.ts | 11 +-- docs/exec-plans/README.md | 1 + .../2026-08-11-website-ubb-vnode-renderer.md | 76 +++++++++++++++++++ docs/frontend.md | 2 +- 17 files changed, 226 insertions(+), 188 deletions(-) delete mode 100644 apps/website/src/components/rich-content/ubb/renderUbbNode.ts create mode 100644 docs/exec-plans/completed/2026-08-11-website-ubb-vnode-renderer.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7ea5009..de4d167 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -56,7 +56,7 @@ flowchart LR remark --> universe ``` -`packages/ubb` 不依赖 Vue。`createUbbRegistry` 登记标签名和解析模式,`createRenderer` 登记每个标签的输出 handler。默认 HTML 和 Markdown 导出器都是泛型 renderer 的字符串预设。`apps/website` 继续用自己的 Vue handler 解释 UBB AST,并集中处理 URL 安全、图片计数和媒体开关。Markdown 编辑器使用 Milkdown,编辑和阅读共享 remark 语法体系。 +`packages/ubb` 不依赖 Vue。`createUbbRegistry` 登记标签名和解析模式,`createRenderer` 登记每个标签的输出 handler。默认 HTML 和 Markdown 导出器都是泛型 renderer 的字符串预设。`apps/website` 通过同一泛型 renderer 注册 Vue handler 输出 VNode,并集中处理 URL 安全、图片计数和媒体开关。Markdown 编辑器使用 Milkdown,编辑和阅读共享 remark 语法体系。 ## 依赖方向 diff --git a/apps/website/src/components/rich-content/ContentRenderer.vue b/apps/website/src/components/rich-content/ContentRenderer.vue index 4f48319..ae1eaac 100644 --- a/apps/website/src/components/rich-content/ContentRenderer.vue +++ b/apps/website/src/components/rich-content/ContentRenderer.vue @@ -1,5 +1,5 @@