@caelo-cms/shared 0.10.24 → 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 (110) hide show
  1. package/dist/ai-tools.d.ts +15 -10
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +13 -15
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +0 -2
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +2 -3
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +0 -3
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +91 -7
  12. package/dist/content.js.map +1 -1
  13. package/dist/context.d.ts +6 -0
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/font-assets.d.ts +89 -0
  16. package/dist/font-assets.d.ts.map +1 -0
  17. package/dist/font-assets.js +61 -0
  18. package/dist/font-assets.js.map +1 -0
  19. package/dist/google-models.d.ts +35 -0
  20. package/dist/google-models.d.ts.map +1 -0
  21. package/dist/google-models.js +20 -0
  22. package/dist/google-models.js.map +1 -0
  23. package/dist/index.d.ts +4 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/media.d.ts +0 -3
  28. package/dist/media.d.ts.map +1 -1
  29. package/dist/media.js +0 -5
  30. package/dist/media.js.map +1 -1
  31. package/dist/plugin-preview.d.ts +44 -0
  32. package/dist/plugin-preview.d.ts.map +1 -0
  33. package/dist/plugin-preview.js +69 -0
  34. package/dist/plugin-preview.js.map +1 -0
  35. package/dist/preview-compose.d.ts +26 -0
  36. package/dist/preview-compose.d.ts.map +1 -1
  37. package/dist/preview-compose.js +92 -46
  38. package/dist/preview-compose.js.map +1 -1
  39. package/dist/responsive-images.d.ts +6 -5
  40. package/dist/responsive-images.d.ts.map +1 -1
  41. package/dist/responsive-images.js +6 -5
  42. package/dist/responsive-images.js.map +1 -1
  43. package/dist/seo.d.ts +10 -14
  44. package/dist/seo.d.ts.map +1 -1
  45. package/dist/seo.js +11 -12
  46. package/dist/seo.js.map +1 -1
  47. package/dist/template-engine.d.ts +18 -0
  48. package/dist/template-engine.d.ts.map +1 -1
  49. package/dist/template-engine.js +39 -2
  50. package/dist/template-engine.js.map +1 -1
  51. package/dist/theme-importers/loose.js +1 -1
  52. package/dist/theme-importers/loose.js.map +1 -1
  53. package/dist/theme-normalize.d.ts +9 -0
  54. package/dist/theme-normalize.d.ts.map +1 -1
  55. package/dist/theme-normalize.js +65 -6
  56. package/dist/theme-normalize.js.map +1 -1
  57. package/dist/theme-render.d.ts +15 -0
  58. package/dist/theme-render.d.ts.map +1 -1
  59. package/dist/theme-render.js +39 -0
  60. package/dist/theme-render.js.map +1 -1
  61. package/dist/themes.d.ts +15 -1
  62. package/dist/themes.d.ts.map +1 -1
  63. package/dist/themes.js +48 -1
  64. package/dist/themes.js.map +1 -1
  65. package/dist/url.d.ts +45 -0
  66. package/dist/url.d.ts.map +1 -0
  67. package/dist/url.js +64 -0
  68. package/dist/url.js.map +1 -0
  69. package/dist/version.d.ts +2 -2
  70. package/dist/version.js +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/redos-hardening.test.ts +1 -1
  73. package/src/ai-tools.ts +13 -15
  74. package/src/build-page.ts +3 -5
  75. package/src/content.test.ts +99 -17
  76. package/src/content.ts +95 -8
  77. package/src/context.ts +6 -0
  78. package/src/font-assets.ts +63 -0
  79. package/src/google-models.ts +20 -0
  80. package/src/index.ts +4 -2
  81. package/src/media.ts +0 -6
  82. package/src/plugin-preview.test.ts +79 -0
  83. package/src/plugin-preview.ts +81 -0
  84. package/src/preview-compose-deferrals.test.ts +144 -0
  85. package/src/preview-compose.test.ts +16 -0
  86. package/src/preview-compose.ts +125 -37
  87. package/src/responsive-images.ts +6 -5
  88. package/src/seo.test.ts +8 -8
  89. package/src/seo.ts +15 -23
  90. package/src/template-engine-data-lists.test.ts +90 -0
  91. package/src/template-engine.ts +72 -1
  92. package/src/theme-importers/loose.ts +1 -1
  93. package/src/theme-normalize.ts +70 -8
  94. package/src/theme-render.ts +36 -0
  95. package/src/theme-token-roles.test.ts +182 -0
  96. package/src/themes.ts +53 -0
  97. package/src/url.test.ts +40 -0
  98. package/src/url.ts +69 -0
  99. package/src/version.ts +1 -1
  100. package/dist/design-manifest.d.ts +0 -36
  101. package/dist/design-manifest.d.ts.map +0 -1
  102. package/dist/design-manifest.js +0 -90
  103. package/dist/design-manifest.js.map +0 -1
  104. package/dist/i18n.d.ts +0 -42
  105. package/dist/i18n.d.ts.map +0 -1
  106. package/dist/i18n.js +0 -85
  107. package/dist/i18n.js.map +0 -1
  108. package/src/design-manifest.ts +0 -93
  109. package/src/i18n.test.ts +0 -58
  110. package/src/i18n.ts +0 -91
package/src/seo.test.ts CHANGED
@@ -16,17 +16,17 @@ describe("resolveCanonicalUrl", () => {
16
16
  expect(
17
17
  resolveCanonicalUrl({
18
18
  siteBaseUrl: "https://example.com",
19
- pageSlug: "anything",
19
+ pagePath: "/anything",
20
20
  override: "https://canonical.example.com/x",
21
21
  }),
22
22
  ).toBe("https://canonical.example.com/x");
23
23
  });
