diff --git a/bun.lock b/bun.lock index a2d961196..e1d2a398f 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@opencode-ai/util": "workspace:*", "electron-context-menu": "4.1.2", @@ -145,7 +145,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "2026.5.5", + "version": "2026.5.7", "bin": { "opencode": "./bin/opencode", }, @@ -336,7 +336,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", @@ -356,6 +356,7 @@ "virtua": "catalog:", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -371,7 +372,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@opencode-ai/core": "workspace:*", "zod": "catalog:", @@ -1839,7 +1840,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index a27218cea..5160f3983 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -414,8 +414,13 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, path: string) => { - shell.showItemInFolder(path) + ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, target: string) => { + // shell.showItemInFolder needs an absolute path; relative resolves go + // through the renderer where the chat session knows its workspace cwd. + // process.cwd() in a packaged Electron main process is not the user's + // workspace (typically `/Users/` on macOS), so absolutizing here + // would silently reveal the wrong file. + shell.showItemInFolder(target) }) ipcMain.handle("stat-paths", async (_event: IpcMainInvokeEvent, paths: string[]) => { diff --git a/packages/desktop-electron/src/main/markdown.ts b/packages/desktop-electron/src/main/markdown.ts index b956f4876..8d0ebd633 100644 --- a/packages/desktop-electron/src/main/markdown.ts +++ b/packages/desktop-electron/src/main/markdown.ts @@ -4,7 +4,16 @@ const renderer = new marked.Renderer() renderer.link = ({ href, title, text }: Tokens.Link) => { const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + // The desktop renderer's document-level handler grabs every .external-link + // and routes it to shell.openExternal. Only mark true remote links so hash + // anchors and repo paths fall through to the markdown component's own + // click handler (scroll / Finder reveal). Keep this in sync with the + // jsParser branch in packages/ui/src/context/marked.tsx. + const remote = /^(?:https?:\/\/|mailto:)/i.test(href) + if (remote) { + return `${text}` + } + return `${text}` } export function parseMarkdown(input: string) { diff --git a/packages/ui/bunfig.toml b/packages/ui/bunfig.toml new file mode 100644 index 000000000..363990451 --- /dev/null +++ b/packages/ui/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./happydom.ts"] diff --git a/packages/ui/happydom.ts b/packages/ui/happydom.ts new file mode 100644 index 000000000..ce010847d --- /dev/null +++ b/packages/ui/happydom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" + +GlobalRegistrator.register() diff --git a/packages/ui/package.json b/packages/ui/package.json index 066e91b45..fe863740e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "generate:tailwind": "bun run script/tailwind.ts" }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -55,7 +56,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1fded2771..a6c17b5b5 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -1,14 +1,14 @@ +/* Markdown body — W3 lock 2026-05-10 + Source of truth: docs/design/preview/markdown-body.html + Standard: docs/design/STANDARDS.md#L43 */ + [data-component="markdown"] { - /* Reset & Base Typography */ min-width: 0; max-width: 100%; overflow-wrap: break-word; + font: var(--type-body); color: var(--fg-strong); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); /* 13px */ - line-height: 160%; - /* Spacing for flow */ > *:first-child { margin-top: 0; } @@ -16,121 +16,311 @@ margin-bottom: 0; } - /* Headings: Same size, distinguished by color and spacing */ + /* §1 Headings · G mapping + All tiers share font + margin; hierarchy comes from fg color only. + Spacing follows the 8/12/16 semantic gradient: section-break (16 + above) > paragraph rhythm (12) > heading-to-content (8 below). + Every value is on the 4pt grid; the gradient itself encodes + "stick < breathe < break". */ h1, h2, h3, h4, h5, h6 { - font-size: var(--font-size-hierarchy); + font: var(--type-h3); + margin: var(--space-lg) 0 var(--space-sm); + text-transform: none; + letter-spacing: 0; + } + h1, + h2 { color: var(--fg-strong); - font-weight: var(--font-weight-medium); - margin-top: 0px; - margin-bottom: 24px; - line-height: var(--line-height-large); + } + h3 { + color: var(--fg-base); + } + h4, + h5, + h6 { + color: var(--fg-weak); } - /* Emphasis & Strong: Neutral strong color */ + /* §2 Paragraph + inline emphasis */ + p { + margin: 0 0 var(--space-md); + } + p:last-child { + margin-bottom: 0; + } strong, b { color: var(--fg-strong); font-weight: var(--font-weight-medium); } - - /* Paragraphs */ - p { - margin-bottom: 12px; + em, + i { + font-style: italic; + } + del, + s { + text-decoration: line-through; + color: var(--fg-weak); } - /* Links */ + /* §3 Links · ink-only quiet underline. + Hover 不动 offset(避免下划线下沉跳跃),不动 text color(DESIGN.md L120 + brand 保留给选中/激活/运行)— 仅 underline 自身二维加重,落 NN/g + 「color + shape」高对比度档。 */ a { - color: var(--brand-primary); - text-decoration: none; - font-weight: inherit; + color: inherit; + text-decoration: underline; + text-decoration-color: var(--fg-weak); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: + text-decoration-color var(--duration-fast) ease-out, + text-decoration-thickness var(--duration-fast) ease-out; + overflow-wrap: anywhere; } - a:hover { - text-decoration: underline; - text-underline-offset: 2px; + text-decoration-color: var(--fg-strong); + text-decoration-thickness: 2px; } - - /* Lists */ - ul, - ol { - margin-top: 8px; - margin-bottom: 12px; - margin-left: 0; - padding-left: 32px; - list-style-position: outside; + /* 偏离: focus halo border-radius 2px (DESIGN.md radii 6/10/14/9999). + 理由: inline 链接焦点环紧贴文字行盒,4pt 网格不适用于 inline 焦点圆角; + 2px 是 inline-link 行业惯例 (Stripe / Linear / GitHub 同向) */ + a:focus-visible { + outline: none; + border-radius: 2px; + box-shadow: + 0 0 0 1px var(--brand-primary), + 0 0 0 3px rgba(255, 89, 16, 0.2); } - ul { - list-style-type: disc; + /* §4 Inline code · 复用 DESIGN.md「Code & diff」 */ + :not(pre) > code { + font: var(--type-mono-small); + background: var(--surface-sunken); + padding: 0 var(--space-xs); + border-radius: var(--radius-sm); + color: var(--fg-strong); + overflow-wrap: anywhere; } + /* §5 Lists */ + ul, ol { - list-style-type: decimal; - padding-left: 2.25rem; + margin: 0 0 var(--space-md); + padding-left: 24px; + } + ul ul, + ul ol, + ol ul, + ol ol { + margin: var(--space-xs) 0; } - li { - margin-bottom: 8px; + margin: 0 0 var(--space-xs); } - - li > p:first-child { - display: inline; - margin: 0; + li:last-child { + margin-bottom: 0; } - - li > p + p { - display: block; - margin-top: 0.5rem; + li > p { + margin: 0 0 var(--space-xs); + } + li > p:last-child { + margin-bottom: 0; + } + ul { + list-style: disc; + } + ol { + list-style: decimal; } - li::marker { color: var(--fg-weak); } - /* Nested lists spacing */ - li > ul, - li > ol { - margin-top: 0.25rem; - margin-bottom: 0.25rem; - padding-left: 1rem; /* Minimal indent for nesting only */ + /* §6 Task list · 16px circle / circle-check svg, read-only. + Per-li opt-in (li.task-item) so a list mixing tasks and bullets keeps + the bullet on the non-task siblings. */ + li.task-item { + list-style: none; + margin-left: -24px; + padding-left: 0; + display: flex; + align-items: flex-start; + gap: var(--space-sm); + } + /* 偏离: svg margin-top 2px (DESIGN.md 4pt 网格). + 理由: 16px 图标对齐 13/160 文字基线的光学调整 ((20.8-16)/2 ≈ 2), + 不属布局间距 */ + li.task-item > svg { + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--fg-strong); + margin-top: 2px; } - - li > ol { - padding-left: 1.75rem; + li.task-item > [data-slot="task-label"] { + flex: 1 1 0; + min-width: 0; } + /* If marked wraps loose-list content in

