@caelo-cms/shared 0.10.21 → 0.10.23
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/dist/ai-tools.d.ts +291 -231
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +349 -281
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared template engine for AI-authored module HTML. Consolidates the
|
|
5
|
+
* no-DB `applyFieldSubstitution` in `preview-compose.ts` and the
|
|
6
|
+
* DB-aware `substituteWithRecursion` in `preview-render.ts` into a
|
|
7
|
+
* single engine built on `mustache.js` (Plan B per issue #71).
|
|
8
|
+
*
|
|
9
|
+
* Grammar (a Mustache subset — see CMS_REQUIREMENTS §3.1 / §5):
|
|
10
|
+
* {{name}} — primitive substitution
|
|
11
|
+
* {{#name}}…inner…{{/name}} — section: ITERATES over a list field
|
|
12
|
+
* (text-list / link-list / module-list);
|
|
13
|
+
* for a SCALAR (text/image/…) or single
|
|
14
|
+
* `module` field it is a CONDITIONAL —
|
|
15
|
+
* the block renders once when the field
|
|
16
|
+
* has a value, and is dropped when empty
|
|
17
|
+
* (never a kind-mismatch — that used to
|
|
18
|
+
* silently drop real scalar content)
|
|
19
|
+
* {{>name}} — single nested module reference
|
|
20
|
+
* (module field kind)
|
|
21
|
+
*
|
|
22
|
+
* Substitution priority for {{name}}:
|
|
23
|
+
* 1. contentValues[name] — per-placement value
|
|
24
|
+
* 2. fields[name].default — module-level default
|
|
25
|
+
* 3. raw `{{name}}` left in place (CLAUDE.md §2 — no fallbacks pre-1.0,
|
|
26
|
+
* so broken templates stay visible)
|
|
27
|
+
*
|
|
28
|
+
* Loud-raw invariant: an unknown `{{name}}` / `{{#name}}` / `{{>name}}`
|
|
29
|
+
* (no matching declared field) is left as raw text in the output, not
|
|
30
|
+
* silenced. Mustache's "unknown → empty string" default is overridden
|
|
31
|
+
* by a sentinel pre-scan + post-substitute pass that re-injects the
|
|
32
|
+
* original Mustache source after rendering. See plan §2 risk #2.
|
|
33
|
+
*
|
|
34
|
+
* Failure markers preserved verbatim from the legacy preview-render
|
|
35
|
+
* for `missingSlots`: `field-not-declared:<name>`,
|
|
36
|
+
* `kind-mismatch:<name> expected=<…> actual=<kind>`,
|
|
37
|
+
* `text-list-malformed:<name>[<i>]`, `link-list-malformed:<name>[<i>]`,
|
|
38
|
+
* `module-list-malformed:<name>[<i>]`, `module-ref-malformed:<name>`.
|
|
39
|
+
* v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
|
|
40
|
+
* `{{theme_<slot>_url}}` placeholders when the active theme's asset
|
|
41
|
+
* slot isn't bound.
|
|
42
|
+
* The chat-runner diag pass + editor missing-content surface read
|
|
43
|
+
* these literal strings; any rename is a silent regression for them.
|
|
44
|
+
*
|
|
45
|
+
* No HTML escaping. Module HTML substitutes raw — modules are the
|
|
46
|
+
* place raw HTML lives (CMS_REQUIREMENTS §3.1). Auto-escape would
|
|
47
|
+
* silently break every <a href="{{url}}"> in the catalog. The
|
|
48
|
+
* `Mustache.escape` override at module load is the public Mustache
|
|
49
|
+
* configuration knob; the engine module is the sole workspace
|
|
50
|
+
* importer of mustache so the singleton mutation is scoped in
|
|
51
|
+
* effect.
|
|
52
|
+
*
|
|
53
|
+
* Partials are caller-supplied (sync `Record<string, string>`). The
|
|
54
|
+
* compose path passes an empty map (no DB) — module / module-list
|
|
55
|
+
* refs become loud HTML comments so static-gen output is visible-
|
|
56
|
+
* broken instead of silent-empty. The preview-render path
|
|
57
|
+
* pre-resolves each nested ref via its existing RenderResolver walk
|
|
58
|
+
* (depth-limit + cycle-detection live there, untouched) and supplies
|
|
59
|
+
* the rendered HTML as a partial.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import Mustache from "mustache";
|
|
63
|
+
import { MODULE_FIELD_SECTION_KINDS, type ModuleFieldKind } from "./content.js";
|
|
64
|
+
import { stripCdataGuards } from "./strip-cdata.js";
|
|
65
|
+
|
|
66
|
+
// Override Mustache's default HTML escape — module HTML substitutes
|
|
67
|
+
// raw. The engine module is the only workspace importer of mustache,
|
|
68
|
+
// so the singleton mutation is scoped in effect. Plan §2 risk #1: the
|
|
69
|
+
// {{href}} test pins this; any leak of the default escape fails.
|
|
70
|
+
Mustache.escape = (s: unknown): string => (s === null || s === undefined ? "" : String(s));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Subset of `modules.fields[]` the engine cares about. The full
|
|
74
|
+
* schema lives in `content.ts`; the engine only needs `name`, `kind`,
|
|
75
|
+
* and the optional `default`. Importers pass the full row through.
|
|
76
|
+
*/
|
|
77
|
+
export interface TemplateField {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
readonly kind: ModuleFieldKind;
|
|
80
|
+
readonly default?: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RenderTemplateInput {
|
|
84
|
+
readonly html: string;
|
|
85
|
+
readonly fields: readonly TemplateField[];
|
|
86
|
+
/** Per-placement values from `content_instances.values`. */
|
|
87
|
+
readonly contentValues?: Readonly<Record<string, unknown>>;
|
|
88
|
+
/**
|
|
89
|
+
* Pre-rendered nested-module HTML keyed by:
|
|
90
|
+
* - `<name>` for single `{{>name}}` (module field kind)
|
|
91
|
+
* - `<name>__<index>` for each `{{#name}}` element (module-list)
|
|
92
|
+
* Compose path: empty map (no DB → loud HTML comments emit).
|
|
93
|
+
* Preview-render path: built from RenderResolver walks.
|
|
94
|
+
*/
|
|
95
|
+
readonly partials?: Readonly<Record<string, string>>;
|
|
96
|
+
/**
|
|
97
|
+
* v0.11.1 (issue #76) — active theme's resolved asset URLs. When
|
|
98
|
+
* present, `{{theme_logo_url}}` / `{{theme_logo_dark_url}}` /
|
|
99
|
+
* `{{theme_favicon_url}}` / `{{theme_social_share_url}}` substitute
|
|
100
|
+
* to the asset URL string. Unbound slots (null/absent) follow the
|
|
101
|
+
* existing loud-raw invariant (CLAUDE.md §2): the `{{…}}` stays in
|
|
102
|
+
* the output verbatim AND `theme-asset-unbound:<slot>` lands in
|
|
103
|
+
* `missingSlots`. Per-placement `contentValues` still take
|
|
104
|
+
* precedence so an operator can override on a single module.
|
|
105
|
+
*/
|
|
106
|
+
readonly themeAssets?: {
|
|
107
|
+
readonly logo: string | null;
|
|
108
|
+
readonly logoDark: string | null;
|
|
109
|
+
readonly favicon: string | null;
|
|
110
|
+
readonly socialShare: string | null;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RenderTemplateOutput {
|
|
115
|
+
readonly html: string;
|
|
116
|
+
/** Structured failure channel — see file-level comment for the markers. */
|
|
117
|
+
readonly missingSlots: readonly string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface NestedRef {
|
|
121
|
+
readonly moduleId: string;
|
|
122
|
+
readonly contentInstanceId: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Field names per the v0.4.0 + v0.12.0 grammar: lowercase ASCII +
|
|
126
|
+
// digits + underscores. Case-insensitive primitive matching (the AI
|
|
127
|
+
// sometimes camelCases the placeholder when extracting from existing
|
|
128
|
+
// HTML) is handled below via lowercased view lookup. Whitespace
|
|
129
|
+
// between `{{` and the sigil (#, >, /) is permitted to match the
|
|
130
|
+
// Mustache spec — an extractor that pretty-prints AI-authored HTML
|
|
131
|
+
// shouldn't silently break section / partial dispatch.
|
|
132
|
+
// The section body uses a "tempered dot" — `(?:(?!CLOSE)[\s\S])*` — instead
|
|
133
|
+
// of a lazy `[\s\S]*?`. The lazy form makes the engine try, then backtrack,
|
|
134
|
+
// the close-tag match at every interior position, which is O(n²) on
|
|
135
|
+
// unclosed/large input (CodeQL js/polynomial-redos). The tempered form
|
|
136
|
+
// consumes one character only when the close tag does not start there, so
|
|
137
|
+
// there is a single unambiguous path. The capture stops at the first close
|
|
138
|
+
// tag for the captured name, exactly as the lazy form did — same output.
|
|
139
|
+
const SECTION_RE =
|
|
140
|
+
/\{\{\s*#\s*([a-z][a-z0-9_]*)\s*\}\}((?:(?!\{\{\s*\/\s*\1\s*\}\})[\s\S])*)\{\{\s*\/\s*\1\s*\}\}/g;
|
|
141
|
+
const PARTIAL_RE = /\{\{\s*>\s*([a-z][a-z0-9_]*)\s*\}\}/g;
|
|
142
|
+
const PRIMITIVE_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
143
|
+
|
|
144
|
+
// Section-dispatch kinds (the engine's `{{#name}}` operand). Imported
|
|
145
|
+
// from content.ts so new list-shaped kinds wire through automatically
|
|
146
|
+
// when the canonical declaration is extended.
|
|
147
|
+
const SECTION_KINDS: ReadonlySet<string> = new Set(MODULE_FIELD_SECTION_KINDS);
|
|
148
|
+
|
|
149
|
+
// v0.11.1 (issue #76) — the four theme-asset substitutions the engine
|
|
150
|
+
// resolves from `RenderTemplateInput.themeAssets`. Keys are the
|
|
151
|
+
// `{{name}}` placeholders module authors write; the table maps them
|
|
152
|
+
// back to the asset slot on the ComposeTheme.assets aggregate.
|
|
153
|
+
const THEME_ASSET_KEY_TO_SLOT = {
|
|
154
|
+
theme_logo_url: "logo",
|
|
155
|
+
theme_logo_dark_url: "logoDark",
|
|
156
|
+
theme_favicon_url: "favicon",
|
|
157
|
+
theme_social_share_url: "socialShare",
|
|
158
|
+
} as const;
|
|
159
|
+
const THEME_ASSET_KEYS = Object.keys(THEME_ASSET_KEY_TO_SLOT) as ReadonlyArray<
|
|
160
|
+
keyof typeof THEME_ASSET_KEY_TO_SLOT
|
|
161
|
+
>;
|
|
162
|
+
|
|
163
|
+
function isNestedRef(v: unknown): v is NestedRef {
|
|
164
|
+
return (
|
|
165
|
+
typeof v === "object" &&
|
|
166
|
+
v !== null &&
|
|
167
|
+
typeof (v as { moduleId?: unknown }).moduleId === "string" &&
|
|
168
|
+
typeof (v as { contentInstanceId?: unknown }).contentInstanceId === "string"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render a `caelo:missing` HTML comment carrying a failure reason.
|
|
174
|
+
* The shape (`<!-- caelo:missing reason=<…> -->`) is part of the
|
|
175
|
+
* public failure-marker contract — the chat-runner diag pass reads
|
|
176
|
+
* the comment text out of rendered HTML, and the editor's missing-
|
|
177
|
+
* content surface highlights it for the operator. Exported so the
|
|
178
|
+
* DB-aware preview-render path (`packages/admin-core/src/ops/content/
|
|
179
|
+
* preview-render.ts`) emits the exact same shape without redeclaring.
|
|
180
|
+
*/
|
|
181
|
+
export function caeloMissingComment(reason: string): string {
|
|
182
|
+
return `<!-- caelo:missing reason=${reason} -->`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const comment = caeloMissingComment;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Render `html` against `contentValues` + `fields` + `partials`,
|
|
189
|
+
* returning the substituted HTML plus a structured `missingSlots`
|
|
190
|
+
* channel. Pure / sync / no IO.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* // AC #1 fixture (issue #71). Iterates a link-list field per element.
|
|
194
|
+
* renderTemplate({
|
|
195
|
+
* html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
|
|
196
|
+
* fields: [{ name: 'nav_items', kind: 'link-list' }],
|
|
197
|
+
* contentValues: {
|
|
198
|
+
* nav_items: [
|
|
199
|
+
* { label: 'Docs', href: '/docs' },
|
|
200
|
+
* { label: 'Blog', href: '/blog' },
|
|
201
|
+
* ],
|
|
202
|
+
* },
|
|
203
|
+
* });
|
|
204
|
+
* // → {
|
|
205
|
+
* // html: '<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>',
|
|
206
|
+
* // missingSlots: [],
|
|
207
|
+
* // }
|
|
208
|
+
*
|
|
209
|
+
* @see {@link TemplateField} for the field shape (name + kind + optional default).
|
|
210
|
+
* @see {@link ./content.js#MODULE_FIELD_KINDS} for the canonical kind union.
|
|
211
|
+
* @see {@link ./content.js#MODULE_FIELD_SECTION_KINDS} for the kinds the engine
|
|
212
|
+
* iterates via `{{#name}}` (text-list / link-list / module-list).
|
|
213
|
+
*/
|
|
214
|
+
export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput {
|
|
215
|
+
const missing: string[] = [];
|
|
216
|
+
const fieldByName = new Map<string, TemplateField>();
|
|
217
|
+
for (const f of input.fields) fieldByName.set(f.name, f);
|
|
218
|
+
const cvs = input.contentValues ?? {};
|
|
219
|
+
const partials = input.partials ?? {};
|
|
220
|
+
|
|
221
|
+
// Sentinels survive Mustache.render untouched (they contain no
|
|
222
|
+
// `{{` `}}`), then get restored to the original Mustache source
|
|
223
|
+
// after render — the loud-raw invariant. The prefix carries a
|
|
224
|
+
// per-call UUID so the sentinel string is unguessable from the
|
|
225
|
+
// outside: a module author who pastes the literal `__CAELO_TPL_…__`
|
|
226
|
+
// pattern into their HTML can't break loud-raw restore by
|
|
227
|
+
// intercepting the sentinel for a known-key sentinel — they would
|
|
228
|
+
// have to guess the UUID minted at render time.
|
|
229
|
+
const sentinels = new Map<string, string>();
|
|
230
|
+
const sentinelPrefix = `__CAELO_TPL_${globalThis.crypto.randomUUID()}_`;
|
|
231
|
+
const mkSentinel = (original: string): string => {
|
|
232
|
+
const key = `${sentinelPrefix}${sentinels.size}__`;
|
|
233
|
+
sentinels.set(key, original);
|
|
234
|
+
return key;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// 0. Defensively unwrap XHTML-style CDATA guards the model sometimes
|
|
238
|
+
// emits around inline <style>/<script> — they otherwise survive the
|
|
239
|
+
// byte-preserving compose path and leak a stray `]]>` into the page.
|
|
240
|
+
// Store-time normalization (modules.create/update) cleans new
|
|
241
|
+
// modules; this covers any already-stored HTML + the deploy render.
|
|
242
|
+
const sourceHtml = stripCdataGuards(input.html);
|
|
243
|
+
|
|
244
|
+
// 1. {{#name}}…{{/name}} sections.
|
|
245
|
+
let html = sourceHtml.replace(SECTION_RE, (match, name: string, inner: string) =>
|
|
246
|
+
renderSection(match, name, inner, fieldByName, cvs, partials, missing, mkSentinel),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// 2. {{>name}} single partials.
|
|
250
|
+
html = html.replace(PARTIAL_RE, (match, name: string) =>
|
|
251
|
+
renderPartialRef(match, name, fieldByName, cvs, partials, missing, mkSentinel),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// 3. {{name}} primitives. Pre-rewrite to canonical lowercase form
|
|
255
|
+
// so the (lowercased) view picks them up regardless of source
|
|
256
|
+
// casing. Unknowns become sentinels for loud-raw.
|
|
257
|
+
const declaredFieldNames = new Set<string>();
|
|
258
|
+
for (const f of input.fields) {
|
|
259
|
+
declaredFieldNames.add(f.name);
|
|
260
|
+
declaredFieldNames.add(f.name.toLowerCase());
|
|
261
|
+
}
|
|
262
|
+
const view: Record<string, unknown> = {};
|
|
263
|
+
for (const [k, v] of Object.entries(cvs)) {
|
|
264
|
+
view[k.toLowerCase()] = v === null || v === undefined ? "" : v;
|
|
265
|
+
}
|
|
266
|
+
for (const f of input.fields) {
|
|
267
|
+
const lower = f.name.toLowerCase();
|
|
268
|
+
if (lower in view) continue;
|
|
269
|
+
if (SECTION_KINDS.has(f.kind) || f.kind === "module") continue;
|
|
270
|
+
if (f.default !== undefined && f.default !== null) view[lower] = f.default;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// v0.11.1 (issue #76) — pre-populate four theme-asset substitutions
|
|
274
|
+
// when the caller supplied `themeAssets`. `contentValues` already
|
|
275
|
+
// populated `view` above so any per-placement override wins (per
|
|
276
|
+
// §S19's "contentValues take precedence" invariant). Unbound slots
|
|
277
|
+
// (null) DON'T land in the view — they fall through to the loud-raw
|
|
278
|
+
// path below, but mark themselves with `theme-asset-unbound:<slot>`
|
|
279
|
+
// so the failure marker disambiguates from `field-not-declared`.
|
|
280
|
+
const themeAssetKeys = THEME_ASSET_KEYS;
|
|
281
|
+
const themeAssetSlotByKey = THEME_ASSET_KEY_TO_SLOT;
|
|
282
|
+
const themeAssets = input.themeAssets;
|
|
283
|
+
if (themeAssets) {
|
|
284
|
+
for (const key of themeAssetKeys) {
|
|
285
|
+
const slot = themeAssetSlotByKey[key];
|
|
286
|
+
const url = themeAssets[slot];
|
|
287
|
+
if (typeof url === "string" && url.length > 0 && !(key in view)) {
|
|
288
|
+
view[key] = url;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
html = html.replace(PRIMITIVE_RE, (match, name: string) => {
|
|
294
|
+
const lower = name.toLowerCase();
|
|
295
|
+
if (lower in view) return `{{${lower}}}`;
|
|
296
|
+
// v0.11.1 (issue #76) — theme-asset placeholder with no bound URL.
|
|
297
|
+
// Use a dedicated marker so the failure surface disambiguates from
|
|
298
|
+
// the generic field-not-declared marker.
|
|
299
|
+
if (lower in themeAssetSlotByKey) {
|
|
300
|
+
const slot = themeAssetSlotByKey[lower as keyof typeof THEME_ASSET_KEY_TO_SLOT];
|
|
301
|
+
missing.push(`theme-asset-unbound:${slot}`);
|
|
302
|
+
return mkSentinel(match);
|
|
303
|
+
}
|
|
304
|
+
// No value + no default: leave raw (CLAUDE.md §2). Track in
|
|
305
|
+
// missingSlots only when the field isn't declared at all —
|
|
306
|
+
// declared-but-empty is the operator's responsibility (still
|
|
307
|
+
// authoring), not a system-side gap callers should warn about.
|
|
308
|
+
if (!declaredFieldNames.has(name) && !declaredFieldNames.has(lower)) {
|
|
309
|
+
missing.push(`field-not-declared:${name}`);
|
|
310
|
+
}
|
|
311
|
+
return mkSentinel(match);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// 4. Render. The view holds only lowercase keys; sentinels survive
|
|
315
|
+
// untouched; sections + partials are already pre-substituted.
|
|
316
|
+
const rendered = Mustache.render(html, view);
|
|
317
|
+
|
|
318
|
+
// 5. Restore loud-raw sentinels.
|
|
319
|
+
let final = rendered;
|
|
320
|
+
for (const [sentinel, original] of sentinels) {
|
|
321
|
+
final = final.split(sentinel).join(original);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { html: final, missingSlots: missing };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderSection(
|
|
328
|
+
match: string,
|
|
329
|
+
name: string,
|
|
330
|
+
inner: string,
|
|
331
|
+
fields: Map<string, TemplateField>,
|
|
332
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
333
|
+
partials: Readonly<Record<string, string>>,
|
|
334
|
+
missing: string[],
|
|
335
|
+
mkSentinel: (original: string) => string,
|
|
336
|
+
): string {
|
|
337
|
+
const field = fields.get(name);
|
|
338
|
+
if (!field) {
|
|
339
|
+
missing.push(`field-not-declared:${name}`);
|
|
340
|
+
return mkSentinel(match);
|
|
341
|
+
}
|
|
342
|
+
if (field.kind === "module-list") {
|
|
343
|
+
return renderModuleList(name, field, cvs, partials, missing);
|
|
344
|
+
}
|
|
345
|
+
if (field.kind === "text-list") {
|
|
346
|
+
return renderTextList(name, inner, field, cvs, missing);
|
|
347
|
+
}
|
|
348
|
+
if (field.kind === "link-list") {
|
|
349
|
+
return renderLinkList(name, inner, field, cvs, missing);
|
|
350
|
+
}
|
|
351
|
+
// Any NON-list field used as a `{{#name}}…{{/name}}` section is a
|
|
352
|
+
// CONDITIONAL (Mustache semantics): render the inner block once when the
|
|
353
|
+
// field has a present, non-empty value; drop it when empty. This is what
|
|
354
|
+
// makes `{{#subtitle}}<p>{{subtitle}}</p>{{/subtitle}}` wrap a scalar
|
|
355
|
+
// text/image field — previously a scalar in a section fell through to
|
|
356
|
+
// `kind-mismatch` and SILENTLY DROPPED real content (headings, body copy,
|
|
357
|
+
// an image, a single nested module). `{{name}}` inside the block resolves
|
|
358
|
+
// in the later primitive pass (a `module` field's `{{>name}}` in the
|
|
359
|
+
// partial pass); `{{.}}`/`{{item}}` are substituted here for parity with
|
|
360
|
+
// the *-list renderers. An empty conditional is intentional, not
|
|
361
|
+
// "missing" — nothing is pushed to `missing`.
|
|
362
|
+
return renderConditionalSection(name, inner, field, cvs);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Render a `{{#name}}…{{/name}}` section over a scalar/module field as a
|
|
367
|
+
* Mustache conditional: the block once when the value is present, else "".
|
|
368
|
+
*/
|
|
369
|
+
function renderConditionalSection(
|
|
370
|
+
name: string,
|
|
371
|
+
inner: string,
|
|
372
|
+
field: TemplateField,
|
|
373
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
374
|
+
): string {
|
|
375
|
+
const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
|
|
376
|
+
if (!isPresentSectionValue(raw)) return "";
|
|
377
|
+
const value = scalarSectionString(raw);
|
|
378
|
+
return value.length > 0 ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value) : inner;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Mustache-style truthiness for a conditional section value. Empty string,
|
|
383
|
+
* null/undefined, `false`, and an empty array/object are absent; everything
|
|
384
|
+
* else (incl. a numeric 0 — a real content value, not a control flag) is
|
|
385
|
+
* present.
|
|
386
|
+
*/
|
|
387
|
+
function isPresentSectionValue(raw: unknown): boolean {
|
|
388
|
+
if (raw === undefined || raw === null || raw === false) return false;
|
|
389
|
+
if (typeof raw === "string") return raw.trim().length > 0;
|
|
390
|
+
if (typeof raw === "number") return !Number.isNaN(raw);
|
|
391
|
+
if (Array.isArray(raw)) return raw.length > 0;
|
|
392
|
+
if (typeof raw === "object") return Object.keys(raw as object).length > 0;
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** String form for `{{.}}`/`{{item}}` inside a scalar conditional. Objects
|
|
397
|
+
* (module refs / image objects) render through `{{name}}`/`{{>name}}`, not
|
|
398
|
+
* `{{.}}`, so they stringify to "". */
|
|
399
|
+
function scalarSectionString(raw: unknown): string {
|
|
400
|
+
if (typeof raw === "string") return raw;
|
|
401
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
402
|
+
return "";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderTextList(
|
|
406
|
+
name: string,
|
|
407
|
+
inner: string,
|
|
408
|
+
field: TemplateField,
|
|
409
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
410
|
+
missing: string[],
|
|
411
|
+
): string {
|
|
412
|
+
const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
|
|
413
|
+
if (!Array.isArray(raw)) return "";
|
|
414
|
+
const parts: string[] = [];
|
|
415
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
416
|
+
const el = raw[i];
|
|
417
|
+
if (typeof el !== "string" && typeof el !== "number" && typeof el !== "boolean") {
|
|
418
|
+
missing.push(`text-list-malformed:${name}[${i}]`);
|
|
419
|
+
parts.push(comment(`text-list-malformed ${name}[${i}]`));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const value = String(el);
|
|
423
|
+
parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value));
|
|
424
|
+
}
|
|
425
|
+
return parts.join("");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderLinkList(
|
|
429
|
+
name: string,
|
|
430
|
+
inner: string,
|
|
431
|
+
field: TemplateField,
|
|
432
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
433
|
+
missing: string[],
|
|
434
|
+
): string {
|
|
435
|
+
const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
|
|
436
|
+
if (!Array.isArray(raw)) return "";
|
|
437
|
+
const parts: string[] = [];
|
|
438
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
439
|
+
const el = raw[i];
|
|
440
|
+
if (
|
|
441
|
+
typeof el !== "object" ||
|
|
442
|
+
el === null ||
|
|
443
|
+
typeof (el as { label?: unknown }).label !== "string" ||
|
|
444
|
+
typeof (el as { href?: unknown }).href !== "string"
|
|
445
|
+
) {
|
|
446
|
+
missing.push(`link-list-malformed:${name}[${i}]`);
|
|
447
|
+
parts.push(comment(`link-list-malformed ${name}[${i}]`));
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const { label, href } = el as { label: string; href: string };
|
|
451
|
+
parts.push(
|
|
452
|
+
inner.replace(/\{\{\s*label\s*\}\}/g, () => label).replace(/\{\{\s*href\s*\}\}/g, () => href),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return parts.join("");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderModuleList(
|
|
459
|
+
name: string,
|
|
460
|
+
field: TemplateField,
|
|
461
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
462
|
+
partials: Readonly<Record<string, string>>,
|
|
463
|
+
missing: string[],
|
|
464
|
+
): string {
|
|
465
|
+
const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
|
|
466
|
+
if (!Array.isArray(raw)) return "";
|
|
467
|
+
const parts: string[] = [];
|
|
468
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
469
|
+
const el = raw[i];
|
|
470
|
+
if (!isNestedRef(el)) {
|
|
471
|
+
missing.push(`module-list-malformed:${name}[${i}]`);
|
|
472
|
+
parts.push(comment(`module-list-malformed ${name}[${i}]`));
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const partialKey = `${name}__${i}`;
|
|
476
|
+
const partialHtml = partials[partialKey];
|
|
477
|
+
if (partialHtml === undefined) {
|
|
478
|
+
// Compose path: no DB → no partials → loud comment so
|
|
479
|
+
// operators see the gap. The preview-render path always
|
|
480
|
+
// supplies a partial (or routes a structured failure marker
|
|
481
|
+
// through it from renderInner), so this branch is the
|
|
482
|
+
// static-gen escape hatch until #70 lands.
|
|
483
|
+
parts.push(`<!-- caelo:module-list ${name} needs recursive renderer (compose path) -->`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
parts.push(partialHtml);
|
|
487
|
+
}
|
|
488
|
+
return parts.join("");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function renderPartialRef(
|
|
492
|
+
match: string,
|
|
493
|
+
name: string,
|
|
494
|
+
fields: Map<string, TemplateField>,
|
|
495
|
+
cvs: Readonly<Record<string, unknown>>,
|
|
496
|
+
partials: Readonly<Record<string, string>>,
|
|
497
|
+
missing: string[],
|
|
498
|
+
mkSentinel: (original: string) => string,
|
|
499
|
+
): string {
|
|
500
|
+
const field = fields.get(name);
|
|
501
|
+
if (!field) {
|
|
502
|
+
missing.push(`field-not-declared:${name}`);
|
|
503
|
+
return mkSentinel(match);
|
|
504
|
+
}
|
|
505
|
+
if (field.kind !== "module") {
|
|
506
|
+
const reason = `kind-mismatch:${name} expected=module actual=${field.kind}`;
|
|
507
|
+
missing.push(reason);
|
|
508
|
+
return comment(reason);
|
|
509
|
+
}
|
|
510
|
+
const ref = cvs[name];
|
|
511
|
+
if (!isNestedRef(ref)) {
|
|
512
|
+
missing.push(`module-ref-malformed:${name}`);
|
|
513
|
+
return comment(`module-ref-malformed ${name}`);
|
|
514
|
+
}
|
|
515
|
+
const partialHtml = partials[name];
|
|
516
|
+
if (partialHtml === undefined) {
|
|
517
|
+
return `<!-- caelo:module ${name} needs recursive renderer (compose path) -->`;
|
|
518
|
+
}
|
|
519
|
+
return partialHtml;
|
|
520
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #153 — first-class `gradient` token category: boundary
|
|
5
|
+
* validation (well-formed CSS gradient strings only; no url(), no
|
|
6
|
+
* declaration breakout) and renderer emission as `--gradient-<name>`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { listThemeCssVarNames, renderThemeCss } from "./theme-render.js";
|
|
11
|
+
import { type ThemeDocument, themeDocument, themeGradientToken } from "./themes.js";
|
|
12
|
+
|
|
13
|
+
function docWithGradient(value: string): unknown {
|
|
14
|
+
return {
|
|
15
|
+
color: { primary: { $type: "color", $value: "#4f46e5" } },
|
|
16
|
+
gradient: { hero: { $type: "gradient", $value: value } },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("themeGradientToken boundary (issue #153)", () => {
|
|
21
|
+
it("accepts linear / radial / conic / repeating gradients and aliases", () => {
|
|
22
|
+
for (const v of [
|
|
23
|
+
"linear-gradient(135deg, #4f46e5, #7c3aed)",
|
|
24
|
+
"radial-gradient(circle at 30% 20%, #06b6d4, transparent)",
|
|
25
|
+
"conic-gradient(from 90deg, #f59e0b, #ef4444)",
|
|
26
|
+
"repeating-linear-gradient(45deg, #0f172a 0 10px, #1e293b 10px 20px)",
|
|
27
|
+
]) {
|
|
28
|
+
const r = themeDocument.safeParse(docWithGradient(v));
|
|
29
|
+
expect(r.success).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
expect(
|
|
32
|
+
themeDocument.safeParse({
|
|
33
|
+
gradient: {
|
|
34
|
+
hero: { $value: "linear-gradient(135deg, #4f46e5, #7c3aed)" },
|
|
35
|
+
subtle: { $type: "gradient", $value: "{gradient.hero}" },
|
|
36
|
+
},
|
|
37
|
+
}).success,
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects url()-carrying, declaration-breaking, and non-gradient values", () => {
|
|
42
|
+
for (const v of [
|
|
43
|
+
"linear-gradient(135deg, url(https://evil.example/x), #fff)",
|
|
44
|
+
"linear-gradient(90deg, #fff, #000); background: red",
|
|
45
|
+
"linear-gradient(90deg, #fff, #000)} body{display:none",
|
|
46
|
+
"not-a-gradient(#fff, #000)",
|
|
47
|
+
]) {
|
|
48
|
+
// Document-level: none of these match ANY token shape.
|
|
49
|
+
expect(themeDocument.safeParse(docWithGradient(v)).success).toBe(false);
|
|
50
|
+
// Token-level: the gradient schema itself names the reason.
|
|
51
|
+
expect(themeGradientToken.safeParse({ $type: "gradient", $value: v }).success).toBe(false);
|
|
52
|
+
}
|
|
53
|
+
// Per-category leaf enforcement (#153): a plain color string under
|
|
54
|
+
// gradient.* fails BOTH the token schema and the document boundary —
|
|
55
|
+
// pre-#153 it silently validated as another category's token.
|
|
56
|
+
expect(themeGradientToken.safeParse({ $type: "gradient", $value: "#4f46e5" }).success).toBe(
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
expect(themeDocument.safeParse(docWithGradient("#4f46e5")).success).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("closes the pre-#153 hole: invalid leaves no longer pass as metadata groups", () => {
|
|
63
|
+
// Before the per-category walker, {$value: <garbage>} fell back to
|
|
64
|
+
// validating as a "group" of $-metadata and STILL got emitted into
|
|
65
|
+
// CSS by the renderer — silent acceptance (CLAUDE.md §2).
|
|
66
|
+
// ("notacolor" would pass — the color schema deliberately admits any
|
|
67
|
+
// single word as a potential CSS named color; the breakout shape
|
|
68
|
+
// below matches no color form at all.)
|
|
69
|
+
expect(
|
|
70
|
+
themeDocument.safeParse({
|
|
71
|
+
color: { primary: { $type: "color", $value: "red; background:url(x)" } },
|
|
72
|
+
}).success,
|
|
73
|
+
).toBe(false);
|
|
74
|
+
expect(
|
|
75
|
+
themeDocument.safeParse({ spacing: { md: { $value: "not-a-dimension" } } }).success,
|
|
76
|
+
).toBe(false);
|
|
77
|
+
// Unknown categories keep the DTCG open-vocabulary tolerance.
|
|
78
|
+
expect(
|
|
79
|
+
themeDocument.safeParse({ effect: { blur: { $type: "blur", $value: "8px" } } }).success,
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("gradient rendering (issue #153)", () => {
|
|
85
|
+
const doc = docWithGradient("linear-gradient(135deg, #4f46e5, #7c3aed)") as ThemeDocument;
|
|
86
|
+
|
|
87
|
+
it("emits --gradient-<name> and lists it in the var inventory", () => {
|
|
88
|
+
const css = renderThemeCss(doc);
|
|
89
|
+
expect(css).toContain("--gradient-hero:linear-gradient(135deg, #4f46e5, #7c3aed);");
|
|
90
|
+
expect(listThemeCssVarNames(doc)).toContain("--gradient-hero");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression tests for the prototype-pollution guards (issue #113, S5 —
|
|
5
|
+
* js/prototype-polluting-assignment + js/prototype-pollution-utility).
|
|
6
|
+
* Hostile keys derived from imported CSS variable names / dotted write
|
|
7
|
+
* paths must never mutate the prototype chain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { isUnsafeKey } from "../../safe-keys.js";
|
|
12
|
+
import { applyDtcgWrites } from "../../themes.js";
|
|
13
|
+
import { importTailwind } from "../tailwind.js";
|
|
14
|
+
|
|
15
|
+
describe("isUnsafeKey", () => {
|
|
16
|
+
it("flags the prototype-pollution keys and nothing else", () => {
|
|
17
|
+
expect(isUnsafeKey("__proto__")).toBe(true);
|
|
18
|
+
expect(isUnsafeKey("constructor")).toBe(true);
|
|
19
|
+
expect(isUnsafeKey("prototype")).toBe(true);
|
|
20
|
+
expect(isUnsafeKey("primary")).toBe(false);
|
|
21
|
+
expect(isUnsafeKey("color")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("importTailwind prototype-pollution guard (S5)", () => {
|
|
26
|
+
it("drops a hostile --color-__proto__ token without polluting", () => {
|
|
27
|
+
const doc = importTailwind("@theme { --color-__proto__: #ff6600; --color-primary: #112233; }");
|
|
28
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
29
|
+
expect(Object.prototype).not.toHaveProperty("__proto__polluted");
|
|
30
|
+
// The legitimate token still imports.
|
|
31
|
+
expect(JSON.stringify(doc)).toContain("primary");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("drops a hostile ramp base name (--color-constructor-500)", () => {
|
|
35
|
+
const doc = importTailwind(
|
|
36
|
+
"@theme { --color-constructor-500: #abcabc; --color-primary: #001122; }",
|
|
37
|
+
);
|
|
38
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
39
|
+
expect(JSON.stringify(doc)).toContain("primary");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("applyDtcgWrites / setLeafAtPath path guard (S5)", () => {
|
|
44
|
+
it("rejects a write whose path contains __proto__ (no pollution, no write)", () => {
|
|
45
|
+
const out = applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {});
|
|
46
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
47
|
+
expect(out).not.toHaveProperty("a");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("still applies a legitimate dotted write", () => {
|
|
51
|
+
const out = applyDtcgWrites({}, { "color.primary": "#fff" }, { "color.primary": "color" });
|
|
52
|
+
expect(JSON.stringify(out)).toContain("primary");
|
|
53
|
+
});
|
|
54
|
+
});
|