@caelo-cms/shared 0.10.22 → 0.10.24

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 (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. package/dist/translation.js.map +0 -1
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * issue #264 — the per-page edit LOG: durable, append-only work history so a
4
+ * later chat or a fresh subagent that touches a page knows WHY it was edited,
5
+ * what decisions were taken, and which operator answers shaped it — without
6
+ * dragging the originating chat's full transcript through its context.
7
+ *
8
+ * This is FACT (what happened), not learned BEHAVIOUR — so, unlike
9
+ * `site_ai_memory`, it is ungated: any actor appends directly. The shared
10
+ * Zod schemas here are the single source of truth for the `page_log.append`
11
+ * op input, the `log_page_edit` tool schema, and the `page_log.list` output.
12
+ * `formatPageLogBlock` renders the `## Page log` context block the AI reads
13
+ * before touching a page.
14
+ */
15
+ import { z } from "zod";
16
+ /**
17
+ * The kinds of log entry, in the order the AI should reach for them:
18
+ * - `edited` — a substantive content/module change was made.
19
+ * - `decision` — a design/structure call the AI made and its rationale.
20
+ * - `operator_answer` — an answer the operator gave that shaped the page.
21
+ * - `open_question` — something still unresolved a future turn must settle.
22
+ * - `rebuilt` — the page was rebuilt from scratch (e.g. migration rebuild).
23
+ * - `note` — anything else worth preserving for a future editor.
24
+ */
25
+ export const pageLogEntryKinds = [
26
+ "edited",
27
+ "decision",
28
+ "operator_answer",
29
+ "open_question",
30
+ "rebuilt",
31
+ "note",
32
+ ];
33
+ export const pageLogEntryKindSchema = z.enum(pageLogEntryKinds);
34
+ /**
35
+ * Optional structured context beyond the one-line summary — an OBJECT, never
36
+ * a bare scalar or array, so the jsonb column stores queryable keys (chosen
37
+ * option, operator's exact words, affected module ids). Writes go through
38
+ * `jsonbParam()` in the op handler to avoid the double-encode trap (issue
39
+ * #68).
40
+ */
41
+ export const pageLogDetailSchema = z.record(z.string(), z.unknown());
42
+ /** Input shape shared by `page_log.append` and the `log_page_edit` tool. */
43
+ export const pageLogAppendInputSchema = z
44
+ .object({
45
+ pageId: z.string().uuid(),
46
+ entryKind: pageLogEntryKindSchema,
47
+ summary: z.string().min(1).max(2000),
48
+ detail: pageLogDetailSchema.optional(),
49
+ })
50
+ .strict();
51
+ /** One row as returned by `page_log.list`. */
52
+ export const pageLogEntrySchema = z
53
+ .object({
54
+ id: z.string().uuid(),
55
+ pageId: z.string().uuid(),
56
+ chatSessionId: z.string().uuid().nullable(),
57
+ actorKind: z.enum(["human", "ai", "plugin", "system"]),
58
+ entryKind: pageLogEntryKindSchema,
59
+ summary: z.string(),
60
+ detail: pageLogDetailSchema.nullable(),
61
+ // ISO datetime, not just any string: the value is `created_at` sliced to a
62
+ // date and rendered into the AI context block — a real contract catches
63
+ // drift (a raw pg timestamp, a number) at the op boundary instead of
64
+ // producing a garbled `## Page log` line.
65
+ createdAt: z.string().datetime(),
66
+ })
67
+ .strict();
68
+ const ACTOR_LABEL = {
69
+ human: "operator",
70
+ ai: "AI",
71
+ plugin: "plugin",
72
+ system: "system",
73
+ };
74
+ /**
75
+ * Render the `## Page log` context block for a single page's recent entries
76
+ * (newest first). Returns `null` when there is nothing to show, so the
77
+ * caller omits the header entirely (CLAUDE.md §11 — render only when
78
+ * non-empty). Kept well under 2 KB: entries are capped and each summary is
79
+ * clamped, because the block rides in the system prompt on every turn that
80
+ * touches the page.
81
+ *
82
+ * @param entries page log rows, expected newest-first (as `page_log.list`
83
+ * returns them). The renderer does not re-sort.
84
+ */
85
+ export function formatPageLogBlock(entries) {
86
+ if (entries.length === 0)
87
+ return null;
88
+ // Capped + clamped to stay comfortably under 2 KB: the block rides in the
89
+ // system prompt on every turn that touches the page.
90
+ const MAX_ENTRIES = 8;
91
+ const MAX_SUMMARY = 160;
92
+ const shown = entries.slice(0, MAX_ENTRIES);
93
+ const lines = [
94
+ "## Page log",
95
+ "",
96
+ "Prior work on THIS page — read it before you change the page so you build on settled decisions instead of re-litigating them. After a meaningful change, append your own entry with `log_page_edit`.",
97
+ "",
98
+ ];
99
+ for (const e of shown) {
100
+ const who = ACTOR_LABEL[e.actorKind] ?? e.actorKind;
101
+ const when = e.createdAt.slice(0, 10);
102
+ const summary = e.summary.length > MAX_SUMMARY ? `${e.summary.slice(0, MAX_SUMMARY - 3)}...` : e.summary;
103
+ lines.push(`- [${e.entryKind}] ${summary} (${who}, ${when})`);
104
+ }
105
+ if (entries.length > MAX_ENTRIES) {
106
+ const omitted = entries.length - MAX_ENTRIES;
107
+ lines.push(`- ${omitted} older ${omitted === 1 ? "entry" : "entries"} omitted.`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+ //# sourceMappingURL=page-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-log.js","sourceRoot":"","sources":["../src/page-log.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,5 @@
1
+ import type { ModuleFieldKind } from "./content.js";
2
+ import type { ThemeDocument } from "./themes.js";
1
3
  export interface ComposeModule {
2
4
  readonly moduleId: string;
3
5
  readonly slug: string;
@@ -5,6 +7,40 @@ export interface ComposeModule {
5
7
  readonly html: string;
6
8
  readonly css: string;
7
9
  readonly js: string;
10
+ /**
11
+ * v0.4.0 module-field schema, extended in #71 to carry `kind` so
12
+ * the shared template engine can dispatch text-list / link-list /
13
+ * module-list / module sections. `kind` is optional for back-compat
14
+ * with callers that haven't been updated; the engine treats absent
15
+ * kinds as primitives (the legacy compose-path behaviour).
16
+ *
17
+ * When present, the composer substitutes `{{name}}` placeholders
18
+ * in `html` with each field's `default` value before slot
19
+ * replacement — without this, modules created via the AI-authored
20
+ * extractor path (which mints `{{spantext}}` / `{{ctahref}}` etc.
21
+ * with declared defaults) ship raw placeholders to the browser,
22
+ * visible to visitors as literal `{{name}}` text.
23
+ *
24
+ * Per-placement overrides (content_instances.values) are applied
25
+ * here via the `contentValues` field below. The chat-branched
26
+ * preview path additionally walks nested-module refs in
27
+ * preview-render before reaching here.
28
+ */
29
+ readonly fields?: readonly {
30
+ name: string;
31
+ kind?: ModuleFieldKind;
32
+ default?: unknown;
33
+ }[];
34
+ /**
35
+ * PR #61 follow-up — per-placement content values from the bound
36
+ * `content_instances.values` jsonb. When present, the composer
37
+ * substitutes `{{name}}` placeholders with `contentValues[name]`
38
+ * BEFORE falling back to `fields[].default`. Without this, AI-
39
+ * authored modules that declare explicit fields (without a `default`)
40
+ * but rely on per-placement values shipped raw `{{name}}` text to
41
+ * visitors — the bug e2e-livedit's second Stage caught.
42
+ */
43
+ readonly contentValues?: Readonly<Record<string, unknown>>;
8
44
  }
9
45
  export interface ComposeBlock {
10
46
  readonly blockName: string;
@@ -17,31 +53,68 @@ export interface ComposeStructuredSets {
17
53
  readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
18
54
  }
19
55
  /**
20
- * P9 — language-selector context. The caller (preview op + static
21
- * generator) resolves the current page's per-locale URLs once and
22
- * threads them in. The composer renders the selector when a module
23
- * slug starts with `language-selector-`.
56
+ * v0.11.0 — Theme context resolved by the preview op + static generator
57
+ * (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
58
+ * four asset URL resolutions (logo / logo-dark / favicon / social-share).
59
+ *
60
+ * The composer reads `tokens` to emit `<style data-source="theme">`;
61
+ * `assets` are surfaced for modules that want to reference theme-bound
62
+ * images (the dedicated `{{theme_logo_url}}` template binding lands in
63
+ * v0.11.x — see #45's "Out of scope"; v0.11.0 only surfaces the URLs).
64
+ */
65
+ export interface ComposeThemeAsset {
66
+ readonly mediaId: string;
67
+ readonly url: string;
68
+ }
69
+ export interface ComposeTheme {
70
+ readonly tokens: ThemeDocument;
71
+ readonly assets: {
72
+ readonly logo: ComposeThemeAsset | null;
73
+ readonly logoDark: ComposeThemeAsset | null;
74
+ readonly favicon: ComposeThemeAsset | null;
75
+ readonly socialShare: ComposeThemeAsset | null;
76
+ };
77
+ }
78
+ /**
79
+ * issue #150 — self-hosted web fonts resolved by the caller (font
80
+ * resolver in admin-core; static generator + preview op thread the
81
+ * same shape so both surfaces load identical fonts). `css` is the
82
+ * @font-face block; `preloads` are woff2 URLs worth a
83
+ * `<link rel="preload">` (body/heading faces, capped upstream).
24
84
  */
25
- export interface ComposeLanguageSelector {
26
- readonly availableLocales: ReadonlyArray<{
27
- code: string;
28
- displayName: string;
29
- href: string;
30
- isCurrent: boolean;
31
- }>;
85
+ export interface ComposeFonts {
86
+ readonly css: string;
87
+ readonly preloads: readonly string[];
32
88
  }
33
89
  export interface ComposeInput {
34
90
  readonly templateHtml: string;
35
91
  readonly templateCss: string;
36
92
  readonly blocks: readonly ComposeBlock[];
37
93
  readonly structuredSets?: ComposeStructuredSets;
38
- readonly languageSelector?: ComposeLanguageSelector;
94
+ /**
95
+ * v0.11.0 — active theme threaded through from the preview op + static
96
+ * generator. Undefined when no theme row exists OR when the caller
97
+ * hasn't been migrated to the new primitive yet (renderer no-ops in
98
+ * both cases, preserving legacy parity).
99
+ */
100
+ readonly theme?: ComposeTheme;
101
+ /** issue #150 — resolved web fonts; undefined = system stacks only. */
102
+ readonly fonts?: ComposeFonts;
39
103
  }
40
104
  export interface ComposeOutput {
41
105
  readonly html: string;
42
106
  readonly replacedSlots: readonly string[];
43
107
  readonly missingSlots: readonly string[];
44
108
  }
109
+ /**
110
+ * issue #150 — head fragment for resolved web fonts: preload links (the
111
+ * `crossorigin` attribute is REQUIRED for font preloads even same-origin,
112
+ * per the fetch spec's font-destination CORS rule) + the @font-face
113
+ * block. Empty css with no preloads → null (system-stack-only theme).
114
+ * Exported for the design-draft theme shell (#375) so draft previews
115
+ * load the identical fonts as the page preview.
116
+ */
117
+ export declare function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null;
45
118
  export declare function composePagePreview(input: ComposeInput): ComposeOutput;
46
119
  export declare function tagModuleId(html: string, moduleId: string): string;
47
120
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"preview-compose.d.ts","sourceRoot":"","sources":["../src/preview-compose.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;CAC5C;AAED;yEACyE;AACzE,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC,CAAC;CACnE;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,gBAAgB,EAAE,aAAa,CAAC;QACvC,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,CAAC;IACzC,QAAQ,CAAC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IAChD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAYD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,aAAa,CA8DrE;AA2HD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAUlE;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,sBAAuB,SAAQ,YAAY;IAC1D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;gBAC5B,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAMjF;AAkCD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB,GAAG,aAAa,CAwHlF"}
1
+ {"version":3,"file":"preview-compose.d.ts","sourceRoot":"","sources":["../src/preview-compose.ts"],"names":[],"mappings":"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"}
@@ -23,8 +23,12 @@
23
23
  * `<head>` skeleton lives. Sandboxing happens one layer up (preview iframe in
24
24
  * the admin; in P11, plugin Web Components inside Shadow DOM).
25
25
  */
26
+ import { BASE_TECHNICAL_CSS } from "./base-css.js";
27
+ import { NAV_FUNCTIONAL_CSS, NAV_TOGGLE_JS } from "./interactions.js";
26
28
  import { applySlotReplacements, extractInnerOfTopLevelContentSlot, listSlotNames, } from "./preview-scanner.js";
27
- import { renderLanguageSelector } from "./structured-sets.js";
29
+ import { stripCdataGuards } from "./strip-cdata.js";
30
+ import { renderTemplate } from "./template-engine.js";
31
+ import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
28
32
  const HEAD_CLOSE_RE = /<\/head\s*>/i;
29
33
  const BODY_CLOSE_RE = /<\/body\s*>/i;
30
34
  function injectBefore(source, marker, fragment) {
@@ -34,10 +38,32 @@ function injectBefore(source, marker, fragment) {
34
38
  const idx = m.index;
35
39
  return source.slice(0, idx) + fragment + source.slice(idx);
36
40
  }
41
+ /**
42
+ * issue #150 — head fragment for resolved web fonts: preload links (the
43
+ * `crossorigin` attribute is REQUIRED for font preloads even same-origin,
44
+ * per the fetch spec's font-destination CORS rule) + the @font-face
45
+ * block. Empty css with no preloads → null (system-stack-only theme).
46
+ * Exported for the design-draft theme shell (#375) so draft previews
47
+ * load the identical fonts as the page preview.
48
+ */
49
+ export function fontsHeadFragment(fonts) {
50
+ if (fonts === undefined)
51
+ return null;
52
+ const links = fonts.preloads
53
+ .map((href) => `<link rel="preload" as="font" type="font/woff2" crossorigin href="${href}">`)
54
+ .join("");
55
+ const style = fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
56
+ const fragment = links + style;
57
+ return fragment.length > 0 ? fragment : null;
58
+ }
37
59
  export function composePagePreview(input) {
38
60
  const contentByName = new Map();
39
61
  const allCss = [];
40
62
  const allJs = [];
63
+ // issue #160 — see composePageWithLayout.
64
+ let navRendered = false;
65
+ // issue #158 — same per-module dedup as composePageWithLayout.
66
+ const seenAssetModules = new Set();
41
67
  // Template CSS first so module CSS can override it via source-order specificity.
42
68
  if (input.templateCss.trim().length > 0)
43
69
  allCss.push(input.templateCss);
@@ -52,37 +78,52 @@ export function composePagePreview(input) {
52
78
  // module HTML.
53
79
  const renderedModuleHtml = block.modules.map((m) => {
54
80
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
55
- const langSelector = lookupLanguageSelector(m.slug, input);
56
81
  let baseHtml;
57
82
  if (navMenuItems !== null) {
83
+ navRendered = true;
58
84
  baseHtml = renderNavMenuHtml(navMenuItems);
59
85
  }
60
- else if (langSelector !== null) {
61
- baseHtml = langSelector;
62
- }
63
86
  else {
64
- baseHtml = m.html;
87
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
65
88
  }
66
89
  return tagModuleId(baseHtml, m.moduleId);
67
90
  });
68
91
  const html = renderedModuleHtml.join("\n");
69
92
  contentByName.set(block.blockName, html);
70
93
  for (const m of block.modules) {
94
+ if (seenAssetModules.has(m.moduleId))
95
+ continue;
96
+ seenAssetModules.add(m.moduleId);
71
97
  if (m.css.trim().length > 0)
72
98
  allCss.push(m.css);
73
99
  if (m.js.trim().length > 0)
74
100
  allJs.push(m.js);
75
101
  }
76
102
  }
103
+ if (navRendered) {
104
+ allCss.push(NAV_FUNCTIONAL_CSS);
105
+ allJs.push(NAV_TOGGLE_JS);
106
+ }
77
107
  const replaced = applySlotReplacements(input.templateHtml, { contentByName });
78
108
  let html = replaced.html;
79
- // P6.7.5 — theme tokens become CSS custom properties on :root. Goes
80
- // first so module CSS can `var(--color-primary)` and override.
81
- const themeCss = renderThemeCss(input.structuredSets);
109
+ // issue #150 — @font-face + preloads before everything else so the
110
+ // browser discovers font URLs as early as possible.
111
+ const fontsFragment = fontsHeadFragment(input.fonts);
112
+ if (fontsFragment !== null) {
113
+ html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
114
+ }
115
+ // v0.11.0 — theme tokens become CSS custom properties on :root + (when
116
+ // dark variants exist) :root.dark. Goes first so module CSS can
117
+ // `var(--color-primary)` and override. Pre-v0.11 read from
118
+ // structured_sets["theme/site"]; now reads the active themes row
119
+ // threaded through ComposeInput.theme.
120
+ const themeCss = renderThemeCss(input.theme);
82
121
  if (themeCss !== null) {
83
122
  const styleTag = `<style data-source="theme">${themeCss}</style>`;
84
123
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
85
124
  }
125
+ // issue #151 — invisible technical baseline (see composePageWithLayout).
126
+ html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
86
127
  if (allCss.length > 0) {
87
128
  const styleTag = `<style data-source="modules">\n${allCss.join("\n")}\n</style>`;
88
129
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
@@ -125,35 +166,6 @@ function lookupNavMenuItems(moduleSlug, sets) {
125
166
  const items = sets.byKindSlug[`nav-menu/${setSlug}`];
126
167
  return items ?? null;
127
168
  }
128
- /**
129
- * P9 — return rendered language-selector HTML when a module's slug
130
- * starts with `language-selector-`. The set's items act as overrides
131
- * (relabel a locale, hide one); the available-locale list comes from
132
- * the caller via `input.languageSelector`. Returns null when the
133
- * module is not a language selector.
134
- *
135
- * Convention: a module slug `language-selector-header` resolves to
136
- * structuredSets[`language-selector/header`] for overrides, and the
137
- * rendered HTML lists every locale that has a published variant of
138
- * the current page.
139
- */
140
- function lookupLanguageSelector(moduleSlug, input) {
141
- const prefix = "language-selector-";
142
- if (!moduleSlug.startsWith(prefix))
143
- return null;
144
- if (!input.languageSelector || input.languageSelector.availableLocales.length === 0) {
145
- return null;
146
- }
147
- const setSlug = moduleSlug.slice(prefix.length);
148
- const overridesUnknown = input.structuredSets?.byKindSlug[`language-selector/${setSlug}`];
149
- const overrides = Array.isArray(overridesUnknown)
150
- ? overridesUnknown
151
- : undefined;
152
- return renderLanguageSelector({
153
- availableLocales: input.languageSelector.availableLocales,
154
- overrides,
155
- });
156
- }
157
169
  function escapeAttr(s) {
158
170
  return s
159
171
  .replace(/&/g, "&amp;")
@@ -176,7 +188,13 @@ function renderNavMenuHtml(items) {
176
188
  const o = it;
177
189
  return typeof o.label === "string" && typeof o.href === "string";
178
190
  });
179
- return `<nav class="caelo-nav-menu"><ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
191
+ // issue #160 — mobile-ready markup: toggle button (three functional
192
+ // bars via currentColor) + collapsible list. The functional CSS/JS
193
+ // ships once per page from ./interactions.ts when a nav rendered.
194
+ const toggle = '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
195
+ '<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
196
+ "</button>";
197
+ return `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
180
198
  }
181
199
  function renderNavItem(item) {
182
200
  const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
@@ -186,25 +204,76 @@ function renderNavItem(item) {
186
204
  return `<li><a href="${escapeAttr(item.href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
187
205
  }
188
206
  /**
189
- * Render the theme/site set's tokens as `:root { --token: value; }` CSS.
190
- * Returns null when no theme is configured so the composer skips
191
- * injecting an empty <style> tag.
207
+ * v0.11.0 — Render the active theme's DTCG tokens as `:root { … }` (+
208
+ * optional `:root.dark { … }`) CSS. Returns null when no active theme
209
+ * is threaded through, so the composer skips injecting an empty
210
+ * `<style>` tag. The renderer itself lives in `theme-render.ts`; this
211
+ * wrapper exists so the composer's call site stays readable + the
212
+ * empty-tokens case (active row, `tokens={}`) emits a deterministic
213
+ * empty shell rather than skipping the tag (observable absence in
214
+ * DevTools, per #45's test-strategy "empty-active-theme" assertion).
192
215
  */
193
- function renderThemeCss(sets) {
194
- if (!sets)
216
+ function renderThemeCss(theme) {
217
+ if (!theme)
195
218
  return null;
196
- const items = sets.byKindSlug["theme/site"];
197
- if (!items || items.length === 0)
198
- return null;
199
- const tokens = items.filter((it) => {
200
- if (!it || typeof it !== "object")
201
- return false;
202
- const o = it;
203
- return typeof o.token === "string" && typeof o.value === "string";
204
- });
205
- if (tokens.length === 0)
206
- return null;
207
- return `:root{${tokens.map((t) => `--${t.token}: ${t.value};`).join("")}}`;
219
+ // Empty-tokens case: emit the shell so cascade ordering stays
220
+ // consistent (legacy "no theme/site row" case still returns null
221
+ // above so the tag is skipped entirely).
222
+ if (Object.keys(theme.tokens).length === 0)
223
+ return ":root{}";
224
+ return renderThemeCssFromTokens(theme.tokens);
225
+ }
226
+ /**
227
+ * Substitute `{{name}}` placeholders and `{{#name}}…{{/name}}`
228
+ * sections in module HTML. Thin wrapper around the shared template
229
+ * engine (#71); see `template-engine.ts` for the full substitution
230
+ * grammar and the loud-raw / failure-marker invariants.
231
+ *
232
+ * Compose is the no-DB path: nested-module field kinds (`module`,
233
+ * `module-list`) cannot be resolved here because the partial loader
234
+ * needs a DB walk to fetch nested content_instances. The engine
235
+ * emits a loud HTML comment for those refs so static-gen output is
236
+ * visible-broken (per CLAUDE.md §2) rather than silent-empty. The
237
+ * chat-branched preview path resolves nested refs via
238
+ * `preview-render.ts` BEFORE the substituted HTML reaches the
239
+ * composer, so this branch never trips for that path. Issue #70
240
+ * tracks pre-resolving in static-gen so the loud comment goes away
241
+ * there too.
242
+ *
243
+ * Both `fields` and `contentValues` are optional. Field `kind` is
244
+ * optional for back-compat with callers that haven't been updated;
245
+ * the engine treats absent kinds as primitives (the legacy
246
+ * compose-path behaviour).
247
+ */
248
+ function applyFieldSubstitution(html, fields, contentValues, theme) {
249
+ // The substitution engine (renderTemplate) already unwraps CDATA
250
+ // guards; cover the no-op early-return path so a chrome module with no
251
+ // fields/values/theme is cleaned too.
252
+ if (!fields && !contentValues && !theme)
253
+ return stripCdataGuards(html);
254
+ const engineFields = (fields ?? []).map((f) => ({
255
+ name: f.name,
256
+ kind: f.kind ?? "text",
257
+ default: f.default,
258
+ }));
259
+ return renderTemplate({
260
+ html,
261
+ fields: engineFields,
262
+ contentValues,
263
+ // v0.11.1 (issue #76) — thread the active theme's asset URLs so
264
+ // module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
265
+ // slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
266
+ themeAssets: theme
267
+ ? {
268
+ logo: theme.assets.logo?.url ?? null,
269
+ logoDark: theme.assets.logoDark?.url ?? null,
270
+ favicon: theme.assets.favicon?.url ?? null,
271
+ socialShare: theme.assets.socialShare?.url ?? null,
272
+ }
273
+ : undefined,
274
+ // Compose path has no DB; pass no partials so module/module-list
275
+ // refs emit the loud HTML comment per CLAUDE.md §2.
276
+ }).html;
208
277
  }
209
278
  export function tagModuleId(html, moduleId) {
210
279
  if (!html)
@@ -284,6 +353,14 @@ export function composePageWithLayout(input) {
284
353
  // brittle and reads as a bug).
285
354
  const cssParts = [];
286
355
  const jsParts = [];
356
+ // issue #160 — set when any nav-menu module rendered; pulls the
357
+ // functional nav CSS/JS in exactly once per page.
358
+ let navRendered = false;
359
+ // issue #158 — a module placed N times contributes its CSS/JS ONCE
360
+ // (first occurrence wins; source order is otherwise preserved).
361
+ // Duplicate rule blocks made the cascade order-dependent and bloated
362
+ // every page the same module appeared on twice.
363
+ const seenAssetModules = new Set();
287
364
  if (input.layoutCss.trim().length > 0)
288
365
  cssParts.push(input.layoutCss);
289
366
  if (input.templateCss.trim().length > 0)
@@ -294,21 +371,21 @@ export function composePageWithLayout(input) {
294
371
  for (const block of input.blocks) {
295
372
  const renderedModuleHtml = block.modules.map((m) => {
296
373
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
297
- const langSelector = lookupLanguageSelector(m.slug, input);
298
374
  let baseHtml;
299
375
  if (navMenuItems !== null) {
376
+ navRendered = true;
300
377
  baseHtml = renderNavMenuHtml(navMenuItems);
301
378
  }
302
- else if (langSelector !== null) {
303
- baseHtml = langSelector;
304
- }
305
379
  else {
306
- baseHtml = m.html;
380
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
307
381
  }
308
382
  return tagModuleId(baseHtml, m.moduleId);
309
383
  });
310
384
  templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
311
385
  for (const m of block.modules) {
386
+ if (seenAssetModules.has(m.moduleId))
387
+ continue;
388
+ seenAssetModules.add(m.moduleId);
312
389
  if (m.css.trim().length > 0)
313
390
  cssParts.push(m.css);
314
391
  if (m.js.trim().length > 0)
@@ -330,36 +407,49 @@ export function composePageWithLayout(input) {
330
407
  continue; // reserved for the page body
331
408
  const renderedModuleHtml = block.modules.map((m) => {
332
409
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
333
- const langSelector = lookupLanguageSelector(m.slug, input);
334
410
  let baseHtml;
335
411
  if (navMenuItems !== null) {
412
+ navRendered = true;
336
413
  baseHtml = renderNavMenuHtml(navMenuItems);
337
414
  }
338
- else if (langSelector !== null) {
339
- baseHtml = langSelector;
340
- }
341
415
  else {
342
- baseHtml = m.html;
416
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
343
417
  }
344
418
  return tagModuleId(baseHtml, m.moduleId);
345
419
  });
346
420
  layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
347
421
  for (const m of block.modules) {
422
+ if (seenAssetModules.has(m.moduleId))
423
+ continue;
424
+ seenAssetModules.add(m.moduleId);
348
425
  if (m.css.trim().length > 0)
349
426
  cssParts.push(m.css);
350
427
  if (m.js.trim().length > 0)
351
428
  jsParts.push(m.js);
352
429
  }
353
430
  }
431
+ if (navRendered) {
432
+ cssParts.push(NAV_FUNCTIONAL_CSS);
433
+ jsParts.push(NAV_TOGGLE_JS);
434
+ }
354
435
  // 3. Render the layout HTML, substituting all named slots.
355
436
  const replaced = applySlotReplacements(input.layoutHtml, {
356
437
  contentByName: layoutContentByName,
357
438
  });
358
439
  let html = replaced.html;
359
- const themeCss = renderThemeCss(input.structuredSets);
440
+ // issue #150 — fonts first (URL discovery), then theme vars, then
441
+ // aggregated CSS; source order in <head> mirrors injection order.
442
+ const fontsFragment = fontsHeadFragment(input.fonts);
443
+ if (fontsFragment !== null) {
444
+ html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
445
+ }
446
+ const themeCss = renderThemeCss(input.theme);
360
447
  if (themeCss !== null) {
361
448
  html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="theme">${themeCss}</style>`);
362
449
  }
450
+ // issue #151 — invisible technical baseline (reset only, zero design
451
+ // opinion); module CSS follows and overrides trivially.
452
+ html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
363
453
  if (cssParts.length > 0) {
364
454
  html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="modules">\n${cssParts.join("\n")}\n</style>`);
365
455
  }