basalt-ui 1.11.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 +15 -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.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +30 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +16 -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 +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- 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 +42 -6
- 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 +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- 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.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- 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.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.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 +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/agent-chat/thread-message.js +14 -3
- 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.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/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- 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 +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +12 -3
- 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 +6 -6
- 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 +53 -26
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +6 -2
- package/src/agent/id.ts +48 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/use-agent-stream.test.tsx +9 -5
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +30 -17
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +141 -1
- package/src/agent-chat/thread-message.tsx +29 -4
- package/src/agent-chat/thread-workspace.tsx +1 -1
- 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/index.ts +13 -1
- package/src/surfaces.ts +12 -3
- 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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize-schema composition for `Markdown` (docs/AGENT-CHAT-SPEC.md §7). Owns three things: the
|
|
3
|
+
* additions-only extension type a consumer hands to `Markdown`'s `sanitizeSchema` prop, basalt's
|
|
4
|
+
* own additions layer, and the additive deep merge that composes both over `rehype-sanitize`'s
|
|
5
|
+
* `defaultSchema`.
|
|
6
|
+
*
|
|
7
|
+
* WHY A DATA EXTENSION, NOT A `(base) => Schema` CALLBACK: a callback can return `{}` and silently
|
|
8
|
+
* disable sanitization, and a "never replace" comment is not enforcement. `SanitizeSchemaExtension`
|
|
9
|
+
* makes removal UNREPRESENTABLE — there is no code path in `mergeSanitizeSchema` that shrinks the
|
|
10
|
+
* base. That, plus `./markdown` appending its sanitize pass AFTER the consumer's `rehypePlugins`,
|
|
11
|
+
* is what keeps the baseline un-defeatable from outside the package.
|
|
12
|
+
*
|
|
13
|
+
* The `Schema` shape is MIRRORED here rather than imported from `hast-util-sanitize`, so the
|
|
14
|
+
* content layer's types never depend on an optional peer. `rehype-sanitize` is dynamically imported
|
|
15
|
+
* by `./markdown`, which composes the effective schema at load time:
|
|
16
|
+
*
|
|
17
|
+
* mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, props.sanitizeSchema)
|
|
18
|
+
*
|
|
19
|
+
* THE TRAP THIS MODULE EXISTS TO AVOID: `hast-util-sanitize` merges a supplied schema with a
|
|
20
|
+
* shallow top-level spread (`{...defaultSchema, ...options}`). Passing an `attributes` object
|
|
21
|
+
* therefore REPLACES every default attribute allowance rather than adding to it — one consumer
|
|
22
|
+
* addition would silently strip `href` from links, `className` from code fences, and the whole
|
|
23
|
+
* `'*'` fallback. Upstream's answer is the third-party `deepmerge` package; basalt owns the small
|
|
24
|
+
* merge below instead of taking the dependency.
|
|
25
|
+
*
|
|
26
|
+
* "ADDITIONS ONLY" IS A STATEMENT ABOUT THE SANITIZER'S BEHAVIOUR, NOT ABOUT THE SCHEMA'S SHAPE.
|
|
27
|
+
* A merge that only ever grows arrays still removes allowances, because of three things
|
|
28
|
+
* `hast-util-sanitize@5` does when it CONSUMES the schema (`lib/index.js`, verified against the
|
|
29
|
+
* installed copy). Each is load-bearing for the code below:
|
|
30
|
+
*
|
|
31
|
+
* 1. `clobberPrefix` is gated on TRUTHINESS — `schema.clobber && schema.clobberPrefix &&
|
|
32
|
+
* schema.clobber.includes(key)`. ANY falsy prefix (`''`, but equally `null`, `0`, `false`,
|
|
33
|
+
* `NaN` — whatever an untyped caller or a cast smuggles through) therefore turns clobber
|
|
34
|
+
* protection OFF entirely and emits `id`/`name`/`ariaLabelledBy`/`ariaDescribedBy` unprefixed,
|
|
35
|
+
* which is the DOM-clobbering hole `user-content-` exists to close. See `adoptClobberPrefix`.
|
|
36
|
+
* 2. The `'*'` fallback is consulted ONLY when the tag-specific lookup yields `null`/`undefined`.
|
|
37
|
+
* For an ARRAY-valued property (`className`, `headers`, …) a matching tag-specific entry
|
|
38
|
+
* returns `[]` — neither — so the fallback is skipped. Introducing a tag-specific entry for a
|
|
39
|
+
* property the base only allowed via `'*'` therefore DELETES that allowance. See
|
|
40
|
+
* `rescueWildcardDefinitions`.
|
|
41
|
+
* 3. `findDefinition` returns the FIRST entry whose name matches, so any later entry for the same
|
|
42
|
+
* property name is dead code (it also treats a `'data*'` entry as the fallback for any
|
|
43
|
+
* `data`-prefixed key). Concatenating a consumer's entry after the base's is a silent no-op.
|
|
44
|
+
* See `unionDefinitions`, which collapses to ONE entry per property name.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// ── Schema shape (structural mirror of hast-util-sanitize's `Schema`) ─────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One entry in an `attributes` allow-list. A bare string allows any value for that property; the
|
|
51
|
+
* tuple form allow-lists specific values (a `RegExp` matches a value, e.g. `['className',
|
|
52
|
+
* /^language-./]`). A ONE-element tuple (`['className']`) also allows any value — upstream only
|
|
53
|
+
* treats an entry as an allow-list when `definition.length > 1`.
|
|
54
|
+
*
|
|
55
|
+
* The `null`/`undefined` arms mirror upstream's `PropertyDefinition` exactly (its rest element is
|
|
56
|
+
* `Exclude<Properties[keyof Properties], Array<any>> | RegExp`, which includes both). They are
|
|
57
|
+
* inert: a sanitized value is always a boolean, number or string, so `allowed === value` can never
|
|
58
|
+
* match one.
|
|
59
|
+
*/
|
|
60
|
+
export type SanitizePropertyDefinition =
|
|
61
|
+
| string
|
|
62
|
+
| readonly [string, ...ReadonlyArray<boolean | number | string | RegExp | null | undefined>]
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Structural mirror of `hast-util-sanitize`'s `Schema`, defined here rather than imported so the
|
|
66
|
+
* content layer never depends on an OPTIONAL peer's types. This is the OUTPUT shape: no `null`
|
|
67
|
+
* arms, so nothing downstream has to re-check a field the merge already normalized away.
|
|
68
|
+
*/
|
|
69
|
+
export type SanitizeSchema = {
|
|
70
|
+
readonly allowComments?: boolean
|
|
71
|
+
readonly allowDoctypes?: boolean
|
|
72
|
+
readonly ancestors?: Readonly<Record<string, readonly string[]>>
|
|
73
|
+
readonly attributes?: Readonly<Record<string, readonly SanitizePropertyDefinition[]>>
|
|
74
|
+
readonly clobber?: readonly string[]
|
|
75
|
+
readonly clobberPrefix?: string
|
|
76
|
+
readonly protocols?: Readonly<Record<string, readonly string[]>>
|
|
77
|
+
readonly required?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
|
78
|
+
readonly strip?: readonly string[]
|
|
79
|
+
readonly tagNames?: readonly string[]
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* INPUT shape for `mergeSanitizeSchema`'s base — `SanitizeSchema` with upstream's `| null |
|
|
83
|
+
* undefined` on every field (and on every `protocols` value), so `rehype-sanitize`'s own
|
|
84
|
+
* `defaultSchema` assigns to it directly with NO cast at the call site.
|
|
85
|
+
*
|
|
86
|
+
* It is deliberately separate from `SanitizeSchema` rather than a widening of it: the null arms
|
|
87
|
+
* belong to what callers may HAND IN, not to what the merge HANDS BACK. `normalizeSchemaInput`
|
|
88
|
+
* drops them, so no `null` ever reaches the exported output type.
|
|
89
|
+
*/
|
|
90
|
+
export type SanitizeSchemaInput = {
|
|
91
|
+
readonly allowComments?: boolean | null | undefined
|
|
92
|
+
readonly allowDoctypes?: boolean | null | undefined
|
|
93
|
+
readonly ancestors?: Readonly<Record<string, readonly string[]>> | null | undefined
|
|
94
|
+
readonly attributes?:
|
|
95
|
+
| Readonly<Record<string, readonly SanitizePropertyDefinition[]>>
|
|
96
|
+
| null
|
|
97
|
+
| undefined
|
|
98
|
+
readonly clobber?: readonly string[] | null | undefined
|
|
99
|
+
readonly clobberPrefix?: string | null | undefined
|
|
100
|
+
readonly protocols?:
|
|
101
|
+
| Readonly<Record<string, readonly string[] | null | undefined>>
|
|
102
|
+
| null
|
|
103
|
+
| undefined
|
|
104
|
+
readonly required?: Readonly<Record<string, Readonly<Record<string, unknown>>>> | null | undefined
|
|
105
|
+
readonly strip?: readonly string[] | null | undefined
|
|
106
|
+
readonly tagNames?: readonly string[] | null | undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ADDITIONS ONLY — the four fields it is safe to widen from outside the package. Removal is
|
|
111
|
+
* deliberately not expressible: there is no way to shrink `tagNames`, drop an attribute allowance,
|
|
112
|
+
* narrow `protocols`, or clear `strip`/`clobber`/`ancestors`/`required`.
|
|
113
|
+
*
|
|
114
|
+
* `clobberPrefix` is the one replace-valued field. Upstream's actual gate is `schema.clobberPrefix`
|
|
115
|
+
* read for TRUTHINESS (`lib/index.js`), not `=== ''` — so `null`, `0`, `false`, `NaN`, and `''` are
|
|
116
|
+
* ALL disarming values, and only a genuine non-empty string keeps the guard live. TypeScript cannot
|
|
117
|
+
* express "a non-empty string" on a plain data field (and the declared `string` type is a
|
|
118
|
+
* compile-time claim a JS caller or an `as` cast can defeat), so `adoptClobberPrefix` re-checks
|
|
119
|
+
* `typeof` at runtime and treats anything that is not a non-empty string as a disarm attempt —
|
|
120
|
+
* ignored with a dev warning, never applied.
|
|
121
|
+
*/
|
|
122
|
+
export type SanitizeSchemaExtension = {
|
|
123
|
+
readonly tagNames?: readonly string[]
|
|
124
|
+
readonly attributes?: Readonly<Record<string, readonly SanitizePropertyDefinition[]>>
|
|
125
|
+
readonly protocols?: Readonly<Record<string, readonly string[]>>
|
|
126
|
+
readonly clobberPrefix?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── basalt's own additions layer ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* basalt's own element allowances, layered over `rehype-sanitize`'s `defaultSchema`.
|
|
133
|
+
*
|
|
134
|
+
* THIS IS THE ADDITIONS LAYER, NOT THE FULL BASELINE. It is deliberately NOT a materialized
|
|
135
|
+
* `defaultSchema + basalt` schema: `defaultSchema` lives in `rehype-sanitize`, an OPTIONAL peer, so
|
|
136
|
+
* materializing it at module scope would require a static import that hard-requires the peer for
|
|
137
|
+
* every consumer of `basalt-ui/content` — and vendoring a copy would silently drift from upstream's
|
|
138
|
+
* GitHub-grade baseline, which for a security default is worse. The effective schema is composed
|
|
139
|
+
* where the peer is dynamically imported (see the module JSDoc).
|
|
140
|
+
*
|
|
141
|
+
* IT IS EMPTY, AND THAT IS THE HONEST ANSWER — everything basalt's markdown pipeline emits into the
|
|
142
|
+
* hast tree is already permitted by `defaultSchema`, verified entry by entry:
|
|
143
|
+
* - fenced code — `pre` > `code` with a `language-*` class: `defaultSchema.attributes.code` is
|
|
144
|
+
* `[['className', /^language-./]]`, which covers `language-mermaid` too. The fence's title comes
|
|
145
|
+
* from the `code` node's `data.meta`, and `data` is preserved by the sanitizer verbatim.
|
|
146
|
+
* - headings — plain `h1`..`h6`; the slug `id` and the hover anchor are added by basalt's React
|
|
147
|
+
* renderers AFTER sanitization, so they never pass through the schema.
|
|
148
|
+
* - GFM alerts — an ordinary `blockquote` whose first paragraph carries a `[!NOTE]`-style text
|
|
149
|
+
* marker; `Callout` is chosen in the renderer, again downstream of the hast tree.
|
|
150
|
+
* - GFM tables / task lists / footnotes / strikethrough — `table`+`align`, `input[type=checkbox]
|
|
151
|
+
* [disabled]`, `.task-list-item`, `section[data-footnotes]`, `del` are all explicit
|
|
152
|
+
* `defaultSchema` allowances.
|
|
153
|
+
* - links/images — `href`/`src` survive the schema and are then narrowed further by
|
|
154
|
+
* `./url-hardening`'s prefix allowlist.
|
|
155
|
+
*
|
|
156
|
+
* So: do not "fill this in". Every entry added here widens the security baseline for every consumer
|
|
157
|
+
* and must be justified by output basalt actually emits. `style`, event handlers, and new protocols
|
|
158
|
+
* are out of bounds; an app-specific need belongs in that app's `sanitizeSchema` prop.
|
|
159
|
+
*/
|
|
160
|
+
export const BASALT_SANITIZE_SCHEMA: SanitizeSchemaExtension = {}
|
|
161
|
+
|
|
162
|
+
// ── Input normalization ───────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** The `attributes` key upstream treats as "applies to every element". */
|
|
165
|
+
const WILDCARD = '*'
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Drops the `null`/`undefined` arms `SanitizeSchemaInput` carries so the merge — and everything it
|
|
169
|
+
* returns — works in the non-null `SanitizeSchema` world. A `null` `protocols` VALUE is dropped
|
|
170
|
+
* with its key: upstream reads a missing key and a `null` one identically ("no protocols defined?
|
|
171
|
+
* then everything is fine"), so this is shape-only.
|
|
172
|
+
*/
|
|
173
|
+
function isPresent<T>(value: T | null | undefined): value is T {
|
|
174
|
+
return value !== null && value !== undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeSchemaInput(base: SanitizeSchemaInput): SanitizeSchema {
|
|
178
|
+
return {
|
|
179
|
+
...(isPresent(base.allowComments) && { allowComments: base.allowComments }),
|
|
180
|
+
...(isPresent(base.allowDoctypes) && { allowDoctypes: base.allowDoctypes }),
|
|
181
|
+
...(isPresent(base.ancestors) && { ancestors: base.ancestors }),
|
|
182
|
+
...(isPresent(base.attributes) && { attributes: base.attributes }),
|
|
183
|
+
...(isPresent(base.clobber) && { clobber: base.clobber }),
|
|
184
|
+
...(isPresent(base.clobberPrefix) && { clobberPrefix: base.clobberPrefix }),
|
|
185
|
+
...(isPresent(base.protocols) && { protocols: normalizeProtocols(base.protocols) }),
|
|
186
|
+
...(isPresent(base.required) && { required: base.required }),
|
|
187
|
+
...(isPresent(base.strip) && { strip: base.strip }),
|
|
188
|
+
...(isPresent(base.tagNames) && { tagNames: base.tagNames }),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeProtocols(
|
|
193
|
+
protocols: Readonly<Record<string, readonly string[] | null | undefined>>,
|
|
194
|
+
): Readonly<Record<string, readonly string[]>> {
|
|
195
|
+
const out = new Map<string, readonly string[]>()
|
|
196
|
+
for (const [key, values] of Object.entries(protocols)) {
|
|
197
|
+
if (isPresent(values)) out.set(key, values)
|
|
198
|
+
}
|
|
199
|
+
return Object.fromEntries(out)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Attribute-definition algebra (mirrors upstream's lookup) ──────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** The property an entry governs — a bare string IS the name, a tuple carries it at index 0. */
|
|
205
|
+
function definitionName(definition: SanitizePropertyDefinition): string {
|
|
206
|
+
return typeof definition === 'string' ? definition : definition[0]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Upstream only treats an entry as an allow-list when it is a tuple with `length > 1`. */
|
|
210
|
+
function definitionAllowsAnyValue(definition: SanitizePropertyDefinition): boolean {
|
|
211
|
+
return typeof definition === 'string' || definition.length < 2
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function definitionValues(
|
|
215
|
+
definition: SanitizePropertyDefinition,
|
|
216
|
+
): readonly (boolean | number | string | RegExp | null | undefined)[] {
|
|
217
|
+
return typeof definition === 'string' ? [] : definition.slice(1)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* De-duplication key for one allow-list value. Expressions are keyed off `String(expression)`
|
|
222
|
+
* (`/^hljs-/i` → `"/^hljs-/i"`), which encodes both source and flags and — unlike `instanceof` —
|
|
223
|
+
* survives a cross-realm `RegExp`, matching upstream's own duck-typed `'flags' in allowed` test.
|
|
224
|
+
*/
|
|
225
|
+
function definitionValueKey(value: boolean | number | string | RegExp | null | undefined): string {
|
|
226
|
+
if (typeof value === 'object' && value !== null) return `expression${String(value)}`
|
|
227
|
+
return `${typeof value}${String(value)}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toDefinition(
|
|
231
|
+
name: string,
|
|
232
|
+
values: readonly (boolean | number | string | RegExp | null | undefined)[],
|
|
233
|
+
): SanitizePropertyDefinition {
|
|
234
|
+
return values.length === 0 ? name : [name, ...values]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Collapses a list of entries to AT MOST ONE ENTRY PER PROPERTY NAME, unioning their allow-lists.
|
|
239
|
+
*
|
|
240
|
+
* This is the merge's core, and it exists because upstream's `findDefinition` returns the FIRST
|
|
241
|
+
* entry matching a property name — so a second entry for that same name is dead code, and merging
|
|
242
|
+
* by concatenation would make every consumer addition to an already-mentioned property a silent
|
|
243
|
+
* no-op. Union semantics, in the only direction an additions-only API may move:
|
|
244
|
+
*
|
|
245
|
+
* - allow-any (a bare string, or a one-element tuple) absorbs everything — union-ing it with a
|
|
246
|
+
* tuple yields allow-any, never the tuple's narrower list. An extension cannot NARROW.
|
|
247
|
+
* - two tuples yield one tuple whose values are the concatenation, de-duplicated by
|
|
248
|
+
* `definitionValueKey` (expressions structurally, primitives by value and type).
|
|
249
|
+
*
|
|
250
|
+
* Order is base-first, by first appearance of each property name, so output is stable.
|
|
251
|
+
*/
|
|
252
|
+
function unionDefinitions(
|
|
253
|
+
definitions: readonly SanitizePropertyDefinition[],
|
|
254
|
+
): readonly SanitizePropertyDefinition[] {
|
|
255
|
+
type Slot = {
|
|
256
|
+
allowAny: boolean
|
|
257
|
+
values: (boolean | number | string | RegExp | null | undefined)[]
|
|
258
|
+
keys: Set<string>
|
|
259
|
+
}
|
|
260
|
+
const order: string[] = []
|
|
261
|
+
const byName = new Map<string, Slot>()
|
|
262
|
+
|
|
263
|
+
for (const definition of definitions) {
|
|
264
|
+
const name = definitionName(definition)
|
|
265
|
+
let slot = byName.get(name)
|
|
266
|
+
if (!slot) {
|
|
267
|
+
slot = { allowAny: false, values: [], keys: new Set<string>() }
|
|
268
|
+
byName.set(name, slot)
|
|
269
|
+
order.push(name)
|
|
270
|
+
}
|
|
271
|
+
if (definitionAllowsAnyValue(definition)) {
|
|
272
|
+
slot.allowAny = true
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
for (const value of definitionValues(definition)) {
|
|
276
|
+
const key = definitionValueKey(value)
|
|
277
|
+
if (slot.keys.has(key)) continue
|
|
278
|
+
slot.keys.add(key)
|
|
279
|
+
slot.values.push(value)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const out: SanitizePropertyDefinition[] = []
|
|
284
|
+
for (const name of order) {
|
|
285
|
+
const slot = byName.get(name)
|
|
286
|
+
if (!slot) continue
|
|
287
|
+
out.push(slot.allowAny ? name : toDefinition(name, slot.values))
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Upstream's `findDefinition`, mirrored: first exact name match wins, otherwise a `'data*'` entry
|
|
294
|
+
* answers for any `data`-prefixed key. Used to locate what the `'*'` fallback would have granted.
|
|
295
|
+
*/
|
|
296
|
+
function findFallbackDefinition(
|
|
297
|
+
definitions: readonly SanitizePropertyDefinition[] | undefined,
|
|
298
|
+
key: string,
|
|
299
|
+
): SanitizePropertyDefinition | undefined {
|
|
300
|
+
let dataDefault: SanitizePropertyDefinition | undefined
|
|
301
|
+
for (const definition of definitions ?? []) {
|
|
302
|
+
const name = definitionName(definition)
|
|
303
|
+
if (name === key) return definition
|
|
304
|
+
if (name === 'data*') dataDefault = definition
|
|
305
|
+
}
|
|
306
|
+
if (key.length > 4 && key.slice(0, 4).toLowerCase() === 'data') return dataDefault
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Re-points a `'*'` entry (possibly the `'data*'` catch-all) at a concrete property name. */
|
|
311
|
+
function renameDefinition(
|
|
312
|
+
definition: SanitizePropertyDefinition,
|
|
313
|
+
name: string,
|
|
314
|
+
): SanitizePropertyDefinition {
|
|
315
|
+
if (definitionName(definition) === name) return definition
|
|
316
|
+
return definitionAllowsAnyValue(definition)
|
|
317
|
+
? name
|
|
318
|
+
: toDefinition(name, definitionValues(definition))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Materializes the `'*'` allowances an addition would otherwise SHADOW OFF a tag.
|
|
323
|
+
*
|
|
324
|
+
* Upstream consults the `'*'` fallback only when the tag-specific lookup returns `null`/undefined —
|
|
325
|
+
* and for an array-valued property a matching tag-specific entry returns `[]`, which is neither.
|
|
326
|
+
* So the moment an extension names property P on tag T, T stops inheriting `'*'`'s P allowance for
|
|
327
|
+
* array values. Rescuing it here (so the union below carries it) is what keeps a widening from
|
|
328
|
+
* being a removal.
|
|
329
|
+
*
|
|
330
|
+
* Only names the extension INTRODUCES to T are rescued: a name T's own base list already carried
|
|
331
|
+
* was already shadowing `'*'` before the merge, so leaving it alone preserves base behaviour
|
|
332
|
+
* exactly rather than widening it.
|
|
333
|
+
*/
|
|
334
|
+
function rescueWildcardDefinitions({
|
|
335
|
+
wildcard,
|
|
336
|
+
current,
|
|
337
|
+
additions,
|
|
338
|
+
}: {
|
|
339
|
+
readonly wildcard: readonly SanitizePropertyDefinition[] | undefined
|
|
340
|
+
readonly current: readonly SanitizePropertyDefinition[] | undefined
|
|
341
|
+
readonly additions: readonly SanitizePropertyDefinition[]
|
|
342
|
+
}): readonly SanitizePropertyDefinition[] {
|
|
343
|
+
if (!wildcard || wildcard.length === 0) return []
|
|
344
|
+
|
|
345
|
+
const shadowed = new Set((current ?? []).map(definitionName))
|
|
346
|
+
const rescued: SanitizePropertyDefinition[] = []
|
|
347
|
+
for (const definition of additions) {
|
|
348
|
+
const name = definitionName(definition)
|
|
349
|
+
if (shadowed.has(name)) continue
|
|
350
|
+
shadowed.add(name)
|
|
351
|
+
const fallback = findFallbackDefinition(wildcard, name)
|
|
352
|
+
if (fallback !== undefined) rescued.push(renameDefinition(fallback, name))
|
|
353
|
+
}
|
|
354
|
+
return rescued
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Additive merge ────────────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/** Base entries first in their original order, then each addition not already present. */
|
|
360
|
+
function concatUniqueStrings(
|
|
361
|
+
base: readonly string[] | undefined,
|
|
362
|
+
additions: readonly string[],
|
|
363
|
+
): readonly string[] {
|
|
364
|
+
const out = base ? [...base] : []
|
|
365
|
+
const seen = new Set(out)
|
|
366
|
+
for (const value of additions) {
|
|
367
|
+
if (seen.has(value)) continue
|
|
368
|
+
seen.add(value)
|
|
369
|
+
out.push(value)
|
|
370
|
+
}
|
|
371
|
+
return out
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Merges a record-of-lists PER KEY: a key in both ends up with the base list AND the addition's
|
|
376
|
+
* list concatenated, never the addition's replacing the base's. Built through a `Map` +
|
|
377
|
+
* `Object.fromEntries` so key order stays base-first and a `__proto__` key in an addition defines
|
|
378
|
+
* an own property rather than reaching the `Object.prototype` setter.
|
|
379
|
+
*/
|
|
380
|
+
function mergeRecordOfLists<T>(
|
|
381
|
+
base: Readonly<Record<string, readonly T[]>> | undefined,
|
|
382
|
+
addition: Readonly<Record<string, readonly T[]>>,
|
|
383
|
+
concat: (base: readonly T[] | undefined, additions: readonly T[]) => readonly T[],
|
|
384
|
+
): Readonly<Record<string, readonly T[]>> {
|
|
385
|
+
const merged = new Map<string, readonly T[]>(Object.entries(base ?? {}))
|
|
386
|
+
for (const [key, values] of Object.entries(addition)) {
|
|
387
|
+
merged.set(key, concat(merged.get(key), values))
|
|
388
|
+
}
|
|
389
|
+
return Object.fromEntries(merged)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Merges `attributes` per tag. Unlike the plain record-of-lists merge this has to model two
|
|
394
|
+
* upstream lookup rules at once — one entry per property name (`unionDefinitions`) and the `'*'`
|
|
395
|
+
* fallback that a tag-specific entry switches off (`rescueWildcardDefinitions`).
|
|
396
|
+
*
|
|
397
|
+
* `'*'` is processed FIRST so a wildcard widened by the same extension object is already visible to
|
|
398
|
+
* the per-tag rescue, making the result independent of the addition's key order.
|
|
399
|
+
*/
|
|
400
|
+
function mergeAttributes(
|
|
401
|
+
base: Readonly<Record<string, readonly SanitizePropertyDefinition[]>> | undefined,
|
|
402
|
+
addition: Readonly<Record<string, readonly SanitizePropertyDefinition[]>>,
|
|
403
|
+
): Readonly<Record<string, readonly SanitizePropertyDefinition[]>> {
|
|
404
|
+
const merged = new Map<string, readonly SanitizePropertyDefinition[]>(Object.entries(base ?? {}))
|
|
405
|
+
const tags = Object.keys(addition)
|
|
406
|
+
const ordered = tags.includes(WILDCARD)
|
|
407
|
+
? [WILDCARD, ...tags.filter((tag) => tag !== WILDCARD)]
|
|
408
|
+
: tags
|
|
409
|
+
|
|
410
|
+
for (const tag of ordered) {
|
|
411
|
+
const additions = addition[tag] ?? []
|
|
412
|
+
const current = merged.get(tag)
|
|
413
|
+
if (tag === WILDCARD) {
|
|
414
|
+
merged.set(tag, unionDefinitions([...(current ?? []), ...additions]))
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
const rescued = rescueWildcardDefinitions({
|
|
418
|
+
wildcard: merged.get(WILDCARD),
|
|
419
|
+
current,
|
|
420
|
+
additions,
|
|
421
|
+
})
|
|
422
|
+
merged.set(tag, unionDefinitions([...(current ?? []), ...rescued, ...additions]))
|
|
423
|
+
}
|
|
424
|
+
return Object.fromEntries(merged)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Applies an extension's `clobberPrefix`, IGNORING anything that is not a non-empty string.
|
|
429
|
+
*
|
|
430
|
+
* Upstream gates prefixing on `schema.clobber && schema.clobberPrefix && …` — a TRUTHINESS check,
|
|
431
|
+
* not `=== ''`. So `''` is not the only disarming value: `null`, `0`, `false`, and `NaN` all read as
|
|
432
|
+
* falsy too and switch clobber protection off entirely, letting `id`/`name` back out unprefixed.
|
|
433
|
+
* That is the DOM-clobbering vector (`<a id="body">`) the default prefix exists to close, and none
|
|
434
|
+
* of its spellings may be reachable through an additions-only API.
|
|
435
|
+
*
|
|
436
|
+
* `next` is typed `unknown` rather than trusting `SanitizeSchemaExtension`'s declared `string` —
|
|
437
|
+
* the type is a compile-time claim, and this is a runtime security boundary: a plain-JS caller (no
|
|
438
|
+
* type checker in the loop) or an `as` cast can hand this function `null`/`0`/`false`/`NaN`/an
|
|
439
|
+
* object despite the type, so the enforcement has to `typeof`-check rather than assume. Only a
|
|
440
|
+
* genuine non-empty string is adopted; everything else — including non-string truthy values, which
|
|
441
|
+
* upstream would coerce/misuse rather than treat as a prefix — is refused with the existing
|
|
442
|
+
* dev-warn and the previous prefix is kept.
|
|
443
|
+
*/
|
|
444
|
+
function adoptClobberPrefix(current: string | undefined, next: unknown): string | undefined {
|
|
445
|
+
if (typeof next === 'string' && next !== '') return next
|
|
446
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
447
|
+
console.warn(
|
|
448
|
+
'[basalt] mergeSanitizeSchema: ignoring an invalid `clobberPrefix`. hast-util-sanitize gates ' +
|
|
449
|
+
'clobber prefixing on truthiness, so a falsy value (or a non-string) disables it entirely ' +
|
|
450
|
+
'and emits id/name unprefixed — the DOM-clobbering hole "user-content-" exists to close. ' +
|
|
451
|
+
'The previous prefix is kept.',
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
return current
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Deep-merges additions into a base schema. Additive ONLY — and "additive" is measured by what the
|
|
459
|
+
* SANITIZER then allows, not by array lengths (see the module JSDoc for the three upstream lookup
|
|
460
|
+
* rules this has to model):
|
|
461
|
+
*
|
|
462
|
+
* - `tagNames` and `protocols` are concatenated per key, de-duplicated.
|
|
463
|
+
* - `attributes` are merged per tag into ONE entry per property name whose allow-list is the union
|
|
464
|
+
* of base and addition — and a tag gaining its first entry for a property keeps whatever `'*'`
|
|
465
|
+
* granted it, so a widening never shadows the wildcard off.
|
|
466
|
+
* - `clobberPrefix` is the single replace-valued field (a later extension wins; an omitted one
|
|
467
|
+
* leaves the previous value intact) — except the empty string, which is ignored.
|
|
468
|
+
*
|
|
469
|
+
* Never removes anything from `base`: keys no extension mentions (`strip`, `clobber`, `ancestors`,
|
|
470
|
+
* `required`, `allowComments`, `allowDoctypes`) pass through untouched.
|
|
471
|
+
*
|
|
472
|
+
* The `base` parameter takes the nullable `SanitizeSchemaInput` so `rehype-sanitize`'s own
|
|
473
|
+
* `defaultSchema` assigns with no cast; the RETURN type stays non-null.
|
|
474
|
+
*
|
|
475
|
+
* Pure and order-stable: no input is mutated, and the output ordering is a deterministic function
|
|
476
|
+
* of the inputs, so snapshots don't churn.
|
|
477
|
+
*
|
|
478
|
+
* @example
|
|
479
|
+
* mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, { tagNames: ['hermes-badge'] })
|
|
480
|
+
*/
|
|
481
|
+
export function mergeSanitizeSchema(
|
|
482
|
+
base: SanitizeSchemaInput,
|
|
483
|
+
...extensions: readonly (SanitizeSchemaExtension | undefined)[]
|
|
484
|
+
): SanitizeSchema {
|
|
485
|
+
const normalized = normalizeSchemaInput(base)
|
|
486
|
+
let tagNames = normalized.tagNames
|
|
487
|
+
let attributes = normalized.attributes
|
|
488
|
+
let protocols = normalized.protocols
|
|
489
|
+
let clobberPrefix = normalized.clobberPrefix
|
|
490
|
+
|
|
491
|
+
for (const extension of extensions) {
|
|
492
|
+
if (!extension) continue
|
|
493
|
+
if (extension.tagNames) tagNames = concatUniqueStrings(tagNames, extension.tagNames)
|
|
494
|
+
if (extension.attributes) attributes = mergeAttributes(attributes, extension.attributes)
|
|
495
|
+
if (extension.protocols) {
|
|
496
|
+
protocols = mergeRecordOfLists(protocols, extension.protocols, concatUniqueStrings)
|
|
497
|
+
}
|
|
498
|
+
if (extension.clobberPrefix !== undefined) {
|
|
499
|
+
clobberPrefix = adoptClobberPrefix(clobberPrefix, extension.clobberPrefix)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
...normalized,
|
|
505
|
+
...(tagNames !== undefined && { tagNames }),
|
|
506
|
+
...(attributes !== undefined && { attributes }),
|
|
507
|
+
...(protocols !== undefined && { protocols }),
|
|
508
|
+
...(clobberPrefix !== undefined && { clobberPrefix }),
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -28,7 +28,12 @@ import type { StatCardTone } from './stat-card'
|
|
|
28
28
|
function render(tone?: StatCardTone) {
|
|
29
29
|
return renderToStaticMarkup(
|
|
30
30
|
<MantineProvider>
|
|
31
|
-
|
|
31
|
+
{/* exactOptionalPropertyTypes forbids an explicit tone={undefined} — spread only when set */}
|
|
32
|
+
<StatCard
|
|
33
|
+
label="Downtime · last 24h"
|
|
34
|
+
value="0 min"
|
|
35
|
+
{...(tone !== undefined ? { tone } : {})}
|
|
36
|
+
/>
|
|
32
37
|
</MantineProvider>,
|
|
33
38
|
)
|
|
34
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -126,15 +126,27 @@ export type {
|
|
|
126
126
|
ThreadOutcomeCardProps,
|
|
127
127
|
ThreadDetailPanelProps,
|
|
128
128
|
ComposerProps,
|
|
129
|
+
ComposerSubmit,
|
|
130
|
+
ComposerAttachment,
|
|
131
|
+
ComposerHandle,
|
|
129
132
|
ThreadTranscriptProps,
|
|
130
133
|
ToolChipProps,
|
|
131
134
|
} from './agent-chat'
|
|
132
|
-
export {
|
|
135
|
+
export {
|
|
136
|
+
createAdapterThreadsStore,
|
|
137
|
+
createThreadsStore,
|
|
138
|
+
heuristicOutcome,
|
|
139
|
+
threadsStoreAdapterContract,
|
|
140
|
+
useAgentThreadRuns,
|
|
141
|
+
} from './agent'
|
|
133
142
|
export type {
|
|
143
|
+
AdapterThreadsStoreOptions,
|
|
134
144
|
AgentThread,
|
|
135
145
|
AgentOutcome,
|
|
136
146
|
ThreadStatus,
|
|
137
147
|
ThreadsStore,
|
|
148
|
+
ThreadsStoreAdapter,
|
|
149
|
+
ThreadsStoreAdapterContractCase,
|
|
138
150
|
ThreadsStoreOptions,
|
|
139
151
|
OutcomeResolver,
|
|
140
152
|
ChatMessage,
|
package/src/surfaces.ts
CHANGED
|
@@ -195,7 +195,14 @@ export const SURFACES = {
|
|
|
195
195
|
],
|
|
196
196
|
description:
|
|
197
197
|
'BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone)',
|
|
198
|
-
optionalPeers: [
|
|
198
|
+
optionalPeers: [
|
|
199
|
+
'react-markdown',
|
|
200
|
+
'remark-gfm',
|
|
201
|
+
'rehype-sanitize',
|
|
202
|
+
'remend',
|
|
203
|
+
'shiki',
|
|
204
|
+
'beautiful-mermaid',
|
|
205
|
+
],
|
|
199
206
|
forbiddenImports: [], // the no-charts/tokens-reexport invariant is comment-only today; Phase-4 plugin
|
|
200
207
|
},
|
|
201
208
|
'./charts': {
|
|
@@ -378,7 +385,7 @@ export const SURFACES = {
|
|
|
378
385
|
skill: ['basalt-app'],
|
|
379
386
|
guardKinds: [],
|
|
380
387
|
description:
|
|
381
|
-
'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled).
|
|
388
|
+
'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx.',
|
|
382
389
|
optionalPeers: [
|
|
383
390
|
'ai',
|
|
384
391
|
'motion',
|
|
@@ -400,7 +407,7 @@ export const SURFACES = {
|
|
|
400
407
|
skill: ['basalt-design'],
|
|
401
408
|
guardKinds: [],
|
|
402
409
|
description:
|
|
403
|
-
'Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers, streaming-
|
|
410
|
+
'Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers; `streaming` is a rendering mode ONLY — `contentTrust` is the independent security input, and any surface rendering agent/model output must pin `contentTrust="untrusted"`, the sole input to the image-origin allowlist; a `fenceRenderers` registry — settledOnly/FenceRenderer/FenceRenderers/FenceRenderContext; `sanitizeSchema`, an additions-only SanitizeSchemaExtension merged over BASALT_SANITIZE_SCHEMA via mergeSanitizeSchema; the remend streaming-repair pass is now a lazy optional peer), MermaidDiagram (beautiful-mermaid, optional peer), mdxComponents/createMdxComponents, ArticleLayout (docs-page frame), ArticleCard/ArticleGrid (overview cards), Article model (sortArticles/filterArticles/formatArticleDate), ArticleFilterBar (category/tags filter UI), toArticleActions (Spotlight projector, @mantine/spotlight type-only), GuideLink/GuideDrawer (contextual-help drawer) — the content/prose surface',
|
|
404
411
|
optionalPeers: [
|
|
405
412
|
'shiki',
|
|
406
413
|
'@shikijs/langs',
|
|
@@ -408,6 +415,8 @@ export const SURFACES = {
|
|
|
408
415
|
'beautiful-mermaid',
|
|
409
416
|
'react-markdown',
|
|
410
417
|
'remark-gfm',
|
|
418
|
+
'rehype-sanitize',
|
|
419
|
+
'remend',
|
|
411
420
|
'@mantine/spotlight',
|
|
412
421
|
],
|
|
413
422
|
forbiddenImports: [],
|
|
@@ -263,8 +263,7 @@ describe('the accent is single-sourced: Mantine chrome is bridged to --vx-*', ()
|
|
|
263
263
|
})
|
|
264
264
|
|
|
265
265
|
describe('the variant resolver hands off to CSS, never to a baked color', () => {
|
|
266
|
-
const resolve = (color: string) =>
|
|
267
|
-
theme.variantColorResolver({ color, theme, variant: 'filled', gradient: undefined })
|
|
266
|
+
const resolve = (color: string) => theme.variantColorResolver({ color, theme, variant: 'filled' })
|
|
268
267
|
|
|
269
268
|
for (const color of COLORS) {
|
|
270
269
|
test(`filled ${color} resolves to var(--vx-on-${color})`, () => {
|
|
@@ -288,7 +287,6 @@ describe('the variant resolver hands off to CSS, never to a baked color', () =>
|
|
|
288
287
|
color: 'blue',
|
|
289
288
|
theme,
|
|
290
289
|
variant: 'filled',
|
|
291
|
-
gradient: undefined,
|
|
292
290
|
autoContrast: false,
|
|
293
291
|
})
|
|
294
292
|
expect(color).toBe('var(--mantine-color-white)')
|
|
@@ -317,7 +315,6 @@ describe('doctrine inversion #1 reaches every default-variant CONTROL, not just
|
|
|
317
315
|
color: theme.primaryColor,
|
|
318
316
|
theme,
|
|
319
317
|
variant: 'default',
|
|
320
|
-
gradient: undefined,
|
|
321
318
|
})
|
|
322
319
|
expect(border).toContain('solid var(--mantine-color-default-border)')
|
|
323
320
|
expect(border).not.toContain('surface-border')
|
|
@@ -67,8 +67,10 @@ describe('Fix 1 — input height, button height, and ActionIcon size never drift
|
|
|
67
67
|
}
|
|
68
68
|
for (const level of ALL_LEVELS) {
|
|
69
69
|
const { anchors } = deriveSpacing(level)
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const expectedHeight = expected[level]
|
|
71
|
+
if (expectedHeight === undefined) throw new Error(`no expected height for level ${level}`)
|
|
72
|
+
expect(anchors.inputHeight).toBe(expectedHeight)
|
|
73
|
+
expect(anchors.controlHeight).toBe(expectedHeight)
|
|
72
74
|
}
|
|
73
75
|
})
|
|
74
76
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, test } from 'bun:test'
|
|
7
7
|
import { baseTheme, createBasaltTheme } from './index'
|
|
8
|
+
import type { BasaltFontsConfig } from './index'
|
|
8
9
|
|
|
9
10
|
describe('the default path carries no theme.other.basaltFonts', () => {
|
|
10
11
|
test('createBasaltTheme() carries no basaltFonts', () => {
|
|
@@ -26,7 +27,12 @@ describe('an empty fonts object is treated as absent — stays on the static bas
|
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
test('an all-undefined-keys fonts object is also treated as absent', () => {
|
|
29
|
-
|
|
30
|
+
// Deliberately constructs a key explicitly set to undefined — exactOptionalPropertyTypes
|
|
31
|
+
// correctly forbids this as a static shape, but the whole point of the test is verifying
|
|
32
|
+
// createBasaltTheme's RUNTIME handling of that wire shape (a caller spreading in an
|
|
33
|
+
// undefined value), hence the cast.
|
|
34
|
+
const fonts = { sans: undefined } as unknown as BasaltFontsConfig
|
|
35
|
+
const theme = createBasaltTheme(undefined, { fonts })
|
|
30
36
|
expect(theme).toBe(baseTheme)
|
|
31
37
|
expect(theme.other?.['basaltFonts']).toBeUndefined()
|
|
32
38
|
})
|