@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.
Files changed (137) hide show
  1. package/dist/ai-tools.d.ts +15 -70
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +14 -74
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/base-css.d.ts +11 -0
  6. package/dist/base-css.d.ts.map +1 -1
  7. package/dist/base-css.js +13 -1
  8. package/dist/base-css.js.map +1 -1
  9. package/dist/build-page.d.ts +0 -2
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +2 -3
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +0 -3
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +91 -7
  16. package/dist/content.js.map +1 -1
  17. package/dist/context.d.ts +6 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/design-draft-shell.d.ts +21 -0
  20. package/dist/design-draft-shell.d.ts.map +1 -0
  21. package/dist/design-draft-shell.js +81 -0
  22. package/dist/design-draft-shell.js.map +1 -0
  23. package/dist/font-assets.d.ts +89 -0
  24. package/dist/font-assets.d.ts.map +1 -0
  25. package/dist/font-assets.js +61 -0
  26. package/dist/font-assets.js.map +1 -0
  27. package/dist/genesis.d.ts +43 -3
  28. package/dist/genesis.d.ts.map +1 -1
  29. package/dist/genesis.js +72 -5
  30. package/dist/genesis.js.map +1 -1
  31. package/dist/google-models.d.ts +35 -0
  32. package/dist/google-models.d.ts.map +1 -0
  33. package/dist/google-models.js +20 -0
  34. package/dist/google-models.js.map +1 -0
  35. package/dist/index.d.ts +5 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/media.d.ts +0 -3
  40. package/dist/media.d.ts.map +1 -1
  41. package/dist/media.js +0 -5
  42. package/dist/media.js.map +1 -1
  43. package/dist/plugin-preview.d.ts +44 -0
  44. package/dist/plugin-preview.d.ts.map +1 -0
  45. package/dist/plugin-preview.js +69 -0
  46. package/dist/plugin-preview.js.map +1 -0
  47. package/dist/preview-compose.d.ts +35 -15
  48. package/dist/preview-compose.d.ts.map +1 -1
  49. package/dist/preview-compose.js +95 -89
  50. package/dist/preview-compose.js.map +1 -1
  51. package/dist/responsive-images.d.ts +6 -5
  52. package/dist/responsive-images.d.ts.map +1 -1
  53. package/dist/responsive-images.js +6 -5
  54. package/dist/responsive-images.js.map +1 -1
  55. package/dist/seo.d.ts +11 -28
  56. package/dist/seo.d.ts.map +1 -1
  57. package/dist/seo.js +12 -31
  58. package/dist/seo.js.map +1 -1
  59. package/dist/structured-sets.d.ts +0 -36
  60. package/dist/structured-sets.d.ts.map +1 -1
  61. package/dist/structured-sets.js +1 -52
  62. package/dist/structured-sets.js.map +1 -1
  63. package/dist/template-engine.d.ts +18 -0
  64. package/dist/template-engine.d.ts.map +1 -1
  65. package/dist/template-engine.js +39 -2
  66. package/dist/template-engine.js.map +1 -1
  67. package/dist/theme-importers/loose.js +1 -1
  68. package/dist/theme-importers/loose.js.map +1 -1
  69. package/dist/theme-normalize.d.ts +9 -0
  70. package/dist/theme-normalize.d.ts.map +1 -1
  71. package/dist/theme-normalize.js +65 -6
  72. package/dist/theme-normalize.js.map +1 -1
  73. package/dist/theme-render.d.ts +15 -0
  74. package/dist/theme-render.d.ts.map +1 -1
  75. package/dist/theme-render.js +39 -0
  76. package/dist/theme-render.js.map +1 -1
  77. package/dist/themes.d.ts +15 -1
  78. package/dist/themes.d.ts.map +1 -1
  79. package/dist/themes.js +48 -1
  80. package/dist/themes.js.map +1 -1
  81. package/dist/url.d.ts +45 -0
  82. package/dist/url.d.ts.map +1 -0
  83. package/dist/url.js +64 -0
  84. package/dist/url.js.map +1 -0
  85. package/dist/version.d.ts +2 -2
  86. package/dist/version.js +1 -1
  87. package/package.json +2 -2
  88. package/src/__tests__/redos-hardening.test.ts +1 -1
  89. package/src/ai-tools.ts +14 -90
  90. package/src/base-css.ts +13 -1
  91. package/src/build-page.ts +3 -5
  92. package/src/content.test.ts +99 -17
  93. package/src/content.ts +95 -8
  94. package/src/context.ts +6 -0
  95. package/src/design-draft-shell.test.ts +85 -0
  96. package/src/design-draft-shell.ts +109 -0
  97. package/src/font-assets.ts +63 -0
  98. package/src/genesis.ts +76 -5
  99. package/src/google-models.ts +20 -0
  100. package/src/index.ts +5 -3
  101. package/src/media.ts +0 -6
  102. package/src/plugin-preview.test.ts +79 -0
  103. package/src/plugin-preview.ts +81 -0
  104. package/src/preview-compose-deferrals.test.ts +144 -0
  105. package/src/preview-compose.test.ts +51 -0
  106. package/src/preview-compose.ts +128 -93
  107. package/src/responsive-images.ts +6 -5
  108. package/src/seo.test.ts +8 -48
  109. package/src/seo.ts +16 -52
  110. package/src/structured-sets.ts +1 -67
  111. package/src/template-engine-data-lists.test.ts +90 -0
  112. package/src/template-engine.ts +72 -1
  113. package/src/theme-importers/loose.ts +1 -1
  114. package/src/theme-normalize.ts +70 -8
  115. package/src/theme-render.ts +36 -0
  116. package/src/theme-token-roles.test.ts +182 -0
  117. package/src/themes.ts +53 -0
  118. package/src/url.test.ts +40 -0
  119. package/src/url.ts +69 -0
  120. package/src/version.ts +1 -1
  121. package/dist/design-manifest.d.ts +0 -36
  122. package/dist/design-manifest.d.ts.map +0 -1
  123. package/dist/design-manifest.js +0 -90
  124. package/dist/design-manifest.js.map +0 -1
  125. package/dist/i18n.d.ts +0 -92
  126. package/dist/i18n.d.ts.map +0 -1
  127. package/dist/i18n.js +0 -226
  128. package/dist/i18n.js.map +0 -1
  129. package/dist/translation.d.ts +0 -127
  130. package/dist/translation.d.ts.map +0 -1
  131. package/dist/translation.js +0 -208
  132. package/dist/translation.js.map +0 -1
  133. package/src/design-manifest.ts +0 -93
  134. package/src/i18n.test.ts +0 -274
  135. package/src/i18n.ts +0 -269
  136. package/src/translation.test.ts +0 -160
  137. 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
+ });
@@ -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(match, name, inner, fieldByName, cvs, partials, missing, mkSentinel),
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
 
@@ -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
- if (
239
- rawValue !== null &&
240
- typeof rawValue === "object" &&
241
- !Array.isArray(rawValue) &&
242
- "$value" in (rawValue as Record<string, unknown>)
243
- ) {
244
- rawValue = (rawValue as Record<string, unknown>).$value;
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
@@ -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;
@@ -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
+ });