24
24
 
25
- it("renders home as the root path", () => {
25
+ it("renders the composed root path as the bare base URL", () => {
26
26
  expect(
27
27
  resolveCanonicalUrl({
28
28
  siteBaseUrl: "https://example.com",
29
- pageSlug: "home",
29
+ pagePath: "/",
30
30
  override: null,
31
31
  }),
32
32
  ).toBe("https://example.com/");
@@ -36,7 +36,7 @@ describe("resolveCanonicalUrl", () => {
36
36
  expect(
37
37
  resolveCanonicalUrl({
38
38
  siteBaseUrl: "https://example.com/",
39
- pageSlug: "about",
39
+ pagePath: "/about",
40
40
  override: null,
41
41
  }),
42
42
  ).toBe("https://example.com/about/");
@@ -47,18 +47,18 @@ describe("resolveCanonicalUrl", () => {
47
47
  expect(
48
48
  resolveCanonicalUrl({
49
49
  siteBaseUrl: "https://example.com",
50
- pageSlug: "about",
50
+ pagePath: "/about",
51
51
  override: null,
52
52
  pageUrlStyle: "no-extension",
53
53
  }),
54
54
  ).toBe("https://example.com/about");
55
55
  });
56
56
 
57
- it("keeps the root URL for the home page", () => {
57
+ it("keeps the root URL for the composed root path", () => {
58
58
  expect(
59
59
  resolveCanonicalUrl({
60
60
  siteBaseUrl: "https://example.com",
61
- pageSlug: "home",
61
+ pagePath: "/",
62
62
  override: null,
63
63
  pageUrlStyle: "no-extension",
64
64
  }),
@@ -69,7 +69,7 @@ describe("resolveCanonicalUrl", () => {
69
69
  expect(
70
70
  resolveCanonicalUrl({
71
71
  siteBaseUrl: "https://example.com",
72
- pageSlug: "about",
72
+ pagePath: "/about",
73
73
  override: null,
74
74
  }),
75
75
  ).toBe("https://example.com/about/");
package/src/seo.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { z } from "zod";
11
- import { isHomeSlug } from "./i18n.js";
11
+ import { trimSlashes } from "./url.js";
12
12
 
13
13
  export const CHANGEFREQ_VALUES = [
14
14
  "always",
@@ -108,39 +108,31 @@ export interface SiteSeoSettings {
108
108
 
109
109
  /**
110
110
  * Resolve the canonical URL for a page. If `pages_seo.canonical_url`
111
- * is set it wins; otherwise build `<siteBaseUrl>/<slug>` (home pages
112
- * canonicalize to the bare base URL). URL shaping beyond base + slug
113
- * becomes a plugin contribution on the URL composition point (#390).
111
+ * is set it wins; otherwise `<siteBaseUrl><pagePath>` — where
112
+ * `pagePath` is the COMPOSED public path from `pages.current_path`
113
+ * (#390: the URL composition point materializes prefixes, slug
114
+ * formats, and the home designation into that one column; canonical
115
+ * simply follows it).
114
116
  */
115
117
  export function resolveCanonicalUrl(args: {
116
118
  siteBaseUrl: string;
117
- pageSlug: string;
119
+ /** The page's composed path (`pages.current_path`): leading slash,
120
+ * "/" for the site root. */
121
+ pagePath: string;
118
122
  override: string | null;
119
123
  /**
120
124
  * v0.2.85 — page emission style. 'directory' (default) → URLs end
121
- * in `/<slug>/`; 'no-extension' → URLs end in `/<slug>` (no
122
- * trailing slash) to match what the bucket actually serves when
123
- * pages are emitted as bare slugs.
125
+ * in `/…/`; 'no-extension' → no trailing slash, matching what the
126
+ * bucket serves when pages are emitted as bare files.
124
127
  */
125
128
  pageUrlStyle?: "directory" | "no-extension";
126
- /**
127
- * 0184 — explicit homepage designation. When true this page IS the
128
- * site root and canonicalizes to `<base>/` regardless of its slug.
129
- * Uses the same predicate (`isHomeSlug`) as the magic-slug fallback
130
- * so canonical agrees with the emitted output path.
131
- */
132
- isHomePage?: boolean;
133
129
  }): string {
134
130
  if (args.override && args.override.length > 0) return args.override;
135
- const base = args.siteBaseUrl.replace(/\/$/, "");
136
- const isHome = args.isHomePage === true || isHomeSlug(args.pageSlug);
137
- const cleanSlug = isHome ? "" : args.pageSlug;
131
+ const base = args.siteBaseUrl.endsWith("/") ? args.siteBaseUrl.slice(0, -1) : args.siteBaseUrl;
132
+ const trimmed = trimSlashes(args.pagePath);
133
+ if (trimmed.length === 0) return `${base}/`;
138
134
  const style = args.pageUrlStyle ?? "directory";
139
- // Tail = the slug portion of the canonical URL, with the trailing
140
- // shape dictated by the page-emission style. Home pages always
141
- // resolve to the base URL (no tail) regardless of style.
142
- const tail = cleanSlug ? (style === "no-extension" ? cleanSlug : `${cleanSlug}/`) : "";
143
- return tail ? `${base}/${tail}` : `${base}/`;
135
+ return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
144
136
  }
145
137
 
146
138
  export interface SeoMetaInput {
@@ -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
+ });