@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
@@ -34,7 +34,6 @@ import {
34
34
  listSlotNames,
35
35
  } from "./preview-scanner.js";
36
36
  import { stripCdataGuards } from "./strip-cdata.js";
37
- import { type LanguageSelectorOverride, renderLanguageSelector } from "./structured-sets.js";
38
37
  import { renderTemplate, type TemplateField } from "./template-engine.js";
39
38
  import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
40
39
  import type { ThemeDocument } from "./themes.js";
@@ -90,21 +89,6 @@ export interface ComposeStructuredSets {
90
89
  readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
91
90
  }
92
91
 
93
- /**
94
- * P9 — language-selector context. The caller (preview op + static
95
- * generator) resolves the current page's per-locale URLs once and
96
- * threads them in. The composer renders the selector when a module
97
- * slug starts with `language-selector-`.
98
- */
99
- export interface ComposeLanguageSelector {
100
- readonly availableLocales: ReadonlyArray<{
101
- code: string;
102
- displayName: string;
103
- href: string;
104
- isCurrent: boolean;
105
- }>;
106
- }
107
-
108
92
  /**
109
93
  * v0.11.0 — Theme context resolved by the preview op + static generator
110
94
  * (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
@@ -147,7 +131,6 @@ export interface ComposeInput {
147
131
  readonly templateCss: string;
148
132
  readonly blocks: readonly ComposeBlock[];
149
133
  readonly structuredSets?: ComposeStructuredSets;
150
- readonly languageSelector?: ComposeLanguageSelector;
151
134
  /**
152
135
  * v0.11.0 — active theme threaded through from the preview op + static
153
136
  * generator. Undefined when no theme row exists OR when the caller
@@ -157,6 +140,21 @@ export interface ComposeInput {
157
140
  readonly theme?: ComposeTheme;
158
141
  /** issue #150 — resolved web fonts; undefined = system stacks only. */
159
142
  readonly fonts?: ComposeFonts;
143
+ /**
144
+ * Plugin-provided lists for the page being composed, plus the names
145
+ * declared by installed-but-inactive plugins. Threaded so a module
146
+ * iterating `{{#language_links}}` renders the same on deploy as in
147
+ * the editor — including the loud marker when its plugin is off.
148
+ */
149
+ readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
150
+ readonly dormantDataLists?: Readonly<Record<string, string>>;
151
+ /**
152
+ * #450 — modules withheld by a plugin, keyed by module id. Resolved
153
+ * by the host before composing; absent means the module renders
154
+ * normally, which is the case for every module on a site with no
155
+ * gating plugin active.
156
+ */
157
+ readonly deferredModules?: Readonly<Record<string, ComposeDeferral>>;
160
158
  }
161
159
 
