npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ubb-core

v1.0.0

Published

CC98 UBB 解析器、泛型输出注册器与 HTML/Markdown 预设

Downloads

190

Readme

@cc98/ubb

框架无关的 UBB 解析和输出工具。自己注册标签,或使用包内的 CC98 标签规则;同一份解析结果可以输出字符串、VNode 或其他结果。

注册标签并创建 renderer

import { createUbbRegistry } from "@cc98/ubb";

const ubb = createUbbRegistry()
  .register("b", "recursive")
  .register("code", "text")
  .register("line", "empty")
  .register("spoiler", "recursive");

const html = ubb.createRenderer<string>({
  renderText: escapeHtml,
  concat: (parts) => parts.join(""),
  handlers: {
    b: ({ content }) => `<strong>${content}</strong>`,
    code: ({ content }) => `<pre><code>${content}</code></pre>`,
    line: () => "<hr>",
    spoiler: ({ attrs, content }) => {
      const title = escapeHtml(attrs.positionals[0] ?? "剧透");
      return `<details><summary>${title}</summary>${content}</details>`;
    },
  },
});

html.render("[spoiler=注意][b]隐藏内容[/b][/spoiler]");

register() 返回新的 registry,原实例不会改变。标签名会转成小写,重复注册会抛错。parse() 在内部按注册规则判断标签模式。四种解析模式分别是:

  • recursive:内部继续解析 UBB。
  • text:内部保持纯文本。
  • empty:自闭合,不读取 children。
  • autoclose:结束标签可省略,有结束标签时仍可包裹内容。

默认标签头支持 [tag] 和 [tag=value],等号之后的内容原样放在 attrs.positionals[0]。注册器默认不识别任何标签;未注册、未闭合或解析失败的标签按原文保留。

标签节点的 raw 记录开始、结束标签的原文,例如 [URL=a.com]x[/Url] 得到 { open: "[URL=a.com]", close: "[/Url]" }。没写结束标签时 close 为 null,可以借此区分 [user=张三] 和 [user=张三][/user]。handler 无法渲染某个标签时,用 raw.open、子节点和 raw.close 能把它原样还原。

创建注册器时也可以一次传入全部配置,效果与逐个 register() 相同:

const registry = createUbbRegistry({
  tags: { badge: "empty" },
  parseTag: (source) => {
    const match = /^badge:(\w+)$/.exec(source);
    if (!match) return null;
    return { name: "badge", attrs: { positionals: [match[1]], named: {} } };
  },
});

registry.parse("[badge:gold]");

parseTag 用来替换不同论坛的参数语法,不必重写整个 UBB 解析器。

降级为文本的标签默认各自占一个文本节点,价格[约[b]100[/b] 会得到四个相邻的文本节点。需要更紧凑的 AST 时,传入 mergeAdjacentText: true,相邻文本会合并成一个节点。

parseTag 收到 [ 与 ] 之间的原始字符串,返回标签名和属性;返回 null 或抛错时,原文会作为文本保留。标签名会转为小写,attrs 使用 { positionals: string[], named: Record<string, string> }。这是标签头的参数解析接口,不负责匹配结束标签或修改文本容错规则。回调可作为独立函数复用,避免在每个标签上重复拆参数。

标签族

表情这类按编号成批出现的标签,用正则登记成一个标签族:

const ubb = createUbbRegistry()
  .register("b", "recursive")
  .registerFamily("emoji", /^em\d{2}$/, "empty");

ubb.parse("[em01]");
// [{ type: "tag", tag: "em01", family: "emoji", attrs: …, children: [] }]

const html = ubb.createRenderer<string>({
  renderText: escapeHtml,
  concat: (parts) => parts.join(""),
  handlers: {
    b: ({ content }) => `<strong>${content}</strong>`,
    emoji: ({ node }) => `<img src="/emoji/${node.tag}.gif">`,
  },
});

精确标签优先匹配,未命中时按登记顺序尝试各个标签族,第一个匹配的生效。正则收到的是转成小写后的标签名,不能带 g 或 y 标志。标签名和族名共用 handler 的键,不能重名。命中标签族的节点带 family 字段,renderer 按族名查找 handler,node.tag 仍是具体标签名。

Handler 参数

每个 handler 会收到同一组参数:

({ node, attrs, content, textContent, context, renderNodes }) => output;
  • node 是当前标签节点,attrs 等于 node.attrs。注意 node.children 是子节点 AST,content 才是渲染结果。
  • content 是子节点的渲染结果,首次读取时计算并缓存。
  • textContent 是子树的纯文本内容。
  • context 是调用 render(source, context) 时传入的值。
  • renderNodes(nodes) 使用当前 renderer 和 context 渲染任意子树,不执行根级 finalize。

createRenderer<Output, Context>() 的 Output 不限于字符串。调用方只需提供 renderText() 和 concat(),就能输出 VNode、ReactNode 或自己的 AST。

CC98 预设

CC98 标签和表情规则从 @cc98/ubb/cc98 导入。它沿用旧论坛的逗号、等号、引号参数语法,表情登记为 em、ac、ms、mahjong、cc98、tb 六个标签族;HTML、Markdown 输出从独立子路径导入:

import { cc98Registry } from "@cc98/ubb/cc98";
import { ubbHtmlRenderer, ubbToHtml } from "@cc98/ubb/cc98/html";
import { ubbMarkdownRenderer, ubbToMarkdown } from "@cc98/ubb/cc98/markdown";

const nodes = cc98Registry.parse("[color=red]正文[/color]");

CC98 规则也可以拆开复用。parseCc98Tag 是旧论坛的标签头解析函数,可以配给自己的标签集;unregister() 移除一个标签或标签族,configure() 替换 parseTag 等解析配置,两者都返回新的注册器:

import { cc98Registry, parseCc98Tag } from "@cc98/ubb/cc98";

// 自己的标签,沿用 CC98 的参数写法
const forum = createUbbRegistry({ tags: { spoiler: "recursive" }, parseTag: parseCc98Tag });

// 在 CC98 规则上改一个标签的模式
const custom = cc98Registry.unregister("md").register("md", "recursive");

ubbToHtml() 和 ubbToMarkdown() 是两个 CC98 renderer 的快捷函数。解析统一走注册器的 parse(),解析规则和 renderer 因此总是同一份配置。

自定义 HTML handler 直接返回字符串,属于受信任代码。默认 HTML preset 会转义文本和属性并过滤 URL 协议,但无法约束调用方自行拼接的 HTML。