Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 层:
Expand All @@ -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<Output, Context>` 登记每个标签的输出 handler。默认 HTML 和 Markdown 导出器都是泛型 renderer 的字符串预设。`apps/website` 通过同一泛型 renderer 注册 Vue handler 输出 VNode,并集中处理 URL 安全、图片计数和媒体开关。Markdown 编辑器使用 Milkdown,编辑和阅读共享 remark 语法体系。

## 依赖方向

Expand Down
6 changes: 4 additions & 2 deletions apps/website/src/components/rich-content/ContentRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { parseUbb } from "@cc98/ubb";
import { defaultUbbRegistry } from "@cc98/ubb";
import { computed } from "vue";
import MarkdownRenderer from "./markdown/MarkdownRenderer.vue";
import { resolveRichContentOptions } from "./options";
Expand All @@ -9,7 +9,9 @@ import UbbRenderer from "./ubb/UbbRenderer.vue";
const props = defineProps<ContentRendererProps>();

const resolvedOptions = computed(() => resolveRichContentOptions(props.options));
const ubbNodes = computed(() => (props.type === "ubb" ? parseUbb(props.content) : []));
const ubbNodes = computed(() =>
props.type === "ubb" ? defaultUbbRegistry.parse(props.content) : [],
);
</script>

<template>
Expand Down
8 changes: 0 additions & 8 deletions apps/website/src/components/rich-content/text.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
import type { UbbNode } from "@cc98/ubb";

export function getUbbTextContent(nodes: readonly UbbNode[]): string {
return nodes
.map((node) => (node.type === "text" ? node.value : getUbbTextContent(node.children)))
.join("");
}

export function getOriginalUbbTag(tag: string): string {
return `[${tag}]`;
}
6 changes: 4 additions & 2 deletions apps/website/src/components/rich-content/ubb/UbbRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { defineComponent, h, type PropType } from "vue";
import type { RichContentOptions } from "../types";
import UniverseRoot from "../universe/UniverseRoot.vue";
import { createUbbRenderContext } from "./context";
import { renderUbbNodes } from "./renderUbbNode";
import { ubbVueRenderer } from "./registry";

export default defineComponent({
name: "UbbRenderer",
Expand All @@ -21,7 +21,9 @@ export default defineComponent({
setup(props) {
return () => {
const context = createUbbRenderContext(props.options);
return h(UniverseRoot, { contentType: "ubb" }, () => renderUbbNodes(props.nodes, context));
return h(UniverseRoot, { contentType: "ubb" }, () =>
ubbVueRenderer.renderNodes(props.nodes, context),
);
};
},
});
Expand Down
6 changes: 3 additions & 3 deletions apps/website/src/components/rich-content/ubb/emotion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { getOriginalUbbTag } from "../../text";
import type { UbbTagRenderer } from "../types";
import UbbEmotion from "./UbbEmotion.vue";

export const renderEmotionTag: UbbTagRenderer = (node, context) => {
if (!context.options.allowEmotion) return getOriginalUbbTag(node.tag);
export const renderEmotionTag: UbbTagRenderer = ({ node, context }) => {
if (!context.options.allowEmotion) return [getOriginalUbbTag(node.tag)];
const emotion = resolveUbbEmotionTag(node.tag);
return emotion ? h(UbbEmotion, { emotion }) : getOriginalUbbTag(node.tag);
return emotion ? [h(UbbEmotion, { emotion })] : [getOriginalUbbTag(node.tag)];
};
31 changes: 12 additions & 19 deletions apps/website/src/components/rich-content/ubb/link.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import type { UbbTagRenderer } from "./types";
import { h } from "vue";
import { getUbbTextContent } from "../text";
import { sanitizeLinkUrl } from "../security";
import UniverseLink from "../universe/UniverseLink.vue";
import type { UbbTagRenderer } from "./types";

function renderFallback(value: string) {
return value;
}

export const renderUrlTag: UbbTagRenderer = (node, context, renderChildren) => {
const source = node.attrs.positionals[0] ?? getUbbTextContent(node.children);
export const renderUrlTag: UbbTagRenderer = ({ attrs, text, children, context }) => {
const source = attrs.positionals[0] ?? text;
const href = sanitizeLinkUrl(source, context.options);
if (!href) return renderChildren(node, context);
if (!href) return children;

const children = renderChildren(node, context);
return h(UniverseLink, { href }, () => (children.length > 0 ? children : href));
return [h(UniverseLink, { href }, () => (children.length > 0 ? children : href))];
};

export const renderSiteLinkTag: UbbTagRenderer = (node, context, renderChildren) => {
const content = getUbbTextContent(node.children).trim();
const value = (node.attrs.positionals[0] ?? content).trim();
if (!value) return renderChildren(node, context);
export const renderSiteLinkTag: UbbTagRenderer = ({ node, attrs, text, children }) => {
const content = text.trim();
const value = (attrs.positionals[0] ?? content).trim();
if (!value) return children;

if (node.tag === "pm") {
return h("span", { class: "text-cc98-primary" }, `@${value}`);
return [h("span", { class: "text-cc98-primary" }, `@${value}`)];
}

const route =
Expand All @@ -34,10 +28,9 @@ export const renderSiteLinkTag: UbbTagRenderer = (node, context, renderChildren)
: node.tag === "board"
? `/list/${encodeURIComponent(value)}`
: null;
if (!route) return renderFallback(content || value);
if (!route) return [content || value];

const children = renderChildren(node, context);
const fallback =
node.tag === "user" ? `@${value}` : node.tag === "topic" ? `帖子 ${value}` : `板块 ${value}`;
return h(UniverseLink, { href: route }, () => (children.length > 0 ? children : fallback));
return [h(UniverseLink, { href: route }, () => (children.length > 0 ? children : fallback))];
};
10 changes: 4 additions & 6 deletions apps/website/src/components/rich-content/ubb/literal.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { h } from "vue";
import { getUbbTextContent } from "../text";
import MarkdownRenderer from "../markdown/MarkdownRenderer.vue";
import UniversePlainText from "../universe/UniversePlainText.vue";
import UbbCodeBlock from "./UbbCodeBlock.vue";
import type { UbbTagRenderer } from "./types";

export const renderLiteralTag: UbbTagRenderer = (node, context) => {
const content = getUbbTextContent(node.children);
export const renderLiteralTag: UbbTagRenderer = ({ node, text, context }) => {
if (node.tag === "code") {
return h(UbbCodeBlock, { code: content });
return [h(UbbCodeBlock, { code: text })];
}
if (node.tag === "md" && context.options.allowEmbeddedMarkdown) {
return h(MarkdownRenderer, { content, options: context.options });
return [h(MarkdownRenderer, { content: text, options: context.options })];
}
return h(UniversePlainText, { content });
return [h(UniversePlainText, { content: text })];
};
74 changes: 35 additions & 39 deletions apps/website/src/components/rich-content/ubb/media.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { h } from "vue";
import { getUbbTextContent } from "../text";
import { h, type VNodeChild } from "vue";
import { sanitizeImageUrl, sanitizeLinkUrl, sanitizeMediaUrl } from "../security";
import type { UbbRenderContext } from "./context";
import UniverseAudio from "../universe/UniverseAudio.vue";
import UniverseBili from "../universe/UniverseBili.vue";
import UniverseImage from "../universe/UniverseImage.vue";
Expand All @@ -15,50 +15,47 @@ function renderImage(
source: string,
title: string | undefined,
hidden: boolean,
context: Parameters<UbbTagRenderer>[1],
) {
context: UbbRenderContext,
): VNodeChild[] {
const safeSrc = sanitizeImageUrl(source, context.options);
if (!safeSrc || context.state.imageCount >= context.options.maxImageCount) return source;
if (!safeSrc || context.state.imageCount >= context.options.maxImageCount) return [source];
context.state.imageCount += 1;
return h(UniverseImage, {
src: safeSrc,
alt: title ?? "",
title,
defaultVisible: !hidden,
allowToolbox: context.options.allowToolbox,
showCaption: Boolean(title),
});
return [
h(UniverseImage, {
src: safeSrc,
alt: title ?? "",
title,
defaultVisible: !hidden,
allowToolbox: context.options.allowToolbox,
showCaption: Boolean(title),
}),
];
}

export const renderImageTag: UbbTagRenderer = (node, context) => {
const source = getUbbTextContent(node.children).trim();
return renderImage(
source,
node.attrs.named.title || undefined,
node.attrs.positionals[0] === "1",
context,
);
export const renderImageTag: UbbTagRenderer = ({ attrs, text, context }) => {
const source = text.trim();
return renderImage(source, attrs.named.title || undefined, attrs.positionals[0] === "1", context);
};

export const renderUploadTag: UbbTagRenderer = (node, context) => {
const source = getUbbTextContent(node.children).trim();
const type = node.attrs.positionals[0]?.toLowerCase();
export const renderUploadTag: UbbTagRenderer = ({ attrs, text, context }) => {
const source = text.trim();
const type = attrs.positionals[0]?.toLowerCase();
if (type && IMAGE_UPLOAD_TYPES.has(type)) {
return renderImage(source, "上传图片", node.attrs.positionals[1] === "1", context);
return renderImage(source, "上传图片", attrs.positionals[1] === "1", context);
}

const href = sanitizeLinkUrl(source, context.options);
return href ? h(UniverseUpload, { href }) : source;
return href ? [h(UniverseUpload, { href })] : [source];
};

export const renderMediaTag: UbbTagRenderer = (node, context) => {
const source = getUbbTextContent(node.children).trim();
export const renderMediaTag: UbbTagRenderer = ({ node, attrs, text, context }) => {
const source = text.trim();
const url = sanitizeMediaUrl(source, context.options);
if (!url) return source;
if (!url) return [source];
if (node.tag === "audio" || node.tag === "mp3") {
return h(UniverseAudio, { url, title: node.attrs.named.title || undefined });
return [h(UniverseAudio, { url, title: attrs.named.title || undefined })];
}
return h(UniverseVideo, { url });
return [h(UniverseVideo, { url })];
};

interface BiliDescriptor {
Expand Down Expand Up @@ -88,14 +85,13 @@ function resolveBiliSource(source: string, requestedPage?: string): BiliDescript
}
}

export const renderBiliTag: UbbTagRenderer = (node, context) => {
const source = getUbbTextContent(node.children).trim();
if (!context.options.allowMediaContent) return source;
const descriptor = resolveBiliSource(source, node.attrs.positionals[0]);
return descriptor ? h(UniverseBili, descriptor) : source;
export const renderBiliTag: UbbTagRenderer = ({ attrs, text, context }) => {
const source = text.trim();
if (!context.options.allowMediaContent) return [source];
const descriptor = resolveBiliSource(source, attrs.positionals[0]);
return descriptor ? [h(UniverseBili, descriptor)] : [source];
};

export const renderMathTag: UbbTagRenderer = (node) => {
const content = getUbbTextContent(node.children);
return h(UniverseMath, { content, inline: node.tag === "m" });
export const renderMathTag: UbbTagRenderer = ({ node, text }) => {
return [h(UniverseMath, { content: text, inline: node.tag === "m" })];
};
6 changes: 3 additions & 3 deletions apps/website/src/components/rich-content/ubb/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function resolvePermissionMessage(tag: string, value?: string): MessageSe
return settings[index];
}

export const renderPermissionTag: UbbTagRenderer = (node) => {
const setting = resolvePermissionMessage(node.tag, node.attrs.positionals[0]);
return setting ? h(UniverseMessageBar, setting) : `[${node.tag}]`;
export const renderPermissionTag: UbbTagRenderer = ({ node, attrs }) => {
const setting = resolvePermissionMessage(node.tag, attrs.positionals[0]);
return setting ? [h(UniverseMessageBar, setting)] : [`[${node.tag}]`];
};
35 changes: 18 additions & 17 deletions apps/website/src/components/rich-content/ubb/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { matchUbbRegexTagFamily, type UbbRegexTagFamily, type UbbStaticTagName } from "@cc98/ubb";
import { defaultUbbRegistry, matchUbbRegexTagFamily, type UbbStaticTagName } from "@cc98/ubb";
import type { VNodeChild } from "vue";
import type { UbbRenderContext } from "./context";
import { renderEmotionTag } from "./emotion";
import { renderLiteralTag } from "./literal";
import { renderSiteLinkTag, renderUrlTag } from "./link";
Expand All @@ -14,7 +16,7 @@ import { renderDividerTag, renderQuoteTag, renderTableTag } from "./structure";
import { renderAlignmentTag, renderTextStyleTag } from "./textStyle";
import type { UbbTagRenderer } from "./types";

const staticTagRenderers = {
const staticTagHandlers = {
b: renderTextStyleTag,
i: renderTextStyleTag,
u: renderTextStyleTag,
Expand Down Expand Up @@ -56,18 +58,17 @@ const staticTagRenderers = {
allowviewer: renderPermissionTag,
} satisfies Record<UbbStaticTagName, UbbTagRenderer>;

const regexFamilyRenderers = {
em: renderEmotionTag,
ac: renderEmotionTag,
ms: renderEmotionTag,
mahjong: renderEmotionTag,
cc98: renderEmotionTag,
tb: renderEmotionTag,
} satisfies Record<UbbRegexTagFamily, UbbTagRenderer>;

export function resolveUbbTagRenderer(tag: string): UbbTagRenderer | undefined {
const staticRenderer = staticTagRenderers[tag as UbbStaticTagName];
if (staticRenderer) return staticRenderer;
const family = matchUbbRegexTagFamily(tag);
return family ? regexFamilyRenderers[family] : undefined;
}
/**
* 网站的 Vue UBB renderer:输出 VNodeChild 数组。
* 标签解析模式来自 @cc98/ubb 的默认注册器,handler 键由 registry 泛型推导,
* 新增静态标签时类型系统会要求同步登记 handler。
*/
export const ubbVueRenderer = defaultUbbRegistry.createRenderer<VNodeChild[], UbbRenderContext>({
text: (value) => [value],
concat: (parts) => parts.flat(),
handlers: staticTagHandlers,
fallback: (props) => {
if (matchUbbRegexTagFamily(props.node.tag)) return renderEmotionTag(props);
return [`[${props.node.tag}]${props.text}[/${props.node.tag}]`];
},
});
20 changes: 0 additions & 20 deletions apps/website/src/components/rich-content/ubb/renderUbbNode.ts

This file was deleted.

Loading