162
160
  export interface ComposeOutput {
@@ -180,11 +178,16 @@ function injectBefore(source: string, marker: RegExp, fragment: string): string
180
178
  * `crossorigin` attribute is REQUIRED for font preloads even same-origin,
181
179
  * per the fetch spec's font-destination CORS rule) + the @font-face
182
180
  * block. Empty css with no preloads → null (system-stack-only theme).
181
+ * Exported for the design-draft theme shell (#375) so draft previews
182
+ * load the identical fonts as the page preview.
183
183
  */
184
- function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
184
+ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
185
185
  if (fonts === undefined) return null;
186
186
  const links = fonts.preloads
187
- .map((href) => `<link rel="preload" as="font" type="font/woff2" crossorigin href="${href}">`)
187
+ .map((href) => {
188
+ const format = /\.(woff2?|ttf|otf)(?:[?#]|$)/.exec(href)?.[1];
189
+ return `<link rel="preload" as="font"${format ? ` type="font/${format}"` : ""} crossorigin href="${href}">`;
190
+ })
188
191
  .join("");
189
192
  const style =
190
193
  fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
@@ -193,6 +196,13 @@ function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
193
196
  }
194
197
 
195
198
  export function composePagePreview(input: ComposeInput): ComposeOutput {
199
+ // No withholding path here; rendering a withheld module would ship it
200
+ // ungated, so refuse instead of degrading silently (CLAUDE.md §2).
201
+ if (input.deferredModules && Object.keys(input.deferredModules).length > 0) {
202
+ throw new Error(
203
+ "composePagePreview cannot withhold modules; compose pages with deferrals through composePageWithLayout",
204
+ );
205
+ }
196
206
  const contentByName = new Map<string, string>();
197
207
  const allCss: string[] = [];
198
208
  const allJs: string[] = [];
@@ -214,15 +224,12 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
214
224
  // module HTML.
215
225
  const renderedModuleHtml = block.modules.map((m) => {
216
226
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
217
- const langSelector = lookupLanguageSelector(m.slug, input);
218
227
  let baseHtml: string;
219
228
  if (navMenuItems !== null) {
220
229
  navRendered = true;
221
230
  baseHtml = renderNavMenuHtml(navMenuItems);
222
- } else if (langSelector !== null) {
223
- baseHtml = langSelector;
224
231
  } else {
225
- baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
232
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
226
233
  }
227
234
  return tagModuleId(baseHtml, m.moduleId);
228
235
  });
@@ -314,35 +321,6 @@ function lookupNavMenuItems(
314
321
  return items ?? null;
315
322
  }
316
323
 
317
- /**
318
- * P9 — return rendered language-selector HTML when a module's slug
319
- * starts with `language-selector-`. The set's items act as overrides
320
- * (relabel a locale, hide one); the available-locale list comes from
321
- * the caller via `input.languageSelector`. Returns null when the
322
- * module is not a language selector.
323
- *
324
- * Convention: a module slug `language-selector-header` resolves to
325
- * structuredSets[`language-selector/header`] for overrides, and the
326
- * rendered HTML lists every locale that has a published variant of
327
- * the current page.
328
- */
329
- function lookupLanguageSelector(moduleSlug: string, input: ComposeInput): string | null {
330
- const prefix = "language-selector-";
331
- if (!moduleSlug.startsWith(prefix)) return null;
332
- if (!input.languageSelector || input.languageSelector.availableLocales.length === 0) {
333
- return null;
334
- }
335
- const setSlug = moduleSlug.slice(prefix.length);
336
- const overridesUnknown = input.structuredSets?.byKindSlug[`language-selector/${setSlug}`];
337
- const overrides = Array.isArray(overridesUnknown)
338
- ? (overridesUnknown as LanguageSelectorOverride[])
339
- : undefined;
340
- return renderLanguageSelector({
341
- availableLocales: input.languageSelector.availableLocales,
342
- overrides,
343
- });
344
- }
345
-
346
324
  interface NavMenuItem {
347
325
  label: string;
348
326
  href: string;
@@ -436,11 +414,18 @@ function applyFieldSubstitution(
436
414
  fields: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[] | undefined,
437
415
  contentValues: Readonly<Record<string, unknown>> | undefined,
438
416
  theme: ComposeTheme | undefined,
417
+ lists: {
418
+ readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
419
+ readonly dormantDataLists?: Readonly<Record<string, string>>;
420
+ },
439
421
  ): string {
440
422
  // The substitution engine (renderTemplate) already unwraps CDATA
441
423
  // guards; cover the no-op early-return path so a chrome module with no
442
424
  // fields/values/theme is cleaned too.
443
- if (!fields && !contentValues && !theme) return stripCdataGuards(html);
425
+ const hasLists =
426
+ Object.keys(lists.dataLists ?? {}).length > 0 ||
427
+ Object.keys(lists.dormantDataLists ?? {}).length > 0;
428
+ if (!fields && !contentValues && !theme && !hasLists) return stripCdataGuards(html);
444
429
  const engineFields: TemplateField[] = (fields ?? []).map((f) => ({
445
430
  name: f.name,
446
431
  kind: f.kind ?? "text",
@@ -450,6 +435,8 @@ function applyFieldSubstitution(
450
435
  html,
451
436
  fields: engineFields,
452
437
  contentValues,
438
+ dataLists: lists.dataLists,
439
+ dormantDataLists: lists.dormantDataLists,
453
440
  // v0.11.1 (issue #76) — thread the active theme's asset URLs so
454
441
  // module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
455
442
  // slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
@@ -492,6 +479,58 @@ export function tagModuleId(html: string, moduleId: string): string {
492
479
  * static generator) surface it as a structured failure rather than
493
480
  * silently emitting broken HTML.
494
481
  */
482
+ /**
483
+ * A module withheld from this render, as resolved by the plugin host
484
+ * (#450). The composer never decides this — it only knows the verdict.
485
+ */
486
+ export interface ComposeDeferral {
487
+ readonly pluginSlug: string;
488
+ readonly reason: string;
489
+ readonly placeholderModuleSlug: string;
490
+ readonly placeholderHtml: string;
491
+ readonly placeholderCss: string;
492
+ }
493
+
494
+ /**
495
+ * Emit a withheld module: the visible placeholder, plus the real markup
496
+ * parked in an inert `<template>`.
497
+ *
498
+ * `<template>` is the whole point. Browsers parse its contents but
499
+ * instantiate nothing — no image, iframe, script or stylesheet inside
500
+ * one is ever fetched. So a video module behind a consent gate does not
501
+ * touch YouTube until the plugin's runtime clones the content out,
502
+ * which is a fact about the network rather than a promise about the
503
+ * DOM. Hiding the module with CSS or stripping attributes in script
504
+ * would both leave the request already sent.
505
+ *
506
+ * The module's CSS and JS go into the same `<template>` and NEVER into
507
+ * the page-wide bundles: a `url(https://maps.gstatic.com/…)` in its CSS
508
+ * or a `fetch()` in its JS reaches the vendor exactly as surely as an
509
+ * `<iframe src>` does. The JS is parked as `type="text/plain"` (inert
510
+ * even once cloned) and the plugin runtime executes it once per module,
511
+ * after the markup it expects is in the DOM.
512
+ */
513
+ function wrapDeferredModule(
514
+ moduleHtml: string,
515
+ module: Pick<ComposeModule, "moduleId" | "slug" | "css" | "js">,
516
+ deferral: ComposeDeferral,
517
+ ): string {
518
+ const attr = (v: string): string =>
519
+ v.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
520
+ const css =
521
+ module.css.trim().length > 0 ? `<style data-source="module">${module.css}</style>` : "";
522
+ const js =
523
+ module.js.trim().length > 0
524
+ ? `<script type="text/plain" data-caelo-deferred-script="${attr(module.moduleId)}">${module.js}</script>`
525
+ : "";
526
+ return [
527
+ `<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}">`,
528
+ `<div data-caelo-deferred-placeholder>${deferral.placeholderHtml}</div>`,
529
+ `<template data-caelo-deferred-content>${css}${moduleHtml}${js}</template>`,
530
+ `</div>`,
531
+ ].join("");
532
+ }
533
+
495
534
  export interface ComposeLayoutBlock {
496
535
  readonly blockName: string;
497
536
  readonly modules: readonly ComposeModule[];
@@ -584,34 +623,44 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
584
623
  // Duplicate rule blocks made the cascade order-dependent and bloated
585
624
  // every page the same module appeared on twice.
586
625
  const seenAssetModules = new Set<string>();
626
+ // Placeholder CSS, keyed by placeholder slug: one withheld module's
627
+ // placeholder used on five placements is emitted once.
628
+ const deferredCss = new Map<string, string>();
587
629
  if (input.layoutCss.trim().length > 0) cssParts.push(input.layoutCss);
588
630
  if (input.templateCss.trim().length > 0) cssParts.push(input.templateCss);
589
631
 
590
632
  // 1. Render the page modules into the template (slot replacement only;
591
633
  // no head/body manipulation here — that belongs to the layout).
592
634
  const templateContentByName = new Map<string, string>();
635
+ const renderPlaced = (m: ComposeModule): string => {
636
+ const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
637
+ let baseHtml: string;
638
+ if (navMenuItems !== null) {
639
+ navRendered = true;
640
+ baseHtml = renderNavMenuHtml(navMenuItems);
641
+ } else {
642
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
643
+ }
644
+ const tagged = tagModuleId(baseHtml, m.moduleId);
645
+ const deferral = input.deferredModules?.[m.moduleId];
646
+ if (!deferral) return tagged;
647
+ deferredCss.set(deferral.placeholderModuleSlug, deferral.placeholderCss);
648
+ return wrapDeferredModule(tagged, m, deferral);
649
+ };
650
+ // A withheld module's CSS/JS travel inside its <template> (see
651
+ // wrapDeferredModule); only modules that render normally feed the
652
+ // page-wide bundles.
653
+ const collectAssets = (m: ComposeModule): void => {
654
+ if (seenAssetModules.has(m.moduleId)) return;
655
+ seenAssetModules.add(m.moduleId);
656
+ if (input.deferredModules?.[m.moduleId]) return;
657
+ if (m.css.trim().length > 0) cssParts.push(m.css);
658
+ if (m.js.trim().length > 0) jsParts.push(m.js);
659
+ };
593
660
  for (const block of input.blocks) {
594
- const renderedModuleHtml = block.modules.map((m) => {
595
- const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
596
- const langSelector = lookupLanguageSelector(m.slug, input);
597
- let baseHtml: string;
598
- if (navMenuItems !== null) {
599
- navRendered = true;
600
- baseHtml = renderNavMenuHtml(navMenuItems);
601
- } else if (langSelector !== null) {
602
- baseHtml = langSelector;
603
- } else {
604
- baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
605
- }
606
- return tagModuleId(baseHtml, m.moduleId);
607
- });
661
+ const renderedModuleHtml = block.modules.map(renderPlaced);
608
662
  templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
609
- for (const m of block.modules) {
610
- if (seenAssetModules.has(m.moduleId)) continue;
611
- seenAssetModules.add(m.moduleId);
612
- if (m.css.trim().length > 0) cssParts.push(m.css);
613
- if (m.js.trim().length > 0) jsParts.push(m.js);
614
- }
663
+ for (const m of block.modules) collectAssets(m);
615
664
  }
616
665
  const renderedTemplate = applySlotReplacements(input.templateHtml, {
617
666
  contentByName: templateContentByName,
@@ -626,27 +675,13 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
626
675
  layoutContentByName.set("content", innerBody);
627
676
  for (const block of input.layoutBlocks) {
628
677
  if (block.blockName === "content") continue; // reserved for the page body
629
- const renderedModuleHtml = block.modules.map((m) => {
630
- const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
631
- const langSelector = lookupLanguageSelector(m.slug, input);
632
- let baseHtml: string;
633
- if (navMenuItems !== null) {
634
- navRendered = true;
635
- baseHtml = renderNavMenuHtml(navMenuItems);
636
- } else if (langSelector !== null) {
637
- baseHtml = langSelector;
638
- } else {
639
- baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
640
- }
641
- return tagModuleId(baseHtml, m.moduleId);
642
- });
678
+ const renderedModuleHtml = block.modules.map(renderPlaced);
643
679
  layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
644
- for (const m of block.modules) {
645
- if (seenAssetModules.has(m.moduleId)) continue;
646
- seenAssetModules.add(m.moduleId);
647
- if (m.css.trim().length > 0) cssParts.push(m.css);
648
- if (m.js.trim().length > 0) jsParts.push(m.js);
649
- }
680
+ for (const m of block.modules) collectAssets(m);
681
+ }
682
+
683
+ for (const css of deferredCss.values()) {
684
+ if (css.trim().length > 0) cssParts.push(css);
650
685
  }
651
686
 
652
687
  if (navRendered) {
@@ -37,11 +37,12 @@ export function variantFamily(variant: string): string {
37
37
  * Pick the variant tag AI-facing surfaces should hand out for an
38
38
  * asset, given the variant tags that ACTUALLY exist on it.
39
39
  *
40
- * Run #10 D4: `find_media` and the `## Media` system-prompt block
41
- * advertised `webp-800` for every raster mime, but the pipeline never
42
- * emits `webp-800` for sources narrower than 800px (no upscaling) or
43
- * for animated GIFs — the AI wrote those URLs into module HTML and the
44
- * static generator's media pass failed the whole staging build on
40
+ * Run #10 D4: `find_media` (and the since-removed `## Media`
41
+ * system-prompt block) advertised `webp-800` for every raster mime, but
42
+ * the pipeline never emits `webp-800` for sources narrower than 800px
43
+ * (no upscaling) or for animated GIFs — the AI wrote those URLs into
44
+ * module HTML and the static generator's media pass failed the whole
45
+ * staging build on
45
46
  * "asset/variant pairs missing". Advertising must be grounded in the
46
47
  * `media_variants` rows, not in the mime.
47
48
  *
package/src/seo.test.ts CHANGED
@@ -16,19 +16,17 @@ describe("resolveCanonicalUrl", () => {
16
16
  expect(
17
17
  resolveCanonicalUrl({
18
18
  siteBaseUrl: "https://example.com",
19
- pageSlug: "anything",
20
- pageLocale: "en",
19
+ pagePath: "/anything",
21
20
  override: "https://canonical.example.com/x",
22
21
  }),
23
22
  ).toBe("https://canonical.example.com/x");
24
23
  });
25
24
 
26
- it("renders home as the root path", () => {
25
+ it("renders the composed root path as the bare base URL", () => {
27
26
  expect(
28
27
  resolveCanonicalUrl({
29
28
  siteBaseUrl: "https://example.com",
30
- pageSlug: "home",
31
- pageLocale: "en",
29
+ pagePath: "/",
32
30
  override: null,
33
31
  }),
34
32
  ).toBe("https://example.com/");
@@ -38,8 +36,7 @@ describe("resolveCanonicalUrl", () => {
38
36
  expect(
39
37
  resolveCanonicalUrl({
40
38
  siteBaseUrl: "https://example.com/",
41
- pageSlug: "about",
42
- pageLocale: "en",
39
+ pagePath: "/about",
43
40
  override: null,
44
41
  }),
45
42
  ).toBe("https://example.com/about/");
@@ -50,50 +47,29 @@ describe("resolveCanonicalUrl", () => {
50
47
  expect(
51
48
  resolveCanonicalUrl({
52
49
  siteBaseUrl: "https://example.com",
53
- pageSlug: "about",
54
- pageLocale: "en",
50
+ pagePath: "/about",
55
51
  override: null,
56
52
  pageUrlStyle: "no-extension",
57
53
  }),
58
54
  ).toBe("https://example.com/about");
59
55
  });
60
56
 
61
- it("keeps the root URL for the home page", () => {
57
+ it("keeps the root URL for the composed root path", () => {
62
58
  expect(
63
59
  resolveCanonicalUrl({
64
60
  siteBaseUrl: "https://example.com",
65
- pageSlug: "home",
66
- pageLocale: "en",
61
+ pagePath: "/",
67
62
  override: null,
68
63
  pageUrlStyle: "no-extension",
69
64
  }),
70
65
  ).toBe("https://example.com/");
71
66
  });
72
67
 
73
- it("subdirectory locale strategy keeps the locale prefix, no trailing slash on slug", () => {
74
- expect(
75
- resolveCanonicalUrl({
76
- siteBaseUrl: "https://example.com",
77
- pageSlug: "about",
78
- pageLocale: "de",
79
- override: null,
80
- pageUrlStyle: "no-extension",
81
- localeConfig: {
82
- code: "de",
83
- urlStrategy: "subdirectory",
84
- urlHost: null,
85
- isDefault: false,
86
- },
87
- }),
88
- ).toBe("https://example.com/de/about");
89
- });
90
-
91
68
  it("default style preserves pre-v0.2.85 trailing-slash behavior", () => {
92
69
  expect(
93
70
  resolveCanonicalUrl({
94
71
  siteBaseUrl: "https://example.com",
95
- pageSlug: "about",
96
- pageLocale: "en",
72
+ pagePath: "/about",
97
73
  override: null,
98
74
  }),
99
75
  ).toBe("https://example.com/about/");
@@ -108,7 +84,6 @@ describe("renderSeoHead", () => {
108
84
  canonical: "https://example.com/",
109
85
  noindex: false,
110
86
  ogImageUrl: null,
111
- hreflang: [] as { locale: string; url: string }[],
112
87
  organization: {},
113
88
  };
114
89
 
@@ -136,21 +111,6 @@ describe("renderSeoHead", () => {
136
111
  expect(head).toContain('<meta name="twitter:card" content="summary_large_image" />');
137
112
  });
138
113
 
139
- it("emits hreflang per row + x-default when any rows are present", () => {
140
- const head = renderSeoHead({
141
- ...base,
142
- hreflang: [
143
- { locale: "de", url: "https://example.com/de/" },
144
- { locale: "fr", url: "https://example.com/fr/" },
145
- ],
146
- });
147
- expect(head).toContain('<link rel="alternate" hreflang="de" href="https://example.com/de/" />');
148
- expect(head).toContain('<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />');
149
- expect(head).toContain(
150
- '<link rel="alternate" hreflang="x-default" href="https://example.com/" />',
151
- );
152
- });
153
-
154
114
  it("emits a JSON-LD WebPage block; encodes the < character to avoid script-tag breaks", () => {
155
115
  const head = renderSeoHead({
156
116
  ...base,
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,58 +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>/<page-path>` from
112
- * the page's slug + locale. Locale strategy is single-locale (no
113
- * prefix) until P9 i18n.
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;
118
- pageLocale: string;
119
+ /** The page's composed path (`pages.current_path`): leading slash,
120
+ * "/" for the site root. */
121
+ pagePath: string;
119
122
  override: string | null;
120
- /** P9 — when present, the locale's url_strategy decides the URL shape. */
121
- localeConfig?: {
122
- code: string;
123
- urlStrategy: "none" | "subdirectory" | "subdomain" | "domain";
124
- urlHost: string | null;
125
- isDefault: boolean;
126
- };
127
123
  /**
128
124
  * v0.2.85 — page emission style. 'directory' (default) → URLs end
129
- * in `/<slug>/`; 'no-extension' → URLs end in `/<slug>` (no
130
- * trailing slash) to match what the bucket actually serves when
131
- * 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.
132
127
  */
133
128
  pageUrlStyle?: "directory" | "no-extension";
134
- /**
135
- * 0184 — explicit homepage designation (`locales.home_page_id`). When
136
- * true this page IS the locale root and canonicalizes to `<base>/`
137
- * regardless of its slug. Uses the same predicate (`isHomeSlug`) as
138
- * the magic-slug fallback so canonical agrees with hreflang + the
139
- * emitted output path.
140
- */
141
- isHomePage?: boolean;
142
129
  }): string {
143
130
  if (args.override && args.override.length > 0) return args.override;
144
- const base = args.siteBaseUrl.replace(/\/$/, "");
145
- const isHome = args.isHomePage === true || isHomeSlug(args.pageSlug);
146
- 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}/`;
147
134
  const style = args.pageUrlStyle ?? "directory";
148
- // Tail = the slug portion of the canonical URL, with the trailing
149
- // shape dictated by the page-emission style. Home pages always
150
- // resolve to the base URL (no tail) regardless of style.
151
- const tail = cleanSlug ? (style === "no-extension" ? cleanSlug : `${cleanSlug}/`) : "";
152
- const cfg = args.localeConfig;
153
- if (cfg && cfg.urlStrategy !== "none") {
154
- if (cfg.urlStrategy === "subdirectory") {
155
- return tail ? `${base}/${cfg.code}/${tail}` : `${base}/${cfg.code}/`;
156
- }
157
- if ((cfg.urlStrategy === "subdomain" || cfg.urlStrategy === "domain") && cfg.urlHost) {
158
- const protocol = base.startsWith("http://") ? "http://" : "https://";
159
- return tail ? `${protocol}${cfg.urlHost}/${tail}` : `${protocol}${cfg.urlHost}/`;
160
- }
161
- }
162
- return tail ? `${base}/${tail}` : `${base}/`;
135
+ return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
163
136
  }
164
137
 
165
138
  export interface SeoMetaInput {
@@ -168,7 +141,6 @@ export interface SeoMetaInput {
168
141
  canonical: string;
169
142
  noindex: boolean;
170
143
  ogImageUrl: string | null;
171
- hreflang: { locale: string; url: string }[];
172
144
  organization: SiteSeoSettings["organization"];
173
145
  }
174
146
 
@@ -177,7 +149,7 @@ export interface SeoMetaInput {
177
149
  * the renderer injects just before </head>. Order is canonical
178
150
  * (W3C-recommended): charset / viewport stay in the layout; meta
179
151
  * description + canonical + robots + Open Graph + Twitter card +
180
- * hreflang + JSON-LD.
152
+ * JSON-LD.
181
153
  *
182
154
  * No raw HTML escape hatch — every variable is HTML-attribute-encoded.
183
155
  */
@@ -208,14 +180,6 @@ export function renderSeoHead(input: SeoMetaInput): string {
208
180
  lines.push(
209
181
  `<meta name="twitter:card" content="${input.ogImageUrl ? "summary_large_image" : "summary"}" />`,
210
182
  );
211
- // Hreflang per locale + x-default. P8 ships zero rows; P9 i18n
212
- // populates pages_hreflang and the existing renderer iterates.
213
- for (const h of input.hreflang) {
214
- lines.push(`<link rel="alternate" hreflang="${enc(h.locale)}" href="${enc(h.url)}" />`);
215
- }
216
- if (input.hreflang.length > 0) {
217
- lines.push(`<link rel="alternate" hreflang="x-default" href="${enc(input.canonical)}" />`);
218
- }
219
183
  // JSON-LD WebPage block referencing the Organization (when set).
220
184
  const ld: Record<string, unknown> = {
221
185
  "@context": "https://schema.org",
@@ -75,38 +75,13 @@ export const linkListItem = z
75
75
  })
76
76
  .strict();
77
77
 
78
- /**
79
- * P9 review-pass — `language-selector` kind. Items are user-supplied
80
- * overrides (e.g. force a specific display label per locale); empty
81
- * array means "auto-populate from the locale registry at render time."
82
- * The renderer (apps/static-generator + preview op) reads the locale
83
- * registry + the current page's slug + resolveLocaleUrl to emit
84
- * `<a hreflang lang href>` rows. Closes CMS_REQUIREMENTS §7.8.
85
- */
86
- export const languageSelectorOverride = z
87
- .object({
88
- /** BCP-47 locale code; must match a row in the locales registry. */
89
- locale: z.string().min(2).max(10),
90
- /** Override the locale's display_name when rendering this entry. */
91
- label: z.string().min(1).max(120).optional(),
92
- /** Hide this locale from the rendered selector even if it has a published page. */
93
- hidden: z.boolean().optional(),
94
- })
95
- .strict();
96
-
97
78
  /** Discriminated kind → items array. The Query API op picks the
98
79
  * validator at runtime by reading `kind` first.
99
80
  *
100
81
  * v0.11.0 (#45) — `theme` is no longer a structured-set kind. The
101
82
  * theme primitive moved to its own `themes` table with DTCG-shaped
102
83
  * jsonb tokens; see `themes.ts` + `theme-render.ts`. */
103
- export const structuredSetKind = z.enum([
104
- "nav-menu",
105
- "taxonomy",
106
- "tags",
107
- "link-list",
108
- "language-selector",
109
- ]);
84
+ export const structuredSetKind = z.enum(["nav-menu", "taxonomy", "tags", "link-list"]);
110
85
  export type StructuredSetKind = z.infer<typeof structuredSetKind>;
111
86
 
112
87
  /** Validate `items` against the right schema for `kind`. Throws a
@@ -130,8 +105,6 @@ export function validateStructuredSetItems(kind: StructuredSetKind, items: unkno
130
105
  return items.map((it) => tagItem.parse(it));
131
106
  case "link-list":
132
107
  return items.map((it) => linkListItem.parse(it));
133
- case "language-selector":
134
- return items.map((it) => languageSelectorOverride.parse(it));
135
108
  }
136
109
  }
137
110
 
@@ -139,42 +112,3 @@ export type NavMenuItem = z.infer<typeof navMenuItem>;
139
112
  export type TaxonomyItem = z.infer<typeof taxonomyItem>;
140
113
  export type TagItem = z.infer<typeof tagItem>;
141
114
  export type LinkListItem = z.infer<typeof linkListItem>;
142
- export type LanguageSelectorOverride = z.infer<typeof languageSelectorOverride>;
143
-
144
- /**
145
- * Render a `<nav class="caelo-language-selector">` with one `<a>`
146
- * per locale that has a published variant of the current page. Pure
147
- * function — caller threads the page's published-locale list and a
148
- * URL resolver. Used by `composePagePreview` (admin) and the static
149
- * generator (deploy) so byte-for-byte output parity holds.
150
- *
151
- * Per CMS_REQUIREMENTS §7.8: locales with no published variant for the
152
- * current page are excluded.
153
- */
154
- export function renderLanguageSelector(args: {
155
- /** Each locale that has a published variant of the current page. */
156
- readonly availableLocales: ReadonlyArray<{
157
- code: string;
158
- displayName: string;
159
- href: string;
160
- isCurrent: boolean;
161
- }>;
162
- /** Owner-supplied overrides (relabel a locale, hide one). */
163
- readonly overrides?: ReadonlyArray<LanguageSelectorOverride>;
164
- }): string {
165
- const overrideByLocale = new Map<string, LanguageSelectorOverride>(
166
- (args.overrides ?? []).map((o) => [o.locale, o]),
167
- );
168
- const enc = (s: string): string =>
169
- s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
170
- const items = args.availableLocales
171
- .filter((l) => !overrideByLocale.get(l.code)?.hidden)
172
- .map((l) => {
173
- const override = overrideByLocale.get(l.code);
174
- const label = override?.label ?? l.displayName;
175
- const aria = l.isCurrent ? ' aria-current="true"' : "";
176
- return `<a hreflang="${enc(l.code)}" lang="${enc(l.code)}" href="${enc(l.href)}"${aria}>${enc(label)}</a>`;
177
- });
178
- if (items.length === 0) return "";
179
- return `<nav class="caelo-language-selector" aria-label="Language">${items.join("")}</nav>`;
180
- }