@caelo-cms/shared 0.10.23 → 0.10.26
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 +15 -70
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +14 -74
- package/dist/ai-tools.js.map +1 -1
- package/dist/base-css.d.ts +11 -0
- package/dist/base-css.d.ts.map +1 -1
- package/dist/base-css.js +13 -1
- package/dist/base-css.js.map +1 -1
- package/dist/build-page.d.ts +0 -2
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +2 -3
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +0 -3
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +91 -7
- package/dist/content.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/design-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.js.map +1 -0
- package/dist/font-assets.d.ts +89 -0
- package/dist/font-assets.d.ts.map +1 -0
- package/dist/font-assets.js +61 -0
- package/dist/font-assets.js.map +1 -0
- package/dist/genesis.d.ts +43 -3
- package/dist/genesis.d.ts.map +1 -1
- package/dist/genesis.js +72 -5
- package/dist/genesis.js.map +1 -1
- package/dist/google-models.d.ts +35 -0
- package/dist/google-models.d.ts.map +1 -0
- package/dist/google-models.js +20 -0
- package/dist/google-models.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +0 -3
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +0 -5
- package/dist/media.js.map +1 -1
- package/dist/plugin-preview.d.ts +44 -0
- package/dist/plugin-preview.d.ts.map +1 -0
- package/dist/plugin-preview.js +69 -0
- package/dist/plugin-preview.js.map +1 -0
- package/dist/preview-compose.d.ts +35 -15
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +95 -89
- package/dist/preview-compose.js.map +1 -1
- package/dist/responsive-images.d.ts +6 -5
- package/dist/responsive-images.d.ts.map +1 -1
- package/dist/responsive-images.js +6 -5
- package/dist/responsive-images.js.map +1 -1
- package/dist/seo.d.ts +11 -28
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +12 -31
- package/dist/seo.js.map +1 -1
- package/dist/structured-sets.d.ts +0 -36
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +1 -52
- package/dist/structured-sets.js.map +1 -1
- package/dist/template-engine.d.ts +18 -0
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +39 -2
- package/dist/template-engine.js.map +1 -1
- package/dist/theme-importers/loose.js +1 -1
- package/dist/theme-importers/loose.js.map +1 -1
- package/dist/theme-normalize.d.ts +9 -0
- package/dist/theme-normalize.d.ts.map +1 -1
- package/dist/theme-normalize.js +65 -6
- package/dist/theme-normalize.js.map +1 -1
- package/dist/theme-render.d.ts +15 -0
- package/dist/theme-render.d.ts.map +1 -1
- package/dist/theme-render.js +39 -0
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts +15 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +48 -1
- package/dist/themes.js.map +1 -1
- package/dist/url.d.ts +45 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +64 -0
- package/dist/url.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/redos-hardening.test.ts +1 -1
- package/src/ai-tools.ts +14 -90
- package/src/base-css.ts +13 -1
- package/src/build-page.ts +3 -5
- package/src/content.test.ts +99 -17
- package/src/content.ts +95 -8
- package/src/context.ts +6 -0
- package/src/design-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -0
- package/src/font-assets.ts +63 -0
- package/src/genesis.ts +76 -5
- package/src/google-models.ts +20 -0
- package/src/index.ts +5 -3
- package/src/media.ts +0 -6
- package/src/plugin-preview.test.ts +79 -0
- package/src/plugin-preview.ts +81 -0
- package/src/preview-compose-deferrals.test.ts +144 -0
- package/src/preview-compose.test.ts +51 -0
- package/src/preview-compose.ts +128 -93
- package/src/responsive-images.ts +6 -5
- package/src/seo.test.ts +8 -48
- package/src/seo.ts +16 -52
- package/src/structured-sets.ts +1 -67
- package/src/template-engine-data-lists.test.ts +90 -0
- package/src/template-engine.ts +72 -1
- package/src/theme-importers/loose.ts +1 -1
- package/src/theme-normalize.ts +70 -8
- package/src/theme-render.ts +36 -0
- package/src/theme-token-roles.test.ts +182 -0
- package/src/themes.ts +53 -0
- package/src/url.test.ts +40 -0
- package/src/url.ts +69 -0
- package/src/version.ts +1 -1
- package/dist/design-manifest.d.ts +0 -36
- package/dist/design-manifest.d.ts.map +0 -1
- package/dist/design-manifest.js +0 -90
- package/dist/design-manifest.js.map +0 -1
- package/dist/i18n.d.ts +0 -92
- package/dist/i18n.d.ts.map +0 -1
- package/dist/i18n.js +0 -226
- package/dist/i18n.js.map +0 -1
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- package/dist/translation.js.map +0 -1
- package/src/design-manifest.ts +0 -93
- package/src/i18n.test.ts +0 -274
- package/src/i18n.ts +0 -269
- package/src/translation.test.ts +0 -160
- package/src/translation.ts +0 -295
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin data lists in the template engine: `{{#name}}…{{/name}}` over
|
|
5
|
+
* data a plugin supplies per page, with the module owning the markup.
|
|
6
|
+
*
|
|
7
|
+
* The load-bearing case is the third one. Plugin activation is a hard
|
|
8
|
+
* state, so a module written while a plugin ran keeps its
|
|
9
|
+
* `{{#language_links}}` after the plugin is switched off. Rendering
|
|
10
|
+
* that as an empty string would be a silent fallback (CLAUDE.md §2) and
|
|
11
|
+
* would read to the operator as "the switcher vanished for no reason".
|
|
12
|
+
* It has to stay visible AND say which plugin to turn back on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "bun:test";
|
|
16
|
+
import { renderTemplate } from "./template-engine.js";
|
|
17
|
+
|
|
18
|
+
const SWITCHER =
|
|
19
|
+
'<nav>{{#language_links}}<a href="{{href}}">{{label}}</a>{{/language_links}}</nav>';
|
|
20
|
+
|
|
21
|
+
describe("template engine — plugin data lists", () => {
|
|
22
|
+
it("iterates a plugin list with the module's own markup", () => {
|
|
23
|
+
const r = renderTemplate({
|
|
24
|
+
html: SWITCHER,
|
|
25
|
+
fields: [],
|
|
26
|
+
dataLists: {
|
|
27
|
+
language_links: [
|
|
28
|
+
{ href: "/", label: "English", locale: "en" },
|
|
29
|
+
{ href: "/de", label: "Deutsch", locale: "de" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
expect(r.html).toBe('<nav><a href="/">English</a><a href="/de">Deutsch</a></nav>');
|
|
34
|
+
expect(r.missingSlots).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders nothing for an empty list — that is data, not breakage", () => {
|
|
38
|
+
// A page with no published translations legitimately has no links.
|
|
39
|
+
const r = renderTemplate({
|
|
40
|
+
html: SWITCHER,
|
|
41
|
+
fields: [],
|
|
42
|
+
dataLists: { language_links: [] },
|
|
43
|
+
});
|
|
44
|
+
expect(r.html).toBe("<nav></nav>");
|
|
45
|
+
expect(r.missingSlots).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stays loud and names the plugin when its plugin is switched off", () => {
|
|
49
|
+
const r = renderTemplate({
|
|
50
|
+
html: SWITCHER,
|
|
51
|
+
fields: [],
|
|
52
|
+
dormantDataLists: { language_links: "international-site" },
|
|
53
|
+
});
|
|
54
|
+
// Loud-raw: the section survives in the output rather than
|
|
55
|
+
// silently collapsing.
|
|
56
|
+
expect(r.html).toContain("{{#language_links}}");
|
|
57
|
+
expect(r.missingSlots).toContain(
|
|
58
|
+
"plugin-list-unavailable:language_links plugin=international-site",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps an unknown name a plain undeclared field", () => {
|
|
63
|
+
// No plugin claims it — this really is a typo, and must not be
|
|
64
|
+
// reported as a deactivated plugin.
|
|
65
|
+
const r = renderTemplate({ html: "<p>{{#nope}}x{{/nope}}</p>", fields: [] });
|
|
66
|
+
expect(r.missingSlots).toContain("field-not-declared:nope");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lets a module's own field win over a plugin list of the same name", () => {
|
|
70
|
+
// Module fields are resolved first by construction, so a plugin can
|
|
71
|
+
// never shadow content the author declared.
|
|
72
|
+
const r = renderTemplate({
|
|
73
|
+
html: "<ul>{{#items}}<li>{{label}}</li>{{/items}}</ul>",
|
|
74
|
+
fields: [{ name: "items", kind: "link-list" }],
|
|
75
|
+
contentValues: { items: [{ label: "authored", href: "/a" }] },
|
|
76
|
+
dataLists: { items: [{ label: "from-plugin" }] },
|
|
77
|
+
});
|
|
78
|
+
expect(r.html).toContain("authored");
|
|
79
|
+
expect(r.html).not.toContain("from-plugin");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves a key the items do not carry raw", () => {
|
|
83
|
+
const r = renderTemplate({
|
|
84
|
+
html: "{{#l}}<a href={{href}}>{{missing_key}}</a>{{/l}}",
|
|
85
|
+
fields: [],
|
|
86
|
+
dataLists: { l: [{ href: "/x" }] },
|
|
87
|
+
});
|
|
88
|
+
expect(r.html).toContain("{{missing_key}}");
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/template-engine.ts
CHANGED
|
@@ -93,6 +93,24 @@ export interface RenderTemplateInput {
|
|
|
93
93
|
* Preview-render path: built from RenderResolver walks.
|
|
94
94
|
*/
|
|
95
95
|
readonly partials?: Readonly<Record<string, string>>;
|
|
96
|
+
/**
|
|
97
|
+
* Plugin-provided lists for THIS page, keyed by the claimed name a
|
|
98
|
+
* module iterates as `{{#name}}`. The plugin supplies the data, the
|
|
99
|
+
* module owns the markup — so a language switcher looks like the site
|
|
100
|
+
* it lives on instead of carrying a plugin's fixed HTML.
|
|
101
|
+
*
|
|
102
|
+
* Resolved per page, which is what lets a module carrying one sit in
|
|
103
|
+
* a LAYOUT and cover every page. The `staticRender` placeholder can't:
|
|
104
|
+
* it needs the page's own id baked into its HTML.
|
|
105
|
+
*/
|
|
106
|
+
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
107
|
+
/**
|
|
108
|
+
* Names an INSTALLED plugin declares that are not live right now,
|
|
109
|
+
* mapped to the owning plugin. A module written while the plugin ran
|
|
110
|
+
* still says `{{#language_links}}`; this is what lets the render
|
|
111
|
+
* report "that plugin is switched off" instead of "unknown field".
|
|
112
|
+
*/
|
|
113
|
+
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
96
114
|
/**
|
|
97
115
|
* v0.11.1 (issue #76) — active theme's resolved asset URLs. When
|
|
98
116
|
* present, `{{theme_logo_url}}` / `{{theme_logo_dark_url}}` /
|
|
@@ -243,7 +261,18 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
|
|
|
243
261
|
|
|
244
262
|
// 1. {{#name}}…{{/name}} sections.
|
|
245
263
|
let html = sourceHtml.replace(SECTION_RE, (match, name: string, inner: string) =>
|
|
246
|
-
renderSection(
|
|
264
|
+
renderSection(
|
|
265
|
+
match,
|
|
266
|
+
name,
|
|
267
|
+
inner,
|
|
268
|
+
fieldByName,
|
|
269
|
+
cvs,
|
|
270
|
+
partials,
|
|
271
|
+
missing,
|
|
272
|
+
mkSentinel,
|
|
273
|
+
input.dataLists ?? {},
|
|
274
|
+
input.dormantDataLists ?? {},
|
|
275
|
+
),
|
|
247
276
|
);
|
|
248
277
|
|
|
249
278
|
// 2. {{>name}} single partials.
|
|
@@ -333,9 +362,24 @@ function renderSection(
|
|
|
333
362
|
partials: Readonly<Record<string, string>>,
|
|
334
363
|
missing: string[],
|
|
335
364
|
mkSentinel: (original: string) => string,
|
|
365
|
+
dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>,
|
|
366
|
+
dormantLists: Readonly<Record<string, string>>,
|
|
336
367
|
): string {
|
|
337
368
|
const field = fields.get(name);
|
|
338
369
|
if (!field) {
|
|
370
|
+
// No declared field — a plugin may still offer this name. Module
|
|
371
|
+
// fields are looked up FIRST by construction, so a plugin can never
|
|
372
|
+
// shadow something the module's author declared.
|
|
373
|
+
const items = dataLists[name];
|
|
374
|
+
if (items) return renderDataList(inner, items);
|
|
375
|
+
// Declared by an installed plugin that is NOT running. Naming the
|
|
376
|
+
// plugin turns "unknown field" (hunt for a typo) into "switch that
|
|
377
|
+
// plugin back on", which is the actual fix.
|
|
378
|
+
const owner = dormantLists[name];
|
|
379
|
+
if (owner) {
|
|
380
|
+
missing.push(`plugin-list-unavailable:${name} plugin=${owner}`);
|
|
381
|
+
return mkSentinel(match);
|
|
382
|
+
}
|
|
339
383
|
missing.push(`field-not-declared:${name}`);
|
|
340
384
|
return mkSentinel(match);
|
|
341
385
|
}
|
|
@@ -425,6 +469,33 @@ function renderTextList(
|
|
|
425
469
|
return parts.join("");
|
|
426
470
|
}
|
|
427
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Iterate a plugin-provided list, substituting each item's keys inside
|
|
474
|
+
* the section body.
|
|
475
|
+
*
|
|
476
|
+
* Unlike `link-list` no shape is imposed: the keys are whatever the
|
|
477
|
+
* plugin declared in `itemFields`, so a language switcher uses
|
|
478
|
+
* href/label/locale and a comment list something else entirely. A
|
|
479
|
+
* `{{key}}` the item does not carry is left raw, matching the engine's
|
|
480
|
+
* loud-raw rule everywhere else — a typo'd key stays visible instead of
|
|
481
|
+
* quietly rendering as nothing.
|
|
482
|
+
*/
|
|
483
|
+
function renderDataList(
|
|
484
|
+
inner: string,
|
|
485
|
+
items: ReadonlyArray<Readonly<Record<string, string>>>,
|
|
486
|
+
): string {
|
|
487
|
+
const parts: string[] = [];
|
|
488
|
+
for (const item of items) {
|
|
489
|
+
let rendered = inner;
|
|
490
|
+
for (const [key, value] of Object.entries(item)) {
|
|
491
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
492
|
+
rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () => value);
|
|
493
|
+
}
|
|
494
|
+
parts.push(rendered);
|
|
495
|
+
}
|
|
496
|
+
return parts.join("");
|
|
497
|
+
}
|
|
498
|
+
|
|
428
499
|
function renderLinkList(
|
|
429
500
|
name: string,
|
|
430
501
|
inner: string,
|
|
@@ -46,7 +46,7 @@ export function importLoose(body: string): ThemeDocument {
|
|
|
46
46
|
// ambiguity, per the v0.11.0 normalizer's failure surface).
|
|
47
47
|
const normalized = normalizeTokens(obj);
|
|
48
48
|
// Apply to an empty document so the result is a complete tokens tree.
|
|
49
|
-
const written = applyDtcgWrites({}, normalized.set, normalized.types);
|
|
49
|
+
const written = applyDtcgWrites({}, normalized.set, normalized.types, normalized.descriptions);
|
|
50
50
|
return validateThemeTokens(written);
|
|
51
51
|
}
|
|
52
52
|
|
package/src/theme-normalize.ts
CHANGED
|
@@ -48,6 +48,15 @@ export interface NormalizeResult {
|
|
|
48
48
|
CanonicalPath,
|
|
49
49
|
"color" | "dimension" | "typography" | "shadow" | "duration" | "cubicBezier" | "gradient"
|
|
50
50
|
>;
|
|
51
|
+
/**
|
|
52
|
+
* issue #430 — the token's ROLE, per canonical path: what this token
|
|
53
|
+
* is for and, crucially, where it must NOT be used ("CTAs and links
|
|
54
|
+
* only — never large background fills"). Lifted off the `$description`
|
|
55
|
+
* of a DTCG envelope the caller passed in `set`, so a value and its
|
|
56
|
+
* role land in ONE write instead of a value call plus a prose-metadata
|
|
57
|
+
* call. The ops layer stores it back on the leaf as `$description`.
|
|
58
|
+
*/
|
|
59
|
+
readonly descriptions: Record<CanonicalPath, string>;
|
|
51
60
|
/** Echo-back list for the AI tool's result content. */
|
|
52
61
|
readonly canonicalPaths: readonly CanonicalPath[];
|
|
53
62
|
}
|
|
@@ -218,6 +227,7 @@ const DEFAULT_TIER = "sm"; // shadowSm / radiusMd default
|
|
|
218
227
|
export function normalizeTokens(input: Record<string, unknown>): NormalizeResult {
|
|
219
228
|
const set: Record<CanonicalPath, unknown> = {};
|
|
220
229
|
const types: Record<CanonicalPath, NormalizeResult["types"][string]> = {};
|
|
230
|
+
const descriptions: Record<CanonicalPath, string> = {};
|
|
221
231
|
const paths: CanonicalPath[] = [];
|
|
222
232
|
|
|
223
233
|
for (const [rawName, rawValueIn] of Object.entries(input)) {
|
|
@@ -235,13 +245,35 @@ export function normalizeTokens(input: Record<string, unknown>): NormalizeResult
|
|
|
235
245
|
// Not JSON after all — keep the literal string.
|
|
236
246
|
}
|
|
237
247
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
rawValue
|
|
248
|
+
// issue #430 — the envelope may also carry `$description` (the token's
|
|
249
|
+
// role). Lift it BEFORE unwrapping, then re-attach it at the canonical
|
|
250
|
+
// path so "what this colour is for" survives the same call that sets
|
|
251
|
+
// the colour. Without this the role is silently dropped on the way in.
|
|
252
|
+
let envelopeDescription: string | undefined;
|
|
253
|
+
const isEnvelope =
|
|
254
|
+
rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue);
|
|
255
|
+
if (isEnvelope && "$value" in (rawValue as Record<string, unknown>)) {
|
|
256
|
+
const envelope = rawValue as Record<string, unknown>;
|
|
257
|
+
if (typeof envelope.$description === "string" && envelope.$description.trim().length > 0) {
|
|
258
|
+
envelopeDescription = envelope.$description;
|
|
259
|
+
}
|
|
260
|
+
rawValue = envelope.$value;
|
|
261
|
+
} else if (isEnvelope && "$description" in (rawValue as Record<string, unknown>)) {
|
|
262
|
+
// ROLE-ONLY patch: `{$description}` with no `$value`. The anchor-page
|
|
263
|
+
// design review annotates tokens whose VALUES are already settled, so
|
|
264
|
+
// it must be able to write the role without restating (and risking
|
|
265
|
+
// drift on) every value. Resolve the path without the value — which
|
|
266
|
+
// only canonical / CSS-var names allow, since loose names infer their
|
|
267
|
+
// category FROM the value — and record no `set` entry, so
|
|
268
|
+
// applyDtcgWrites patches the leaf's metadata and leaves `$value` alone.
|
|
269
|
+
const role = (rawValue as Record<string, unknown>).$description;
|
|
270
|
+
// A blank role is nothing to write — same tolerance the value-carrying
|
|
271
|
+
// envelope gives it. The tool reports the resulting no-op.
|
|
272
|
+
if (typeof role !== "string" || role.trim().length === 0) continue;
|
|
273
|
+
const path = resolvePathWithoutValue(rawName);
|
|
274
|
+
descriptions[path] = role.trim();
|
|
275
|
+
if (!paths.includes(path)) paths.push(path);
|
|
276
|
+
continue;
|
|
245
277
|
}
|
|
246
278
|
const resolved = resolveOne(rawName, rawValue);
|
|
247
279
|
// v0.11.0 fix (#45 review thread on theme-normalize.ts:149) —
|
|
@@ -273,10 +305,11 @@ export function normalizeTokens(input: Record<string, unknown>): NormalizeResult
|
|
|
273
305
|
}
|
|
274
306
|
set[resolved.path] = storedValue;
|
|
275
307
|
types[resolved.path] = resolved.inferredType;
|
|
308
|
+
if (envelopeDescription !== undefined) descriptions[resolved.path] = envelopeDescription;
|
|
276
309
|
paths.push(resolved.path);
|
|
277
310
|
}
|
|
278
311
|
|
|
279
|
-
return { set, types, canonicalPaths: paths };
|
|
312
|
+
return { set, types, descriptions, canonicalPaths: paths };
|
|
280
313
|
}
|
|
281
314
|
|
|
282
315
|
interface ResolvedToken {
|
|
@@ -291,6 +324,35 @@ interface ResolvedToken {
|
|
|
291
324
|
readonly compositeWrap?: string;
|
|
292
325
|
}
|
|
293
326
|
|
|
327
|
+
/**
|
|
328
|
+
* issue #430 — resolve a canonical path for a ROLE-ONLY patch, where no
|
|
329
|
+
* value is supplied to infer the category from.
|
|
330
|
+
*
|
|
331
|
+
* Only the two name forms that carry their own category work here:
|
|
332
|
+
* a canonical DTCG path (`color.primary`) and the CSS-var form
|
|
333
|
+
* (`--color-primary`) — which is what a caller annotating an existing
|
|
334
|
+
* theme naturally has, since it just read the document or the module CSS.
|
|
335
|
+
* A loose name (`primaryColor`) infers its category FROM the value, so
|
|
336
|
+
* without one it is genuinely unresolvable: say so rather than guess
|
|
337
|
+
* (CLAUDE.md §2).
|
|
338
|
+
*/
|
|
339
|
+
function resolvePathWithoutValue(rawName: string): CanonicalPath {
|
|
340
|
+
if (/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i.test(rawName)) return rawName;
|
|
341
|
+
if (rawName.startsWith("--")) {
|
|
342
|
+
const stripped = rawName.slice(2);
|
|
343
|
+
const firstHyphen = stripped.indexOf("-");
|
|
344
|
+
if (firstHyphen > 0) {
|
|
345
|
+
const category = stripped.slice(0, firstHyphen);
|
|
346
|
+
const rest = stripped.slice(firstHyphen + 1);
|
|
347
|
+
// --font-heading / --text-heading are two emitted vars for ONE
|
|
348
|
+
// typography composite; a role belongs on the composite.
|
|
349
|
+
if (category === "font" || category === "text") return `typography.${rest}`;
|
|
350
|
+
return `${category}.${rest}`;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
throw new UnknownTokenName(rawName, []);
|
|
354
|
+
}
|
|
355
|
+
|
|
294
356
|
function resolveOne(rawName: string, rawValue: unknown): ResolvedToken {
|
|
295
357
|
// 1. Direct canonical DTCG paths: pass through unchanged. Typography
|
|
296
358
|
// sub-paths (`typography.X.fontFamily` / `.fontSize` / …) collapse
|
package/src/theme-render.ts
CHANGED
|
@@ -139,6 +139,41 @@ export function listThemeCssVarNames(tokens: ThemeDocument): readonly string[] {
|
|
|
139
139
|
return [...names].sort();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* issue #430 — map every emitted CSS variable name to the ROLE recorded
|
|
144
|
+
* on its token (`$description`): what the token is for and where it must
|
|
145
|
+
* not be used. Returns `{}` when no token carries a role.
|
|
146
|
+
*
|
|
147
|
+
* This is how a design decision reaches the point of use. The write-time
|
|
148
|
+
* design guard looks up the vars a module's CSS actually references and
|
|
149
|
+
* replays their roles back at the author, so "primary is for CTAs, never
|
|
150
|
+
* for large background fills" is enforced on page nine as firmly as on
|
|
151
|
+
* page one — without a separate design-system document to maintain.
|
|
152
|
+
*
|
|
153
|
+
* A composite (typography) emits several vars from one token; each
|
|
154
|
+
* inherits the same role, which is what a reader expects.
|
|
155
|
+
*/
|
|
156
|
+
export function listTokenRoles(tokens: ThemeDocument): Record<string, string> {
|
|
157
|
+
const flat = flattenTokens(tokens);
|
|
158
|
+
const resolveCache = new Map<string, unknown>();
|
|
159
|
+
const roles: Record<string, string> = {};
|
|
160
|
+
for (const { path, token } of flat) {
|
|
161
|
+
const description = (token as { $description?: unknown }).$description;
|
|
162
|
+
if (typeof description !== "string" || description.trim().length === 0) continue;
|
|
163
|
+
const category = path.split(".")[0] ?? "";
|
|
164
|
+
const value = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
emitTokenLines(category, path, value, lines, []);
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
const colon = line.indexOf(":");
|
|
169
|
+
if (colon <= 0) continue;
|
|
170
|
+
const name = line.slice(0, colon).trim();
|
|
171
|
+
if (name.startsWith("--")) roles[name] = description.trim();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return roles;
|
|
175
|
+
}
|
|
176
|
+
|
|
142
177
|
/**
|
|
143
178
|
* Emit a Tailwind 4 `@theme inline { … }` block. The body re-uses the
|
|
144
179
|
* same per-category emission as `renderThemeCss` so the variable names
|
|
@@ -387,6 +422,7 @@ function emitTypography(rest: string, value: unknown, out: string[]): void {
|
|
|
387
422
|
const v = value as Record<string, unknown>;
|
|
388
423
|
if (v.fontFamily !== undefined) out.push(`--font-${rest}:${asString(v.fontFamily)};`);
|
|
389
424
|
if (v.fontSize !== undefined) out.push(`--text-${rest}:${asString(v.fontSize)};`);
|
|
425
|
+
if (v.fontStyle !== undefined) out.push(`--font-style-${rest}:${asString(v.fontStyle)};`);
|
|
390
426
|
if (v.fontWeight !== undefined) out.push(`--font-weight-${rest}:${asString(v.fontWeight)};`);
|
|
391
427
|
if (v.lineHeight !== undefined) out.push(`--leading-${rest}:${asString(v.lineHeight)};`);
|
|
392
428
|
if (v.letterSpacing !== undefined) out.push(`--tracking-${rest}:${asString(v.letterSpacing)};`);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #430 — a token's ROLE is set in the same call as its value and
|
|
5
|
+
* must survive every later value-only edit.
|
|
6
|
+
*
|
|
7
|
+
* Before this, `applyDtcgWrites` rebuilt each leaf as `{$value, $type}`
|
|
8
|
+
* and silently dropped `$description`, so recording a role was pointless:
|
|
9
|
+
* the next `set_theme_tokens` erased it. These tests pin the write path
|
|
10
|
+
* (normalizer lifts the role off the DTCG envelope), the preserve path,
|
|
11
|
+
* and the lookup the design guard uses to replay roles at the point of
|
|
12
|
+
* use.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "bun:test";
|
|
16
|
+
import { normalizeTokens } from "./theme-normalize.js";
|
|
17
|
+
import { listTokenRoles } from "./theme-render.js";
|
|
18
|
+
import { applyDtcgWrites, type ThemeDocument, validateThemeTokens } from "./themes.js";
|
|
19
|
+
|
|
20
|
+
const ROLE = "CTAs and links only — never large background fills";
|
|
21
|
+
|
|
22
|
+
function write(doc: ThemeDocument, set: Record<string, unknown>): ThemeDocument {
|
|
23
|
+
const n = normalizeTokens(set);
|
|
24
|
+
return applyDtcgWrites(doc, n.set, n.types, n.descriptions);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("token roles ride with the value (issue #430)", () => {
|
|
28
|
+
it("lifts $description off the envelope onto the canonical path", () => {
|
|
29
|
+
const n = normalizeTokens({
|
|
30
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: ROLE },
|
|
31
|
+
});
|
|
32
|
+
expect(n.descriptions["color.primary"]).toBe(ROLE);
|
|
33
|
+
// The envelope is still unwrapped for the value itself.
|
|
34
|
+
expect(n.set["color.primary"]).toBe("#4f46e5");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stores the role on the leaf and keeps the document valid", () => {
|
|
38
|
+
const doc = write(
|
|
39
|
+
{},
|
|
40
|
+
{
|
|
41
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: ROLE },
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
expect((doc.color as Record<string, { $description?: string }>).primary.$description).toBe(
|
|
45
|
+
ROLE,
|
|
46
|
+
);
|
|
47
|
+
expect(() => validateThemeTokens(doc)).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("PRESERVES the role across a later value-only edit", () => {
|
|
51
|
+
let doc = write(
|
|
52
|
+
{},
|
|
53
|
+
{
|
|
54
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: ROLE },
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
doc = write(doc, { primaryColor: "#ff6600" });
|
|
58
|
+
const leaf = (doc.color as Record<string, { $value: unknown; $description?: string }>).primary;
|
|
59
|
+
expect(leaf.$value).toBe("#ff6600");
|
|
60
|
+
expect(leaf.$description).toBe(ROLE);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("lets an explicit new role overwrite the old one", () => {
|
|
64
|
+
let doc = write(
|
|
65
|
+
{},
|
|
66
|
+
{
|
|
67
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: ROLE },
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
doc = write(doc, {
|
|
71
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: "Buttons only" },
|
|
72
|
+
});
|
|
73
|
+
expect((doc.color as Record<string, { $description?: string }>).primary.$description).toBe(
|
|
74
|
+
"Buttons only",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("maps roles onto the CSS var names module CSS actually references", () => {
|
|
79
|
+
const doc = write(
|
|
80
|
+
{},
|
|
81
|
+
{
|
|
82
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: ROLE },
|
|
83
|
+
"color.border": "#e5e5e5",
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const roles = listTokenRoles(doc);
|
|
87
|
+
expect(roles["--color-primary"]).toBe(ROLE);
|
|
88
|
+
// A token with no recorded role contributes nothing — no invented text.
|
|
89
|
+
expect(roles["--color-border"]).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("gives every var a typography composite emits the same role", () => {
|
|
93
|
+
const doc = write(
|
|
94
|
+
{},
|
|
95
|
+
{
|
|
96
|
+
"typography.heading": {
|
|
97
|
+
$type: "typography",
|
|
98
|
+
$value: { fontFamily: "Poppins, sans-serif", fontSize: "2rem" },
|
|
99
|
+
$description: "Section headings only",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const roles = listTokenRoles(doc);
|
|
104
|
+
expect(roles["--font-heading"]).toBe("Section headings only");
|
|
105
|
+
expect(roles["--text-heading"]).toBe("Section headings only");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores a blank role rather than storing an empty string", () => {
|
|
109
|
+
const n = normalizeTokens({
|
|
110
|
+
"color.primary": { $type: "color", $value: "#4f46e5", $description: " " },
|
|
111
|
+
});
|
|
112
|
+
expect(n.descriptions["color.primary"]).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The anchor-page design review annotates tokens whose values are already
|
|
118
|
+
* settled. Restating each value just to attach a role is both a round-trip
|
|
119
|
+
* (read the document first) and a chance to drift one, so a `$value`-less
|
|
120
|
+
* envelope patches the metadata alone.
|
|
121
|
+
*/
|
|
122
|
+
describe("role-only patches (issue #430)", () => {
|
|
123
|
+
const DOC: ThemeDocument = {
|
|
124
|
+
color: { primary: { $type: "color", $value: "#4f46e5" } },
|
|
125
|
+
typography: {
|
|
126
|
+
heading: { $type: "typography", $value: { fontFamily: "Poppins, sans-serif" } },
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
it("sets the role and leaves $value and $type untouched", () => {
|
|
131
|
+
const doc = write(DOC, { "color.primary": { $description: ROLE } });
|
|
132
|
+
const leaf = (doc.color as Record<string, Record<string, unknown>>).primary;
|
|
133
|
+
expect(leaf.$value).toBe("#4f46e5");
|
|
134
|
+
expect(leaf.$type).toBe("color");
|
|
135
|
+
expect(leaf.$description).toBe(ROLE);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts the CSS-var form the AI reads off module CSS", () => {
|
|
139
|
+
const doc = write(DOC, { "--color-primary": { $description: ROLE } });
|
|
140
|
+
expect((doc.color as Record<string, Record<string, unknown>>).primary.$description).toBe(ROLE);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("maps --font-<name> onto the typography composite", () => {
|
|
144
|
+
const doc = write(DOC, { "--font-heading": { $description: "Section headings only" } });
|
|
145
|
+
const leaf = (doc.typography as Record<string, Record<string, unknown>>).heading;
|
|
146
|
+
expect(leaf.$description).toBe("Section headings only");
|
|
147
|
+
expect(leaf.$value).toEqual({ fontFamily: "Poppins, sans-serif" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("annotates several tokens in ONE call", () => {
|
|
151
|
+
const doc = write(DOC, {
|
|
152
|
+
"color.primary": { $description: ROLE },
|
|
153
|
+
"typography.heading": { $description: "Section headings only" },
|
|
154
|
+
});
|
|
155
|
+
expect((doc.color as Record<string, Record<string, unknown>>).primary.$description).toBe(ROLE);
|
|
156
|
+
expect((doc.typography as Record<string, Record<string, unknown>>).heading.$description).toBe(
|
|
157
|
+
"Section headings only",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects a loose name — category is unresolvable without a value", () => {
|
|
162
|
+
expect(() => write(DOC, { primaryColor: { $description: ROLE } })).toThrow(/UnknownTokenName/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects annotating a token that does not exist", () => {
|
|
166
|
+
expect(() => write(DOC, { "color.nonexistent": { $description: ROLE } })).toThrow(
|
|
167
|
+
/UnknownTokenName/,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("a blank role-only patch writes nothing", () => {
|
|
172
|
+
const n = normalizeTokens({ "color.primary": { $description: " " } });
|
|
173
|
+
expect(n.canonicalPaths).toHaveLength(0);
|
|
174
|
+
expect(n.descriptions).toEqual({});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("still validates as a DTCG document afterwards", () => {
|
|
178
|
+
expect(() =>
|
|
179
|
+
validateThemeTokens(write(DOC, { "color.primary": { $description: ROLE } })),
|
|
180
|
+
).not.toThrow();
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/themes.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { isUnsafeKey } from "./safe-keys.js";
|
|
27
|
+
import { UnknownTokenName } from "./themes-errors.js";
|
|
27
28
|
|
|
28
29
|
// ────────────────────────────────────────────────────────────────────
|
|
29
30
|
// Primitives
|
|
@@ -117,6 +118,7 @@ export const themeTypographyComposite = z
|
|
|
117
118
|
z
|
|
118
119
|
.object({
|
|
119
120
|
fontFamily: z.string().min(1).max(200).optional(),
|
|
121
|
+
fontStyle: z.enum(["normal", "italic"]).optional(),
|
|
120
122
|
fontSize: dimensionValueString.optional(),
|
|
121
123
|
fontWeight: fontWeightValue.optional(),
|
|
122
124
|
lineHeight: z.union([z.number().positive(), dimensionValueString]).optional(),
|
|
@@ -728,11 +730,18 @@ function pluralise(category: string, n: number): string {
|
|
|
728
730
|
* use this same logic — extracted so the dotted-path merge lives in
|
|
729
731
|
* one place and v0.11.1's OKLCH auto-ramp can extend it without
|
|
730
732
|
* forking.
|
|
733
|
+
*
|
|
734
|
+
* issue #430 — `$description` (the token's ROLE: what it is for and
|
|
735
|
+
* where it must not be used) is preserved across value-only edits and
|
|
736
|
+
* overwritten only when `descriptions` supplies a new one. Before this,
|
|
737
|
+
* every routine value edit silently erased the role, so recording a
|
|
738
|
+
* role at all was pointless: the next `set_theme_tokens` wiped it.
|
|
731
739
|
*/
|
|
732
740
|
export function applyDtcgWrites(
|
|
733
741
|
current: ThemeDocument,
|
|
734
742
|
writes: Record<string, unknown>,
|
|
735
743
|
types: Record<string, string>,
|
|
744
|
+
descriptions: Record<string, string> = {},
|
|
736
745
|
): ThemeDocument {
|
|
737
746
|
const out: ThemeDocument = JSON.parse(JSON.stringify(current));
|
|
738
747
|
for (const [path, value] of Object.entries(writes)) {
|
|
@@ -759,9 +768,53 @@ export function applyDtcgWrites(
|
|
|
759
768
|
...(value as Record<string, unknown>),
|
|
760
769
|
};
|
|
761
770
|
}
|
|
771
|
+
// Role precedence: an explicitly supplied description wins; otherwise
|
|
772
|
+
// the leaf keeps the one it already carries. Only a token that never
|
|
773
|
+
// had a role ends up without one.
|
|
774
|
+
const existingDescription =
|
|
775
|
+
existing && typeof existing === "object"
|
|
776
|
+
? (existing as { $description?: unknown }).$description
|
|
777
|
+
: undefined;
|
|
778
|
+
const nextDescription =
|
|
779
|
+
descriptions[path] ??
|
|
780
|
+
(typeof existingDescription === "string" ? existingDescription : undefined);
|
|
781
|
+
const extensions =
|
|
782
|
+
existing && typeof existing === "object"
|
|
783
|
+
? { ...((existing as { $extensions?: Record<string, unknown> }).$extensions ?? {}) }
|
|
784
|
+
: {};
|
|
785
|
+
const oldValue = (existing as { $value?: { fontFamily?: string } } | undefined)?.$value;
|
|
786
|
+
if (
|
|
787
|
+
path.startsWith("typography.") &&
|
|
788
|
+
nextValue &&
|
|
789
|
+
typeof nextValue === "object" &&
|
|
790
|
+
(nextValue as { fontFamily?: string }).fontFamily !== oldValue?.fontFamily
|
|
791
|
+
)
|
|
792
|
+
delete extensions["caelo.font"];
|
|
762
793
|
setLeafAtPath(out, path, {
|
|
794
|
+
...(Object.keys(extensions).length ? { $extensions: extensions } : {}),
|
|
763
795
|
$value: nextValue,
|
|
764
796
|
...(inferredType ? { $type: inferredType } : {}),
|
|
797
|
+
...(nextDescription !== undefined ? { $description: nextDescription } : {}),
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// issue #430 — ROLE-ONLY paths: a description with no value in `writes`.
|
|
802
|
+
// The anchor-page design review annotates tokens whose values are already
|
|
803
|
+
// settled, so it must not have to restate them (a restated value is a
|
|
804
|
+
// chance to drift one). Patch the metadata, leave `$value` and `$type`
|
|
805
|
+
// exactly as they are.
|
|
806
|
+
for (const [path, description] of Object.entries(descriptions)) {
|
|
807
|
+
if (path in writes) continue;
|
|
808
|
+
const existing = readLeafAtPath(out, path);
|
|
809
|
+
if (!existing) {
|
|
810
|
+
// Annotating a token that does not exist is a mistake worth naming —
|
|
811
|
+
// silently minting a value-less leaf would produce a document the
|
|
812
|
+
// renderer can't emit (CLAUDE.md §2).
|
|
813
|
+
throw new UnknownTokenName(path, []);
|
|
814
|
+
}
|
|
815
|
+
setLeafAtPath(out, path, {
|
|
816
|
+
...(existing as Record<string, unknown>),
|
|
817
|
+
$description: description,
|
|
765
818
|
});
|
|
766
819
|
}
|
|
767
820
|
return out;
|
package/src/url.test.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { isDesignatedHomePage, isHomeSlug } from "./url.js";
|
|
5
|
+
|
|
6
|
+
describe("isHomeSlug", () => {
|
|
7
|
+
it("recognises the magic-slug sentinels regardless of surrounding slashes", () => {
|
|
8
|
+
for (const s of ["", "home", "index", "/", "/home/", "/index"]) {
|
|
9
|
+
expect(isHomeSlug(s)).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("rejects ordinary slugs", () => {
|
|
14
|
+
for (const s of ["about", "homepage", "index2", "blog/home"]) {
|
|
15
|
+
expect(isHomeSlug(s)).toBe(false);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("isDesignatedHomePage (0184 shared predicate)", () => {
|
|
21
|
+
const PAGE = "11111111-1111-1111-1111-111111111111";
|
|
22
|
+
const OTHER = "22222222-2222-2222-2222-222222222222";
|
|
23
|
+
|
|
24
|
+
it("explicit designation wins regardless of slug", () => {
|
|
25
|
+
expect(isDesignatedHomePage(PAGE, "welcome", PAGE)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("magic slug is the fallback when no designation exists", () => {
|
|
29
|
+
expect(isDesignatedHomePage(PAGE, "home", null)).toBe(true);
|
|
30
|
+
expect(isDesignatedHomePage(PAGE, "home", undefined)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("a designation pointing at ANOTHER page does not claim this one", () => {
|
|
34
|
+
expect(isDesignatedHomePage(PAGE, "welcome", OTHER)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("plain page, no designation: not home", () => {
|
|
38
|
+
expect(isDesignatedHomePage(PAGE, "about", null)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|