, the paragraph just inherits + normal margin handling. */ - /* Blockquotes */ + /* §7 Blockquote · 2px border-weak left rule + fg-weak text only. + Industry-aligned (GitHub / Tailwind prose / Notion / shadcn — none + of them use a background). 2px matches shadcn's tightest end of the + industry band (2-4px). The neutral-grey rule is a documented + Markdown convention, not the coloured-stripe pattern impeccable + BAN 1 forbids. Reasoned 2026-05-10. */ blockquote { + margin: 0 0 var(--space-md); + padding: 0 var(--space-lg); border-left: 2px solid var(--border-weak); - margin: 1.5rem 0; - padding-left: 0.5rem; color: var(--fg-weak); - font-style: normal; + } + blockquote > :first-child { + margin-top: 0; + } + blockquote > :last-child { + margin-bottom: 0; } - /* Horizontal Rule - Invisible spacing only */ + /* §8 Horizontal rule · matches paragraph rhythm (12), not section + break (16). hr is a structural marker, not a heading; sits in the + paragraph-flow tier, doesn't steal visual weight. */ hr { - border: none; - height: 0; - margin: 40px 0; + border: 0; + border-top: 1px solid var(--border-weak); + margin: var(--space-md) 0; + } + + /* §9 Table — chat-density flat header (industry-aligned with GitHub / + Tailwind prose / shadcn / Linear / Stripe). Two signals: type-h3 + (font-weight 500) + 1px border-base bottom. No fill — cream surface + was visually noisy at our 3-4% delta and didn't match the chat + surface; deep-research 2026-05-10 confirmed flat-header is the + dominant chat / content-table pattern, with WCAG 1.4.3 satisfied + by text contrast alone. Row dividers use --border-weak for + visibility in both themes. No zebra, no hover. */ + table { + border-collapse: collapse; + width: 100%; + margin: 0 0 var(--space-md); + font: var(--type-body); + } + thead th { + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-base); + font: var(--type-h3); + color: var(--fg-strong); + text-align: left; + vertical-align: top; + } + tbody td { + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-weak); + color: var(--fg-base); + vertical-align: top; + } + tbody tr:last-child td { + border-bottom: 0; + } + th[data-numeric="true"], + td[data-numeric="true"], + th[align="right"], + td[align="right"] { + text-align: right; + font-variant-numeric: tabular-nums; } + /* §10 Image · 1px frame + radius-md (DESIGN.md「User content frame」) + margin matches other block elements (mt:0 / mb:12) so heading-below + gradient (h mb 8 → img) stays at 8, not pushed to 12 by symmetric mt. + cursor: zoom-in only when the host wires onImageClick (data-image-click) + so the cursor doesn't promise interactivity that has no handler. */ + img { + max-width: 100%; + height: auto; + display: block; + border: 1px solid var(--border-weak); + border-radius: var(--radius-md); + margin: 0 0 var(--space-md); + } + &[data-image-click] img { + cursor: zoom-in; + } + + /* §11 details / summary · chev + summary text, chev rotates 90deg on open */ + details { + margin: 0 0 var(--space-md); + } + details > summary { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) 0; + list-style: none; + font-weight: var(--font-weight-medium); + color: var(--fg-strong); + user-select: none; + } + details > summary::-webkit-details-marker { + display: none; + } + details > summary > svg.chev { + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--fg-weaker); + transition: transform var(--duration-base) ease-out; + } + details[open] > summary > svg.chev { + transform: rotate(90deg); + } + details > :not(summary) { + margin-left: 24px; + } + /* summary is the heading of details — first content sits 8px below + (summary's padding-bottom), not pushed by its own margin-top. */ + details > summary + * { + margin-top: 0; + } + + /* §12 KaTeX block math · normalized to other block elements (mt:0 / mb:12). + Without this, KaTeX default `.katex-display { margin: 1em 0 }` collapses + against heading mb 8 → 16 (breaks the 8/12/16 rhythm). */ + .katex-display { + margin: 0 0 var(--space-md); + } + + /* §13 Fence code · defers to DESIGN.md L36「Code & diff」.codeblock truth. + pre + .shiki rules retained for code-surfaces compatibility. + margin matches other block elements (mt:0 / mb:12) so heading-below + gradient (h mb 8 → pre) stays at 8, not pushed to 12 by symmetric mt. */ + pre { + margin: 0 0 var(--space-md); + overflow: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } .shiki { - font-size: var(--font-size-x-small); - padding: 12px; - border-radius: 6px; + font: var(--type-mono-small); + padding: var(--space-md); + border-radius: var(--radius-sm); border: 0.5px solid var(--border-weak); } + /* Code copy-button overlay (fence shell affordance) */ [data-component="markdown-code"] { position: relative; } - [data-slot="markdown-copy-button"] { position: absolute; top: 4px; @@ -146,121 +336,47 @@ bottom: calc(100% + 4px); transform: translateX(-50%); z-index: 1000; - max-width: 320px; border-radius: var(--radius-sm); background: var(--surface-raised); - color: var(--fg-on-brand); + color: var(--fg-strong); padding: 2px 8px; border: 1px solid var(--border-weak, rgba(0, 0, 0, 0.07)); box-shadow: var(--shadow-floating); - pointer-events: none; white-space: nowrap; - font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - opacity: 0; transition: opacity 0.15s ease; } } - [data-slot="markdown-copy-button"]:hover::after, [data-slot="markdown-copy-button"]:focus-visible::after { opacity: 1; } - [data-slot="markdown-copy-button"][data-variant="secondary"] { box-shadow: none; border: 1px solid var(--border-weak); } - [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { color: var(--icon-base); } - - [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { + [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"], + [data-component="markdown-code"]:focus-within [data-slot="markdown-copy-button"] { opacity: 1; } - [data-slot="markdown-copy-button"] [data-slot="check-icon"] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] { display: inline-flex; } - - pre { - margin-top: 12px; - margin-bottom: 32px; - overflow: auto; - - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - } - - :not(pre) > code { - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - color: var(--syntax-string); - font-weight: var(--font-weight-medium); - font-size: var(--font-size-x-small); - - /* padding: 2px 2px; */ - /* margin: 0 1.5px; */ - /* border-radius: 2px; */ - /* background: var(--surface-base); */ - /* box-shadow: 0 0 0 0.5px var(--border-weak); */ - } - - /* Tables */ - table { - width: 100%; - border-collapse: collapse; - margin: 24px 0; - font-size: var(--font-size-base); - display: block; - overflow-x: auto; - } - - th, - td { - /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ - border-bottom: 1px solid var(--border-weaker); - padding: 12px; - text-align: left; - vertical-align: top; - } - - th { - color: var(--fg-strong); - font-weight: var(--font-weight-medium); - border-bottom: 1px solid var(--border-weak); - } - - /* Images */ - img { - max-width: 100%; - height: auto; - border-radius: 4px; - margin: 1.5rem 0; - display: block; - } -} - -[data-component="markdown"] a.external-link:hover > code { - text-decoration: underline; - text-underline-offset: 2px; } diff --git a/packages/ui/src/components/markdown.stories.tsx b/packages/ui/src/components/markdown.stories.tsx index cae429486..973d823c3 100644 --- a/packages/ui/src/components/markdown.stories.tsx +++ b/packages/ui/src/components/markdown.stories.tsx @@ -6,26 +6,18 @@ import { markdown } from "../storybook/fixtures" const docs = `### Overview Render sanitized Markdown with code blocks, inline code, and safe links. -Pair with \`Code\` for standalone code views. - -### API -- Required: \`text\` Markdown string. -- Uses the Marked context provider for parsing and sanitization. - -### Variants and states -- Code blocks include copy buttons when rendered. - -### Behavior -- Sanitizes HTML and auto-converts inline URL code to links. -- Adds copy buttons to code blocks. - -### Accessibility -- Copy buttons include aria-labels from i18n. -- TODO: confirm link target behavior in sanitized output. - -### Theming/tokens -- Uses \`data-component="markdown"\` and related slots for styling. +W3 lock 2026-05-10 (see docs/design/preview/markdown-body.html · STANDARDS.md#L43). +### Variants +- **Basic** — kitchen-sink fixture +- **W3.Headings** — H1-H4 G mapping (all 13px sans, hierarchy via fg color + margin) +- **W3.LinksInkOnly** — quiet underline, hover currentColor, focus brand ring +- **W3.TaskList** — 16px circle / circle-check svg, read-only +- **W3.Blockquote** — 2px border-weak left rule + fg-weak text (Markdown 行业惯例,非 BAN 1 彩条) +- **W3.Table** — \`th data-numeric="true"\` for tabular-nums + right align +- **W3.Details** — chev rotates 90deg on open +- **W3.Math** — inline + block KaTeX +- **W3.HtmlWhitelist** — sub/sup/kbd/abbr/del; script/iframe stripped ` const story = create({ @@ -36,6 +28,73 @@ const story = create({ }, }) +const fixtures = { + headings: [ + "# H1 章节标题", + "正文段落示意。", + "## H2 子章节", + "fg-strong 同 H1, mt 减档区分。", + "### H3 三级", + "fg-base 弱一档。", + "#### H4 四级", + "fg-weak 最弱; agent 极少出现。", + ].join("\n"), + links: [ + "agent 输出常见三类链接", + "", + "外部: 见 [PawWork 仓库](https://github.com/Astro-Han/pawwork)", + "", + "本地: 配置在 [packages/ui/src/components/markdown.tsx](packages/ui/src/components/markdown.tsx)", + "", + "锚点: 跳到 [#section](#section)", + ].join("\n"), + tasks: [ + "## 任务清单", + "", + "- [x] 起 worktree", + "- [x] 写施工计划", + "- [ ] 跑 /crosscheck", + "- [ ] 开 PR", + ].join("\n"), + blockquote: [ + "> 引用语段用 2px border-weak 左竖线 + fg-weak 字色, 无背景。", + ">", + "> 中性灰左线是 Markdown 行业惯例 (GitHub / Tailwind prose / Notion / shadcn), 不属 BAN 1 彩条。", + "", + "正文继续。", + ].join("\n"), + table: [ + "| 文件 | 行数 | 增删 |", + "| --- | ---: | ---: |", + "| markdown.css | 340 | +225 -151 |", + "| markdown.tsx | 510 | +123 -2 |", + "| theme.css | 1 | +1 -1 |", + ].join("\n"), + details: [ + "

调试日志", + "", + "默认折叠, 点开看明细。chev 旋转 90deg。", + "", + "
", + ].join("\n"), + math: [ + "Inline 数学: $E = mc^2$ 出现在句中。", + "", + "Block 公式独立成段:", + "", + "$$", + "G = \\frac{\\sum |x_i - x_j|}{2 n^2 \\mu}", + "$$", + ].join("\n"), + htmlWhitelist: [ + "白名单: 2O / 2 / Cmd / API / 1.5", + "", + "", + "", + "上面 script/iframe 被 DOMPurify 砍掉, 不渲染。", + ].join("\n"), +} + export default { title: "UI/Markdown", id: "components-markdown", @@ -51,3 +110,11 @@ export default { } export const Basic = story.Basic +export const W3Headings = { ...story.Basic, args: { text: fixtures.headings } } +export const W3LinksInkOnly = { ...story.Basic, args: { text: fixtures.links } } +export const W3TaskList = { ...story.Basic, args: { text: fixtures.tasks } } +export const W3Blockquote = { ...story.Basic, args: { text: fixtures.blockquote } } +export const W3Table = { ...story.Basic, args: { text: fixtures.table } } +export const W3Details = { ...story.Basic, args: { text: fixtures.details } } +export const W3Math = { ...story.Basic, args: { text: fixtures.math } } +export const W3HtmlWhitelist = { ...story.Basic, args: { text: fixtures.htmlWhitelist } } diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts new file mode 100644 index 000000000..94523ffaa --- /dev/null +++ b/packages/ui/src/components/markdown.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test" +import { + forceOpenAllDetails, + preserveDetailsOpenState, + resolveLinkAction, + rewriteTaskListsForTest, + sanitizeConfig, + sanitizeForTest, +} from "./markdown" + +describe("DOMPurify whitelist config", () => { + test("forbids unsafe tags", () => { + expect(sanitizeConfig.FORBID_TAGS).toContain("script") + expect(sanitizeConfig.FORBID_TAGS).toContain("iframe") + expect(sanitizeConfig.FORBID_TAGS).toContain("style") + expect(sanitizeConfig.FORBID_TAGS).toContain("form") + expect(sanitizeConfig.FORBID_TAGS).toContain("object") + expect(sanitizeConfig.FORBID_TAGS).toContain("embed") + }) + test("permits input only as GFM checkbox (handled via uponSanitizeElement hook)", () => { + expect(sanitizeConfig.FORBID_TAGS).not.toContain("input") + }) + test("forbids unsafe text content", () => { + expect(sanitizeConfig.FORBID_CONTENTS).toContain("script") + expect(sanitizeConfig.FORBID_CONTENTS).toContain("iframe") + expect(sanitizeConfig.FORBID_CONTENTS).toContain("style") + }) + test("URI regex accepts http(s) / mailto / file / relative paths", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("https://example.com")).toBe(true) + expect(re.test("http://example.com")).toBe(true) + expect(re.test("mailto:hi@x.com")).toBe(true) + expect(re.test("file:///tmp/x")).toBe(true) + expect(re.test("/abs/path")).toBe(true) + expect(re.test("./rel/path")).toBe(true) + expect(re.test("../up/path")).toBe(true) + expect(re.test("relative/path")).toBe(true) + expect(re.test("#anchor")).toBe(true) + }) + test("URI regex rejects javascript: / data: / vbscript:", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("javascript:alert(1)")).toBe(false) + expect(re.test("data:text/html,foo")).toBe(false) + expect(re.test("vbscript:msgbox")).toBe(false) + }) + test("URI regex rejects protocol-relative // (defense-in-depth with click router)", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("//evil.com/x")).toBe(false) + expect(sanitizeForTest('x')).not.toContain("href") + }) +}) + +describe("task list svg rendering", () => { + test("replaces unchecked input with circle svg + tags li", () => { + document.body.innerHTML = '' + const li = document.querySelector("li")! + rewriteTaskListsForTest(document.body) + expect(li.classList.contains("task-item")).toBe(true) + expect(li.querySelector("input")).toBeNull() + const svg = li.querySelector("svg") + expect(svg).not.toBeNull() + expect(svg!.getAttribute("data-state")).toBe("unchecked") + }) + test("replaces checked input with circle-check svg", () => { + document.body.innerHTML = '' + rewriteTaskListsForTest(document.body) + const svg = document.querySelector('svg[data-state="checked"]') + expect(svg).not.toBeNull() + expect(svg!.querySelector("path")).not.toBeNull() + }) + test("preserves label text after checkbox", () => { + document.body.innerHTML = '' + rewriteTaskListsForTest(document.body) + expect(document.body.textContent).toContain("read the spec") + }) + test("does not tag sibling LI without checkbox", () => { + document.body.innerHTML = + '' + rewriteTaskListsForTest(document.body) + const items = document.querySelectorAll("li") + expect(items[0]!.classList.contains("task-item")).toBe(true) + expect(items[1]!.classList.contains("task-item")).toBe(false) + }) + test("handles loose-list paragraph wrap", () => { + document.body.innerHTML = + '' + const li = document.querySelector("li")! + rewriteTaskListsForTest(document.body) + expect(li.classList.contains("task-item")).toBe(true) + expect(li.querySelector("input")).toBeNull() + expect(li.querySelector("svg")).not.toBeNull() + }) + test("groups label + nested blocks into a single flex sibling of the icon", () => { + document.body.innerHTML = + '' + const li = document.querySelector("li")! + rewriteTaskListsForTest(document.body) + // li direct children must be exactly [svg, label-wrapper]; otherwise + // nested