basalt-ui 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +25 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +42 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +137 -0
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +65 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +11 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +18 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +35 -0
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +53 -7
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +135 -20
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +138 -11
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +75 -23
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +3 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +115 -45
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +13 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +7 -7
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +380 -55
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +387 -0
- package/src/agent/coalesce.ts +229 -0
- package/src/agent/foreign.test.ts +51 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +75 -0
- package/src/agent/id.ts +91 -0
- package/src/agent/index.ts +34 -3
- package/src/agent/merge.test.ts +206 -0
- package/src/agent/merge.ts +114 -0
- package/src/agent/part-list.test.tsx +112 -0
- package/src/agent/part-list.tsx +108 -35
- package/src/agent/parts.test.ts +516 -57
- package/src/agent/parts.ts +323 -32
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +53 -19
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
- package/src/agent/use-agent-thread-runs.test.tsx +418 -22
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +5 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +351 -0
- package/src/agent-chat/thread-message.tsx +262 -64
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +15 -1
- package/src/surfaces.ts +13 -4
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Element, ElementContent, Properties, Root } from 'hast'
|
|
3
|
+
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
|
4
|
+
import type { SanitizeSchema, SanitizeSchemaExtension, SanitizeSchemaInput } from './sanitize'
|
|
5
|
+
import { BASALT_SANITIZE_SCHEMA, mergeSanitizeSchema } from './sanitize'
|
|
6
|
+
|
|
7
|
+
// ── Running the REAL sanitizer ────────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// The merge's promise ("an extension can add but never remove") is a claim about what
|
|
10
|
+
// hast-util-sanitize DOES with the merged schema, not about the schema's shape — three of its
|
|
11
|
+
// lookup rules turn a bigger schema into a weaker one (see sanitize.ts's module JSDoc). Structural
|
|
12
|
+
// assertions cannot see any of that, so every guarantee below is also asserted through an actual
|
|
13
|
+
// sanitize() round-trip on the installed peer.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `rehype-sanitize`'s default export is `(schema) => (tree) => tree`, and that transform IS
|
|
17
|
+
* `hast-util-sanitize`'s `sanitize()`. The one bridge: upstream's `Schema` declares its arrays
|
|
18
|
+
* mutable where `SanitizeSchema` mirrors them `readonly`, so the schema argument is retyped here
|
|
19
|
+
* (test-only — shipped code hands the peer its own `defaultSchema`-derived object).
|
|
20
|
+
*/
|
|
21
|
+
const runSanitize = rehypeSanitize as unknown as (
|
|
22
|
+
schema: SanitizeSchemaInput,
|
|
23
|
+
) => (tree: Root) => Root
|
|
24
|
+
|
|
25
|
+
function el(tagName: string, properties: Properties, children: ElementContent[] = []): Element {
|
|
26
|
+
return { type: 'element', tagName, properties, children }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitizes `tree` with the REAL sanitizer and returns the surviving properties of the node reached
|
|
31
|
+
* by walking `path` (child indices) down from it — `undefined` if the element did not survive.
|
|
32
|
+
*/
|
|
33
|
+
function sanitized(
|
|
34
|
+
schema: SanitizeSchemaInput,
|
|
35
|
+
tree: Element,
|
|
36
|
+
path: readonly number[] = [],
|
|
37
|
+
): Properties | undefined {
|
|
38
|
+
const root: Root = { type: 'root', children: [tree] }
|
|
39
|
+
let node: ElementContent | undefined = runSanitize(schema)(root).children[0] as
|
|
40
|
+
| ElementContent
|
|
41
|
+
| undefined
|
|
42
|
+
for (const index of path) {
|
|
43
|
+
node = node?.type === 'element' ? node.children[index] : undefined
|
|
44
|
+
}
|
|
45
|
+
return node?.type === 'element' ? node.properties : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `td` only survives inside a `table` (`defaultSchema.ancestors`), so cells are built nested. */
|
|
49
|
+
function tableCell(properties: Properties): Element {
|
|
50
|
+
return el('table', {}, [el('tr', {}, [el('td', properties)])])
|
|
51
|
+
}
|
|
52
|
+
const CELL_PATH = [0, 0] as const
|
|
53
|
+
|
|
54
|
+
// A representative stand-in for rehype-sanitize's defaultSchema — small enough to assert on
|
|
55
|
+
// exhaustively, but carrying one of every field shape the merge has to preserve.
|
|
56
|
+
function baseSchema(): SanitizeSchema {
|
|
57
|
+
return {
|
|
58
|
+
allowComments: false,
|
|
59
|
+
allowDoctypes: false,
|
|
60
|
+
ancestors: { td: ['table'], tr: ['table'] },
|
|
61
|
+
attributes: {
|
|
62
|
+
a: ['href', 'ariaLabel'],
|
|
63
|
+
code: [['className', /^language-./]],
|
|
64
|
+
'*': ['id', 'title'],
|
|
65
|
+
},
|
|
66
|
+
clobber: ['id', 'name'],
|
|
67
|
+
clobberPrefix: 'user-content-',
|
|
68
|
+
protocols: { href: ['http', 'https'], src: ['https'] },
|
|
69
|
+
required: { input: { disabled: true, type: 'checkbox' } },
|
|
70
|
+
strip: ['script'],
|
|
71
|
+
tagNames: ['a', 'code', 'p'],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('mergeSanitizeSchema — the shallow-spread trap', () => {
|
|
76
|
+
test('adding one attribute to one tag leaves every other allowance intact', () => {
|
|
77
|
+
const merged = mergeSanitizeSchema(baseSchema(), { attributes: { 'hermes-badge': ['tone'] } })
|
|
78
|
+
|
|
79
|
+
// A naive `{...base, ...extension}` would set attributes to `{'hermes-badge': ['tone']}` and
|
|
80
|
+
// destroy all three entries below — links would lose `href`, fences would lose their
|
|
81
|
+
// `language-*` class, and the `'*'` fallback would vanish. That is the upstream default.
|
|
82
|
+
expect(merged.attributes?.['a']).toEqual(['href', 'ariaLabel'])
|
|
83
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./]])
|
|
84
|
+
expect(merged.attributes?.['*']).toEqual(['id', 'title'])
|
|
85
|
+
expect(merged.attributes?.['hermes-badge']).toEqual(['tone'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('a key present in both keeps base entries AND gains the extension entries', () => {
|
|
89
|
+
const merged = mergeSanitizeSchema(baseSchema(), { attributes: { a: ['dataFootnoteRef'] } })
|
|
90
|
+
|
|
91
|
+
expect(merged.attributes?.['a']).toEqual(['href', 'ariaLabel', 'dataFootnoteRef'])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('mergeSanitizeSchema — tagNames', () => {
|
|
96
|
+
test('concatenates, de-duplicates, and preserves base order', () => {
|
|
97
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
98
|
+
tagNames: ['hermes-badge', 'code', 'hermes-mark', 'hermes-badge'],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(merged.tagNames).toEqual(['a', 'code', 'p', 'hermes-badge', 'hermes-mark'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('an extension can never remove a base tag', () => {
|
|
105
|
+
const merged = mergeSanitizeSchema(baseSchema(), { tagNames: ['p'] })
|
|
106
|
+
|
|
107
|
+
expect(merged.tagNames).toEqual(['a', 'code', 'p'])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('mergeSanitizeSchema — protocols', () => {
|
|
112
|
+
test('merges per key rather than replacing the map', () => {
|
|
113
|
+
const merged = mergeSanitizeSchema(baseSchema(), { protocols: { href: ['mailto'] } })
|
|
114
|
+
|
|
115
|
+
expect(merged.protocols?.['href']).toEqual(['http', 'https', 'mailto'])
|
|
116
|
+
expect(merged.protocols?.['src']).toEqual(['https'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a new protocol key is added without touching the existing ones', () => {
|
|
120
|
+
const merged = mergeSanitizeSchema(baseSchema(), { protocols: { cite: ['https'] } })
|
|
121
|
+
|
|
122
|
+
expect(Object.keys(merged.protocols ?? {})).toEqual(['href', 'src', 'cite'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('an added protocol really is honoured by the sanitizer', () => {
|
|
126
|
+
const link = () => el('a', { href: 'mailto:x@example.com' })
|
|
127
|
+
|
|
128
|
+
// Before: `mailto:` is not in `href`'s protocol list, so the whole property is dropped.
|
|
129
|
+
expect(sanitized(mergeSanitizeSchema(baseSchema()), link())).toEqual({})
|
|
130
|
+
expect(
|
|
131
|
+
sanitized(mergeSanitizeSchema(baseSchema(), { protocols: { href: ['mailto'] } }), link()),
|
|
132
|
+
).toEqual({ href: 'mailto:x@example.com' })
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('mergeSanitizeSchema — untouched base keys', () => {
|
|
137
|
+
test('an extension mentioning none of them leaves every other field intact', () => {
|
|
138
|
+
const base = baseSchema()
|
|
139
|
+
const merged = mergeSanitizeSchema(base, { tagNames: ['hermes-badge'] })
|
|
140
|
+
|
|
141
|
+
// Dropping any of these silently breaks sanitization — `strip` most of all.
|
|
142
|
+
expect(merged.strip).toEqual(['script'])
|
|
143
|
+
expect(merged.clobber).toEqual(['id', 'name'])
|
|
144
|
+
expect(merged.ancestors).toEqual({ td: ['table'], tr: ['table'] })
|
|
145
|
+
expect(merged.required).toEqual({ input: { disabled: true, type: 'checkbox' } })
|
|
146
|
+
expect(merged.allowComments).toBe(false)
|
|
147
|
+
expect(merged.allowDoctypes).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('the fields are not expressible on an extension at all', () => {
|
|
151
|
+
// Compile-time half of the guarantee: `strip` is not a member of SanitizeSchemaExtension, so
|
|
152
|
+
// "clear the strip list" cannot even be written. @ts-expect-error fails the build if it ever is.
|
|
153
|
+
// @ts-expect-error — removal must stay unrepresentable
|
|
154
|
+
const removal: SanitizeSchemaExtension = { strip: [] }
|
|
155
|
+
|
|
156
|
+
expect(mergeSanitizeSchema(baseSchema(), removal).strip).toEqual(['script'])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('mergeSanitizeSchema — nullable base fields', () => {
|
|
161
|
+
// hast-util-sanitize types every `Schema` field as `T | null | undefined`, which is why the
|
|
162
|
+
// `base` parameter takes `SanitizeSchemaInput`. The null arms must not survive into the output:
|
|
163
|
+
// `SanitizeSchema` promises non-null, and `./markdown` hands the result straight to the peer.
|
|
164
|
+
test('a null-valued field is dropped rather than passed through as null', () => {
|
|
165
|
+
const merged = mergeSanitizeSchema(
|
|
166
|
+
{ tagNames: ['a'], attributes: null, strip: null, clobberPrefix: null },
|
|
167
|
+
{ tagNames: ['hermes-badge'] },
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(merged.tagNames).toEqual(['a', 'hermes-badge'])
|
|
171
|
+
expect('attributes' in merged).toBe(false)
|
|
172
|
+
expect('strip' in merged).toBe(false)
|
|
173
|
+
expect('clobberPrefix' in merged).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a null protocols VALUE is dropped with its key', () => {
|
|
177
|
+
const merged = mergeSanitizeSchema({ protocols: { href: ['https'], src: null } })
|
|
178
|
+
|
|
179
|
+
expect(merged.protocols).toEqual({ href: ['https'] })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('a false boolean field survives (it is not "absent")', () => {
|
|
183
|
+
const merged = mergeSanitizeSchema({ allowComments: false, allowDoctypes: false })
|
|
184
|
+
|
|
185
|
+
expect(merged.allowComments).toBe(false)
|
|
186
|
+
expect(merged.allowDoctypes).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('the real defaultSchema is a valid base with no cast at the call site', () => {
|
|
190
|
+
// Compile-time assertion as much as a runtime one: if `SanitizeSchemaInput` ever loses the
|
|
191
|
+
// null arms, this line stops compiling and `./markdown` needs an `as` cast again.
|
|
192
|
+
const merged = mergeSanitizeSchema(defaultSchema, { tagNames: ['hermes-badge'] })
|
|
193
|
+
|
|
194
|
+
expect(merged.tagNames).toContain('hermes-badge')
|
|
195
|
+
expect(merged.tagNames).toContain('code')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('mergeSanitizeSchema — clobberPrefix', () => {
|
|
200
|
+
test('is replaced when given', () => {
|
|
201
|
+
expect(mergeSanitizeSchema(baseSchema(), { clobberPrefix: 'basalt-' }).clobberPrefix).toBe(
|
|
202
|
+
'basalt-',
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('is preserved when omitted', () => {
|
|
207
|
+
expect(mergeSanitizeSchema(baseSchema(), { tagNames: ['x'] }).clobberPrefix).toBe(
|
|
208
|
+
'user-content-',
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('last extension wins across several', () => {
|
|
213
|
+
const merged = mergeSanitizeSchema(
|
|
214
|
+
baseSchema(),
|
|
215
|
+
{ clobberPrefix: 'first-' },
|
|
216
|
+
{ tagNames: ['x'] },
|
|
217
|
+
{ clobberPrefix: 'last-' },
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(merged.clobberPrefix).toBe('last-')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('mergeSanitizeSchema — an empty clobberPrefix is a removal, not a value', () => {
|
|
225
|
+
// hast-util-sanitize gates prefixing on TRUTHINESS (`schema.clobber && schema.clobberPrefix &&
|
|
226
|
+
// …`), so `''` does not prefix with nothing — it switches clobber protection off and lets
|
|
227
|
+
// `<a id="body">` through, which is exactly the DOM-clobbering vector `user-content-` closes.
|
|
228
|
+
const clobberingAnchor = () => el('a', { id: 'body', href: '#x' })
|
|
229
|
+
|
|
230
|
+
test('the vector is real — a base schema with an empty prefix emits id unprefixed', () => {
|
|
231
|
+
const disarmed: SanitizeSchema = { ...baseSchema(), clobberPrefix: '' }
|
|
232
|
+
|
|
233
|
+
expect(sanitized(disarmed, clobberingAnchor())).toEqual({ id: 'body', href: '#x' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('an extension supplying an empty prefix does NOT weaken clobbering', () => {
|
|
237
|
+
const merged = mergeSanitizeSchema(baseSchema(), { clobberPrefix: '' })
|
|
238
|
+
|
|
239
|
+
expect(merged.clobberPrefix).toBe('user-content-')
|
|
240
|
+
expect(sanitized(merged, clobberingAnchor())).toEqual({
|
|
241
|
+
id: 'user-content-body',
|
|
242
|
+
href: '#x',
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('it is ignored over the real defaultSchema too', () => {
|
|
247
|
+
const merged = mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, {
|
|
248
|
+
clobberPrefix: '',
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
expect(sanitized(merged, clobberingAnchor())).toEqual({
|
|
252
|
+
id: 'user-content-body',
|
|
253
|
+
href: '#x',
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('an empty prefix cannot erase a real one supplied by an earlier extension', () => {
|
|
258
|
+
const merged = mergeSanitizeSchema(
|
|
259
|
+
baseSchema(),
|
|
260
|
+
{ clobberPrefix: 'basalt-' },
|
|
261
|
+
{ clobberPrefix: '' },
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
expect(merged.clobberPrefix).toBe('basalt-')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('warns in dev when it drops one', () => {
|
|
268
|
+
const warnings: string[] = []
|
|
269
|
+
const original = console.warn
|
|
270
|
+
console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
|
|
271
|
+
try {
|
|
272
|
+
mergeSanitizeSchema(baseSchema(), { clobberPrefix: '' })
|
|
273
|
+
} finally {
|
|
274
|
+
console.warn = original
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
expect(warnings).toHaveLength(1)
|
|
278
|
+
expect(warnings[0]).toContain('clobberPrefix')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('a non-empty prefix still applies through the real sanitizer', () => {
|
|
282
|
+
const merged = mergeSanitizeSchema(baseSchema(), { clobberPrefix: 'basalt-' })
|
|
283
|
+
|
|
284
|
+
expect(sanitized(merged, clobberingAnchor())).toEqual({ id: 'basalt-body', href: '#x' })
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('mergeSanitizeSchema — clobberPrefix: every falsy spelling is refused, not just ""', () => {
|
|
289
|
+
// hast-util-sanitize's gate is `schema.clobber && schema.clobberPrefix && …` — a TRUTHINESS
|
|
290
|
+
// check, not `=== ''`. `SanitizeSchemaExtension` declares `clobberPrefix?: string`, but that is a
|
|
291
|
+
// compile-time claim a plain-JS caller (no type checker in the loop) or an `as` cast can defeat,
|
|
292
|
+
// so `null`, `0`, `false`, and `NaN` are exactly as disarming as `''` once the type boundary is
|
|
293
|
+
// gone — each reads falsy to the same upstream check and lets `<a id="body">` back out
|
|
294
|
+
// unprefixed. A non-string TRUTHY value must be refused too: upstream treats a non-string
|
|
295
|
+
// `clobberPrefix` as a plain string-concat operand (`prefix + value`) and NaN aside, this is the
|
|
296
|
+
// family the runtime `typeof` guard actually has to reject.
|
|
297
|
+
const clobberingAnchor = () => el('a', { id: 'body', href: '#x' })
|
|
298
|
+
|
|
299
|
+
const disarmingFalsy: readonly [string, unknown][] = [
|
|
300
|
+
['null', null],
|
|
301
|
+
['0', 0],
|
|
302
|
+
['false', false],
|
|
303
|
+
['NaN', Number.NaN],
|
|
304
|
+
['empty string', ''],
|
|
305
|
+
]
|
|
306
|
+
const nonStringTruthy: readonly [string, unknown][] = [
|
|
307
|
+
['number', 42],
|
|
308
|
+
['array', ['x']],
|
|
309
|
+
['object', { toString: () => 'x-' }],
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
for (const [label, value] of [...disarmingFalsy, ...nonStringTruthy]) {
|
|
313
|
+
test(`clobberPrefix: ${label} does not weaken clobbering`, () => {
|
|
314
|
+
// Test-only: exercising the runtime boundary requires a value the static type forbids —
|
|
315
|
+
// the same idiom as `runSanitize`'s documented cast above.
|
|
316
|
+
const extension = { clobberPrefix: value } as unknown as SanitizeSchemaExtension
|
|
317
|
+
const merged = mergeSanitizeSchema(baseSchema(), extension)
|
|
318
|
+
|
|
319
|
+
expect(merged.clobberPrefix).toBe('user-content-')
|
|
320
|
+
expect(sanitized(merged, clobberingAnchor())).toEqual({
|
|
321
|
+
id: 'user-content-body',
|
|
322
|
+
href: '#x',
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
test('a genuine non-empty string is still adopted (the guard is not overzealous)', () => {
|
|
328
|
+
const merged = mergeSanitizeSchema(baseSchema(), { clobberPrefix: 'basalt-' })
|
|
329
|
+
|
|
330
|
+
expect(merged.clobberPrefix).toBe('basalt-')
|
|
331
|
+
expect(sanitized(merged, clobberingAnchor())).toEqual({ id: 'basalt-body', href: '#x' })
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('mergeSanitizeSchema — a tag-specific addition keeps the wildcard fallback', () => {
|
|
336
|
+
// hast-util-sanitize consults `attributes['*']` ONLY when the tag-specific lookup returns
|
|
337
|
+
// null/undefined. For an ARRAY-valued property a matching tag-specific entry returns `[]`, which
|
|
338
|
+
// is neither — so naming a property on a tag deletes whatever `'*'` granted that tag for it.
|
|
339
|
+
test('introducing a tag key does not strip what the wildcard granted (real defaultSchema)', () => {
|
|
340
|
+
const merged = mergeSanitizeSchema(defaultSchema, {
|
|
341
|
+
attributes: { td: [['headers', 'only-this']] },
|
|
342
|
+
})
|
|
343
|
+
const cell = tableCell({ headers: ['h1'], title: 't' })
|
|
344
|
+
|
|
345
|
+
// `headers` and `title` reach `td` only via defaultSchema's `'*'` entry.
|
|
346
|
+
expect(sanitized(defaultSchema, cell, CELL_PATH)).toEqual({ headers: ['h1'], title: 't' })
|
|
347
|
+
expect(sanitized(merged, cell, CELL_PATH)).toEqual({ headers: ['h1'], title: 't' })
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('the same holds for a tag the base already has an entry for', () => {
|
|
351
|
+
const merged = mergeSanitizeSchema(defaultSchema, {
|
|
352
|
+
attributes: { span: [['headers', 'only-this']] },
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
expect(sanitized(merged, el('span', { headers: ['h1', 'h2'] }))).toEqual({
|
|
356
|
+
headers: ['h1', 'h2'],
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('an extension cannot narrow a wildcard allowance by naming it on one tag', () => {
|
|
361
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
362
|
+
attributes: { code: [['title', 'only-this']] },
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// `title` came from `'*'` as a bare string (allow any value); the union keeps it that way.
|
|
366
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./], 'title'])
|
|
367
|
+
expect(sanitized(merged, el('code', { title: 'anything' }))).toEqual({ title: 'anything' })
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("the rescue follows upstream's `data*` fallback, not just exact names", () => {
|
|
371
|
+
const base: SanitizeSchema = {
|
|
372
|
+
...baseSchema(),
|
|
373
|
+
attributes: { ...baseSchema().attributes, '*': ['id', 'title', 'data*'] },
|
|
374
|
+
}
|
|
375
|
+
const merged = mergeSanitizeSchema(base, { attributes: { code: [['dataFoo', 'only-this']] } })
|
|
376
|
+
|
|
377
|
+
// `dataFoo` was allowed via the `'data*'` catch-all; naming it on `code` must not narrow it.
|
|
378
|
+
expect(sanitized(base, el('code', { dataFoo: ['x'] }))).toEqual({ dataFoo: ['x'] })
|
|
379
|
+
expect(sanitized(merged, el('code', { dataFoo: ['x'] }))).toEqual({ dataFoo: ['x'] })
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('a name the base tag already carried is left alone (base behaviour preserved)', () => {
|
|
383
|
+
// `code` already narrowed `className` itself, so `'*'` never applied to it — and adding
|
|
384
|
+
// another `className` pattern must not silently pull in a wildcard entry that never applied.
|
|
385
|
+
const base: SanitizeSchema = {
|
|
386
|
+
...baseSchema(),
|
|
387
|
+
attributes: { ...baseSchema().attributes, '*': ['id', 'title', 'className'] },
|
|
388
|
+
}
|
|
389
|
+
const merged = mergeSanitizeSchema(base, { attributes: { code: [['className', /^hljs-/]] } })
|
|
390
|
+
|
|
391
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./, /^hljs-/]])
|
|
392
|
+
expect(sanitized(merged, el('code', { className: ['evil'] }))).toEqual({ className: [] })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('a wildcard widened by the SAME extension is visible to its tag additions', () => {
|
|
396
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
397
|
+
attributes: { 'hermes-badge': [['headers', 'only-this']], '*': ['headers'] },
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
expect(merged.attributes?.['hermes-badge']).toEqual(['headers'])
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('mergeSanitizeSchema — widening an already-allowed property takes effect', () => {
|
|
405
|
+
// hast-util-sanitize's `findDefinition` returns the FIRST entry matching a property name, so a
|
|
406
|
+
// second entry for that name is dead code. Merging must union per property name, not concatenate.
|
|
407
|
+
test('a code fence gains an extra className pattern without losing the base one', () => {
|
|
408
|
+
const merged = mergeSanitizeSchema(defaultSchema, {
|
|
409
|
+
attributes: { code: [['className', /^hljs-/]] },
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./, /^hljs-/]])
|
|
413
|
+
expect(sanitized(merged, el('code', { className: ['hljs-keyword'] }))).toEqual({
|
|
414
|
+
className: ['hljs-keyword'],
|
|
415
|
+
})
|
|
416
|
+
expect(sanitized(merged, el('code', { className: ['language-ts'] }))).toEqual({
|
|
417
|
+
className: ['language-ts'],
|
|
418
|
+
})
|
|
419
|
+
// Still an allow-list, not a hole.
|
|
420
|
+
expect(sanitized(merged, el('code', { className: ['evil'] }))).toEqual({ className: [] })
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('a bare-string addition widens a narrowed property to allow-any', () => {
|
|
424
|
+
const merged = mergeSanitizeSchema(defaultSchema, { attributes: { a: ['className'] } })
|
|
425
|
+
|
|
426
|
+
// defaultSchema allows only `className="data-footnote-backref"` on links.
|
|
427
|
+
expect(sanitized(defaultSchema, el('a', { className: ['anything'] }))).toEqual({
|
|
428
|
+
className: [],
|
|
429
|
+
})
|
|
430
|
+
expect(sanitized(merged, el('a', { className: ['anything'] }))).toEqual({
|
|
431
|
+
className: ['anything'],
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('a tuple addition widens a value allow-list', () => {
|
|
436
|
+
const merged = mergeSanitizeSchema(defaultSchema, {
|
|
437
|
+
attributes: { input: [['type', 'checkbox', 'radio']] },
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// defaultSchema pins `input[type]` to `checkbox`; `required` then re-adds the default.
|
|
441
|
+
expect(sanitized(defaultSchema, el('input', { type: 'radio' }))).toEqual({
|
|
442
|
+
disabled: true,
|
|
443
|
+
type: 'checkbox',
|
|
444
|
+
})
|
|
445
|
+
expect(sanitized(merged, el('input', { type: 'radio' }))).toEqual({
|
|
446
|
+
disabled: true,
|
|
447
|
+
type: 'radio',
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('the merged list never carries two entries for one property name', () => {
|
|
452
|
+
const merged = mergeSanitizeSchema(
|
|
453
|
+
defaultSchema,
|
|
454
|
+
{ attributes: { code: [['className', /^hljs-/]] } },
|
|
455
|
+
{ attributes: { code: ['title', ['className', 'literal']] } },
|
|
456
|
+
)
|
|
457
|
+
const names = (merged.attributes?.['code'] ?? []).map((definition) =>
|
|
458
|
+
typeof definition === 'string' ? definition : definition[0],
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
expect(names).toEqual([...new Set(names)])
|
|
462
|
+
expect(merged.attributes?.['code']).toEqual([
|
|
463
|
+
['className', /^language-./, /^hljs-/, 'literal'],
|
|
464
|
+
'title',
|
|
465
|
+
])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('allow-any absorbs a tuple regardless of which side it came from', () => {
|
|
469
|
+
const anyFirst = mergeSanitizeSchema(baseSchema(), {
|
|
470
|
+
attributes: { span: ['rel', ['rel', 'noopener']] },
|
|
471
|
+
})
|
|
472
|
+
const tupleFirst = mergeSanitizeSchema(baseSchema(), {
|
|
473
|
+
attributes: { span: [['rel', 'noopener'], 'rel'] },
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
expect(anyFirst.attributes?.['span']).toEqual(['rel'])
|
|
477
|
+
expect(tupleFirst.attributes?.['span']).toEqual(['rel'])
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('a one-element tuple counts as allow-any (upstream needs length > 1)', () => {
|
|
481
|
+
const merged = mergeSanitizeSchema(baseSchema(), { attributes: { code: [['className']] } })
|
|
482
|
+
|
|
483
|
+
expect(merged.attributes?.['code']).toEqual(['className'])
|
|
484
|
+
expect(sanitized(merged, el('code', { className: ['anything'] }))).toEqual({
|
|
485
|
+
className: ['anything'],
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
describe('mergeSanitizeSchema — purity', () => {
|
|
491
|
+
test('mutates neither the base nor the extension', () => {
|
|
492
|
+
const base = baseSchema()
|
|
493
|
+
const extension: SanitizeSchemaExtension = {
|
|
494
|
+
tagNames: ['hermes-badge'],
|
|
495
|
+
attributes: { a: ['target'] },
|
|
496
|
+
protocols: { href: ['mailto'] },
|
|
497
|
+
}
|
|
498
|
+
const baseBefore = structuredClone(base)
|
|
499
|
+
const extensionBefore = structuredClone(extension)
|
|
500
|
+
|
|
501
|
+
mergeSanitizeSchema(base, extension)
|
|
502
|
+
|
|
503
|
+
expect(base).toEqual(baseBefore)
|
|
504
|
+
expect(extension).toEqual(extensionBefore)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('is deterministic across repeated calls', () => {
|
|
508
|
+
const extension: SanitizeSchemaExtension = { tagNames: ['x'], attributes: { a: ['target'] } }
|
|
509
|
+
|
|
510
|
+
expect(mergeSanitizeSchema(baseSchema(), extension)).toEqual(
|
|
511
|
+
mergeSanitizeSchema(baseSchema(), extension),
|
|
512
|
+
)
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('mergeSanitizeSchema — property definitions', () => {
|
|
517
|
+
test('a tuple entry containing a RegExp survives the merge intact', () => {
|
|
518
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
519
|
+
attributes: { span: [['className', /^hljs-/, 'token']] },
|
|
520
|
+
})
|
|
521
|
+
const spanDefs = merged.attributes?.['span']
|
|
522
|
+
|
|
523
|
+
expect(spanDefs).toEqual([['className', /^hljs-/, 'token']])
|
|
524
|
+
// The RegExp arrives as a RegExp, not a rebuilt/stringified copy.
|
|
525
|
+
const first = spanDefs?.[0] as readonly unknown[] | undefined
|
|
526
|
+
expect(first?.[1]).toBeInstanceOf(RegExp)
|
|
527
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./]])
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('structurally different patterns for the same property union into one entry', () => {
|
|
531
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
532
|
+
attributes: { code: [['className', /^hljs-/]] },
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
// Two entries would leave the second one dead — upstream stops at the first name match.
|
|
536
|
+
expect(merged.attributes?.['code']).toEqual([['className', /^language-./, /^hljs-/]])
|
|
537
|
+
expect(sanitized(merged, el('code', { className: ['hljs-keyword'] }))).toEqual({
|
|
538
|
+
className: ['hljs-keyword'],
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
test('an equal expression applied twice is not duplicated', () => {
|
|
543
|
+
const definition: readonly [string, RegExp] = ['className', /^hljs-/]
|
|
544
|
+
const extension: SanitizeSchemaExtension = { attributes: { span: [definition] } }
|
|
545
|
+
|
|
546
|
+
const merged = mergeSanitizeSchema(baseSchema(), extension, extension)
|
|
547
|
+
|
|
548
|
+
expect(merged.attributes?.['span']).toEqual([definition])
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('expressions de-duplicate structurally, and flags are part of the identity', () => {
|
|
552
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
553
|
+
attributes: { span: [['className', /^hljs-/, /^hljs-/, /^hljs-/i]] },
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
expect(merged.attributes?.['span']).toEqual([['className', /^hljs-/, /^hljs-/i]])
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test('primitive values de-duplicate by value and type', () => {
|
|
560
|
+
const merged = mergeSanitizeSchema(baseSchema(), {
|
|
561
|
+
attributes: { span: [['dataX', 1, '1', 1, true, 'true']] },
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
expect(merged.attributes?.['span']).toEqual([['dataX', 1, '1', true, 'true']])
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
describe('mergeSanitizeSchema — composition', () => {
|
|
569
|
+
test('multiple extensions compose left to right', () => {
|
|
570
|
+
const merged = mergeSanitizeSchema(
|
|
571
|
+
baseSchema(),
|
|
572
|
+
{ tagNames: ['first'], attributes: { a: ['one'] } },
|
|
573
|
+
{ tagNames: ['second'], attributes: { a: ['two'], span: ['three'] } },
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
expect(merged.tagNames).toEqual(['a', 'code', 'p', 'first', 'second'])
|
|
577
|
+
expect(merged.attributes?.['a']).toEqual(['href', 'ariaLabel', 'one', 'two'])
|
|
578
|
+
expect(merged.attributes?.['span']).toEqual(['three'])
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('undefined extensions are skipped', () => {
|
|
582
|
+
const merged = mergeSanitizeSchema(baseSchema(), undefined, { tagNames: ['x'] }, undefined)
|
|
583
|
+
|
|
584
|
+
expect(merged.tagNames).toEqual(['a', 'code', 'p', 'x'])
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
test('no extensions at all returns the base schema unchanged', () => {
|
|
588
|
+
expect(mergeSanitizeSchema(baseSchema())).toEqual(baseSchema())
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('BASALT_SANITIZE_SCHEMA over the real defaultSchema', () => {
|
|
593
|
+
// The regression that matters most: the composition `./markdown` actually performs, against the
|
|
594
|
+
// installed peer rather than a hand-built stand-in — and with a NON-EMPTY consumer extension, so
|
|
595
|
+
// every guard in the merge is actually exercised.
|
|
596
|
+
const extension: SanitizeSchemaExtension = {
|
|
597
|
+
tagNames: ['hermes-badge', 'hermes-mark'],
|
|
598
|
+
attributes: { 'hermes-badge': ['tone'], code: [['className', /^hljs-/]] },
|
|
599
|
+
protocols: { href: ['mailto'] },
|
|
600
|
+
}
|
|
601
|
+
const merged = () => mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, extension)
|
|
602
|
+
|
|
603
|
+
test('script is still stripped, not merely unwrapped', () => {
|
|
604
|
+
const root: Root = {
|
|
605
|
+
type: 'root',
|
|
606
|
+
children: [el('script', {}, [{ type: 'text', value: 'alert(1)' }])],
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
expect(runSanitize(merged())(root).children).toEqual([])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test("basalt's own pipeline output still survives the extension", () => {
|
|
613
|
+
const schema = merged()
|
|
614
|
+
|
|
615
|
+
// The two allowances basalt's markdown renderer depends on, asserted behaviourally.
|
|
616
|
+
expect(sanitized(schema, el('code', { className: ['language-mermaid'] }))).toEqual({
|
|
617
|
+
className: ['language-mermaid'],
|
|
618
|
+
})
|
|
619
|
+
expect(sanitized(schema, el('a', { href: '/docs' }))).toEqual({ href: '/docs' })
|
|
620
|
+
// …and the wildcard `'*'` fallback, reached from a tag the extension never mentions.
|
|
621
|
+
expect(sanitized(schema, tableCell({ headers: ['h1'] }), CELL_PATH)).toEqual({
|
|
622
|
+
headers: ['h1'],
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test('an event handler and a javascript: URL are still rejected', () => {
|
|
627
|
+
const schema = merged()
|
|
628
|
+
|
|
629
|
+
expect(sanitized(schema, el('a', { onClick: 'steal()', href: 'javascript:steal()' }))).toEqual(
|
|
630
|
+
{},
|
|
631
|
+
)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
test("the extension's own additions take effect", () => {
|
|
635
|
+
const schema = merged()
|
|
636
|
+
|
|
637
|
+
expect(schema.tagNames).toContain('hermes-badge')
|
|
638
|
+
expect(sanitized(schema, el('hermes-badge', { tone: 'warn' }))).toEqual({ tone: 'warn' })
|
|
639
|
+
expect(sanitized(schema, el('code', { className: ['hljs-keyword'] }))).toEqual({
|
|
640
|
+
className: ['hljs-keyword'],
|
|
641
|
+
})
|
|
642
|
+
expect(sanitized(schema, el('a', { href: 'mailto:x@example.com' }))).toEqual({
|
|
643
|
+
href: 'mailto:x@example.com',
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('clobber protection survives the extension', () => {
|
|
648
|
+
expect(sanitized(merged(), el('a', { id: 'body', href: '#x' }))).toEqual({
|
|
649
|
+
id: 'user-content-body',
|
|
650
|
+
href: '#x',
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test('basalt adds nothing of its own to the baseline', () => {
|
|
655
|
+
// BASALT_SANITIZE_SCHEMA is deliberately empty — everything basalt emits is already allowed by
|
|
656
|
+
// defaultSchema (see its JSDoc). If this ever fails, the addition needs a written justification.
|
|
657
|
+
expect(BASALT_SANITIZE_SCHEMA).toEqual({})
|
|
658
|
+
})
|
|
659
|
+
})
|