* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖) tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName / package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与 lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。 * feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗 - splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化; - mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用 bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗: 删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部 href/xlink:href,仅接受 SVG 根,解析失败返回空串; - 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互, 基于已清洗 SVG 克隆重写,不新增攻击面); - 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。 * test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md - mermaid-blocks.spec 10 例 fence 切块语义; - mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/ script/外来元素/畸形 XML/非 SVG 根); - bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid (chunk-artifact 补 addEventListener stub); - 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、 点击弹窗 Esc 关闭、preview 模式编辑器隐藏。 * docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新 设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型 (strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。 * fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1) XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML), oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先 toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。 * fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1) XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到 <sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按 localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写 script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器 命中 0,新逻辑命中 2)。 * fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2) P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次 MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram (React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子, 协调安全;块不再 mermaid 时还原原 children)。新增 tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。 P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且 长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复 带缩进开 fence 的 info 提取。blocks 单测 +3 例。
122 lines
5.1 KiB
TypeScript
122 lines
5.1 KiB
TypeScript
/**
|
|
* Registry manifest consistency spec: guards the plugin-registry release form
|
|
* (`dsh.plugin.json`) against the build output and package.json, so the repo
|
|
* cannot drift into an uninstallable / uneable state:
|
|
* - `id` passes the registry's strict two-segment validation (native form),
|
|
* - `version` stays in sync with package.json,
|
|
* - the manifest entry files exist in the build output,
|
|
* - the registry client bundle registers exactly the manifest id (the
|
|
* browser-side `arrive()` contract), and the official client bundle keeps
|
|
* the package-name id (the client-modules compose contract) — the two
|
|
* bundles must never be swapped.
|
|
*
|
|
* Reads the built lib/ output, so run `pnpm build` first (the registry
|
|
* install validates main/client.main existence the same way).
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
|
|
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
|
|
interface PluginManifest {
|
|
id: string
|
|
version: string
|
|
main: string
|
|
description?: string
|
|
engines?: { dsh?: string }
|
|
contributes?: { tools?: string[]; skills?: string[] }
|
|
client?: { main?: string; inject?: string[]; immediately?: boolean }
|
|
}
|
|
|
|
interface PackageJson {
|
|
name: string
|
|
version: string
|
|
}
|
|
|
|
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
|
|
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
|
|
|
|
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
|
|
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
|
|
|
/** Literal require() specifiers the frozen browser module table can answer. */
|
|
const CLIENT_REQUIRE_ALLOWED = new Set([
|
|
'react',
|
|
'react/jsx-runtime',
|
|
'react-dom',
|
|
'react-dom/client',
|
|
'cordis',
|
|
'@deepseek-ai/dsh-client-ui-slots',
|
|
'@deepseek-ai/dsh-client-web-react',
|
|
'@deepseek-ai/dsh-client-ui-primitives',
|
|
'@deepseek-ai/dsh-client-schema-form',
|
|
'@deepseek-ai/dsh-client-runtime/client',
|
|
])
|
|
|
|
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
|
function bundleId(file: string): string {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
|
|
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
|
|
return match[1]!
|
|
}
|
|
|
|
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
|
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
|
|
|
|
/** The global registry slot a built chunk script assigns (its factory key). */
|
|
function chunkSlot(file: string): string {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
const match = /globalThis\.__dshChunks__\["([a-z0-9-]+)"\]/.exec(source)
|
|
if (match === null) throw new Error(`${file} assigns no globalThis.__dshChunks__ slot — run \`pnpm build\` first`)
|
|
return match[1]!
|
|
}
|
|
|
|
describe('registry manifest consistency (dsh.plugin.json)', () => {
|
|
it('id is a valid two-segment registry id (native form)', () => {
|
|
expect(manifest.id).toMatch(ID_PATTERN)
|
|
})
|
|
|
|
it('version matches package.json', () => {
|
|
expect(manifest.version).toBe(pkg.version)
|
|
})
|
|
|
|
it('main and client.main exist in the build output', () => {
|
|
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
|
|
expect(manifest.client?.main).toBeDefined()
|
|
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
|
|
})
|
|
|
|
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
|
|
const registryId = bundleId(manifest.client!.main!)
|
|
expect(registryId).toBe(manifest.id)
|
|
expect(bundleId('lib/client.js')).toBe(pkg.name)
|
|
expect(registryId).not.toBe(pkg.name)
|
|
})
|
|
|
|
it('the lazy chunk bundles exist and assign their global registry slots (served by /sidebar/bundle)', () => {
|
|
for (const file of CHUNK_FILES) {
|
|
expect(existsSync(resolve(ROOT, file)), file).toBe(true)
|
|
expect(chunkSlot(file), file).toBe(file.slice('lib/client-'.length, -'.js'.length))
|
|
}
|
|
})
|
|
|
|
it('client bundles require only frozen module-table entries', () => {
|
|
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
// Exclude method calls such as `freeModule.require("util")`; only the
|
|
// loader factory's lexical require() is constrained by the module table.
|
|
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
|
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id)), file).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('contributes declares no tools or skills (the host half registers none)', () => {
|
|
expect(manifest.contributes?.tools).toEqual([])
|
|
expect(manifest.contributes?.skills).toEqual([])
|
|
})
|
|
})
|