@caelo-cms/shared 0.10.21 → 0.10.23

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 (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  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 +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -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-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * issue #264 — the per-page edit LOG: durable, append-only work history so a
3
+ * later chat or a fresh subagent that touches a page knows WHY it was edited,
4
+ * what decisions were taken, and which operator answers shaped it — without
5
+ * dragging the originating chat's full transcript through its context.
6
+ *
7
+ * This is FACT (what happened), not learned BEHAVIOUR — so, unlike
8
+ * `site_ai_memory`, it is ungated: any actor appends directly. The shared
9
+ * Zod schemas here are the single source of truth for the `page_log.append`
10
+ * op input, the `log_page_edit` tool schema, and the `page_log.list` output.
11
+ * `formatPageLogBlock` renders the `## Page log` context block the AI reads
12
+ * before touching a page.
13
+ */
14
+ import { z } from "zod";
15
+ /**
16
+ * The kinds of log entry, in the order the AI should reach for them:
17
+ * - `edited` — a substantive content/module change was made.
18
+ * - `decision` — a design/structure call the AI made and its rationale.
19
+ * - `operator_answer` — an answer the operator gave that shaped the page.
20
+ * - `open_question` — something still unresolved a future turn must settle.
21
+ * - `rebuilt` — the page was rebuilt from scratch (e.g. migration rebuild).
22
+ * - `note` — anything else worth preserving for a future editor.
23
+ */
24
+ export declare const pageLogEntryKinds: readonly ["edited", "decision", "operator_answer", "open_question", "rebuilt", "note"];
25
+ export declare const pageLogEntryKindSchema: z.ZodEnum<{
26
+ decision: "decision";
27
+ edited: "edited";
28
+ operator_answer: "operator_answer";
29
+ open_question: "open_question";
30
+ rebuilt: "rebuilt";
31
+ note: "note";
32
+ }>;
33
+ export type PageLogEntryKind = (typeof pageLogEntryKinds)[number];
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 declare const pageLogDetailSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
42
+ export type PageLogDetail = z.infer<typeof pageLogDetailSchema>;
43
+ /** Input shape shared by `page_log.append` and the `log_page_edit` tool. */
44
+ export declare const pageLogAppendInputSchema: z.ZodObject<{
45
+ pageId: z.ZodString;
46
+ entryKind: z.ZodEnum<{
47
+ decision: "decision";
48
+ edited: "edited";
49
+ operator_answer: "operator_answer";
50
+ open_question: "open_question";
51
+ rebuilt: "rebuilt";
52
+ note: "note";
53
+ }>;
54
+ summary: z.ZodString;
55
+ detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
56
+ }, z.core.$strict>;
57
+ export type PageLogAppendInput = z.infer<typeof pageLogAppendInputSchema>;
58
+ /** One row as returned by `page_log.list`. */
59
+ export declare const pageLogEntrySchema: z.ZodObject<{
60
+ id: z.ZodString;
61
+ pageId: z.ZodString;
62
+ chatSessionId: z.ZodNullable<z.ZodString>;
63
+ actorKind: z.ZodEnum<{
64
+ system: "system";
65
+ human: "human";
66
+ ai: "ai";
67
+ plugin: "plugin";
68
+ }>;
69
+ entryKind: z.ZodEnum<{
70
+ decision: "decision";
71
+ edited: "edited";
72
+ operator_answer: "operator_answer";
73
+ open_question: "open_question";
74
+ rebuilt: "rebuilt";
75
+ note: "note";
76
+ }>;
77
+ summary: z.ZodString;
78
+ detail: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
79
+ createdAt: z.ZodString;
80
+ }, z.core.$strict>;
81
+ export type PageLogEntry = z.infer<typeof pageLogEntrySchema>;
82
+ /**
83
+ * Render the `## Page log` context block for a single page's recent entries
84
+ * (newest first). Returns `null` when there is nothing to show, so the
85
+ * caller omits the header entirely (CLAUDE.md §11 — render only when
86
+ * non-empty). Kept well under 2 KB: entries are capped and each summary is
87
+ * clamped, because the block rides in the system prompt on every turn that
88
+ * touches the page.
89
+ *
90
+ * @param entries page log rows, expected newest-first (as `page_log.list`
91
+ * returns them). The renderer does not re-sort.
92
+ */
93
+ export declare function formatPageLogBlock(entries: readonly PageLogEntry[]): string | null;
94
+ //# sourceMappingURL=page-log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-log.d.ts","sourceRoot":"","sources":["../src/page-log.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,wFAOpB,CAAC;AAEX,eAAO,MAAM,sBAAsB;;;;;;;EAA4B,CAAC;AAChE,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,wCAAoC,CAAC;AACrE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,4EAA4E;AAC5E,eAAO,MAAM,wBAAwB;;;;;;;;;;;;kBAO1B,CAAC;AACZ,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;kBAepB,CAAC;AACZ,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAS9D;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,GAAG,IAAI,CAyBlF"}
@@ -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;
@@ -30,12 +66,55 @@ export interface ComposeLanguageSelector {
30
66
  isCurrent: boolean;
31
67
  }>;
32
68
  }
69
+ /**
70
+ * v0.11.0 — Theme context resolved by the preview op + static generator
71
+ * (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
72
+ * four asset URL resolutions (logo / logo-dark / favicon / social-share).
73
+ *
74
+ * The composer reads `tokens` to emit `<style data-source="theme">`;
75
+ * `assets` are surfaced for modules that want to reference theme-bound
76
+ * images (the dedicated `{{theme_logo_url}}` template binding lands in
77
+ * v0.11.x — see #45's "Out of scope"; v0.11.0 only surfaces the URLs).
78
+ */
79
+ export interface ComposeThemeAsset {
80
+ readonly mediaId: string;
81
+ readonly url: string;
82
+ }
83
+ export interface ComposeTheme {
84
+ readonly tokens: ThemeDocument;
85
+ readonly assets: {
86
+ readonly logo: ComposeThemeAsset | null;
87
+ readonly logoDark: ComposeThemeAsset | null;
88
+ readonly favicon: ComposeThemeAsset | null;
89
+ readonly socialShare: ComposeThemeAsset | null;
90
+ };
91
+ }
92
+ /**
93
+ * issue #150 — self-hosted web fonts resolved by the caller (font
94
+ * resolver in admin-core; static generator + preview op thread the
95
+ * same shape so both surfaces load identical fonts). `css` is the
96
+ * @font-face block; `preloads` are woff2 URLs worth a
97
+ * `<link rel="preload">` (body/heading faces, capped upstream).
98
+ */
99
+ export interface ComposeFonts {
100
+ readonly css: string;
101
+ readonly preloads: readonly string[];
102
+ }
33
103
  export interface ComposeInput {
34
104
  readonly templateHtml: string;
35
105
  readonly templateCss: string;
36
106
  readonly blocks: readonly ComposeBlock[];
37
107
  readonly structuredSets?: ComposeStructuredSets;
38
108
  readonly languageSelector?: ComposeLanguageSelector;
109
+ /**
110
+ * v0.11.0 — active theme threaded through from the preview op + static
111
+ * generator. Undefined when no theme row exists OR when the caller
112
+ * hasn't been migrated to the new primitive yet (renderer no-ops in
113
+ * both cases, preserving legacy parity).
114
+ */
115
+ readonly theme?: ComposeTheme;
116
+ /** issue #150 — resolved web fonts; undefined = system stacks only. */
117
+ readonly fonts?: ComposeFonts;
39
118
  }
40
119
  export interface ComposeOutput {
41
120
  readonly html: string;
@@ -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":"AA4BA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAWpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,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;IACpB;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,eAAe,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;IACzF;;;;;;;;OAQG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5D;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;;;;;;;;;GASG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAAC;QACxC,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;QAC5C,QAAQ,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAC;QAC3C,QAAQ,CAAC,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAC;KAChD,CAAC;CACH;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;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;IACpD;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;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;AA6BD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,aAAa,CA0FrE;AAwLD,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,CAwJlF"}
@@ -23,8 +23,13 @@
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";
29
+ import { stripCdataGuards } from "./strip-cdata.js";
27
30
  import { renderLanguageSelector } from "./structured-sets.js";
31
+ import { renderTemplate } from "./template-engine.js";
32
+ import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
28
33
  const HEAD_CLOSE_RE = /<\/head\s*>/i;
29
34
  const BODY_CLOSE_RE = /<\/body\s*>/i;
30
35
  function injectBefore(source, marker, fragment) {
@@ -34,10 +39,30 @@ function injectBefore(source, marker, fragment) {
34
39
  const idx = m.index;
35
40
  return source.slice(0, idx) + fragment + source.slice(idx);
36
41
  }
42
+ /**
43
+ * issue #150 — head fragment for resolved web fonts: preload links (the
44
+ * `crossorigin` attribute is REQUIRED for font preloads even same-origin,
45
+ * per the fetch spec's font-destination CORS rule) + the @font-face
46
+ * block. Empty css with no preloads → null (system-stack-only theme).
47
+ */
48
+ function fontsHeadFragment(fonts) {
49
+ if (fonts === undefined)
50
+ return null;
51
+ const links = fonts.preloads
52
+ .map((href) => `<link rel="preload" as="font" type="font/woff2" crossorigin href="${href}">`)
53
+ .join("");
54
+ const style = fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
55
+ const fragment = links + style;
56
+ return fragment.length > 0 ? fragment : null;
57
+ }
37
58
  export function composePagePreview(input) {
38
59
  const contentByName = new Map();
39
60
  const allCss = [];
40
61
  const allJs = [];
62
+ // issue #160 — see composePageWithLayout.
63
+ let navRendered = false;
64
+ // issue #158 — same per-module dedup as composePageWithLayout.
65
+ const seenAssetModules = new Set();
41
66
  // Template CSS first so module CSS can override it via source-order specificity.
42
67
  if (input.templateCss.trim().length > 0)
43
68
  allCss.push(input.templateCss);
@@ -55,34 +80,53 @@ export function composePagePreview(input) {
55
80
  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
86
  else if (langSelector !== null) {
61
87
  baseHtml = langSelector;
62
88
  }
63
89
  else {
64
- baseHtml = m.html;
90
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
65
91
  }
66
92
  return tagModuleId(baseHtml, m.moduleId);
67
93
  });
68
94
  const html = renderedModuleHtml.join("\n");
69
95
  contentByName.set(block.blockName, html);
70
96
  for (const m of block.modules) {
97
+ if (seenAssetModules.has(m.moduleId))
98
+ continue;
99
+ seenAssetModules.add(m.moduleId);
71
100
  if (m.css.trim().length > 0)
72
101
  allCss.push(m.css);
73
102
  if (m.js.trim().length > 0)
74
103
  allJs.push(m.js);
75
104
  }
76
105
  }
106
+ if (navRendered) {
107
+ allCss.push(NAV_FUNCTIONAL_CSS);
108
+ allJs.push(NAV_TOGGLE_JS);
109
+ }
77
110
  const replaced = applySlotReplacements(input.templateHtml, { contentByName });
78
111
  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);
112
+ // issue #150 — @font-face + preloads before everything else so the
113
+ // browser discovers font URLs as early as possible.
114
+ const fontsFragment = fontsHeadFragment(input.fonts);
115
+ if (fontsFragment !== null) {
116
+ html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
117
+ }
118
+ // v0.11.0 — theme tokens become CSS custom properties on :root + (when
119
+ // dark variants exist) :root.dark. Goes first so module CSS can
120
+ // `var(--color-primary)` and override. Pre-v0.11 read from
121
+ // structured_sets["theme/site"]; now reads the active themes row
122
+ // threaded through ComposeInput.theme.
123
+ const themeCss = renderThemeCss(input.theme);
82
124
  if (themeCss !== null) {
83
125
  const styleTag = `<style data-source="theme">${themeCss}</style>`;
84
126
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
85
127
  }
128
+ // issue #151 — invisible technical baseline (see composePageWithLayout).
129
+ html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
86
130
  if (allCss.length > 0) {
87
131
  const styleTag = `<style data-source="modules">\n${allCss.join("\n")}\n</style>`;
88
132
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
@@ -176,7 +220,13 @@ function renderNavMenuHtml(items) {
176
220
  const o = it;
177
221
  return typeof o.label === "string" && typeof o.href === "string";
178
222
  });
179
- return `<nav class="caelo-nav-menu"><ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
223
+ // issue #160 — mobile-ready markup: toggle button (three functional
224
+ // bars via currentColor) + collapsible list. The functional CSS/JS
225
+ // ships once per page from ./interactions.ts when a nav rendered.
226
+ const toggle = '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
227
+ '<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
228
+ "</button>";
229
+ return `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
180
230
  }
181
231
  function renderNavItem(item) {
182
232
  const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
@@ -186,25 +236,76 @@ function renderNavItem(item) {
186
236
  return `<li><a href="${escapeAttr(item.href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
187
237
  }
188
238
  /**
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.
239
+ * v0.11.0 — Render the active theme's DTCG tokens as `:root { … }` (+
240
+ * optional `:root.dark { … }`) CSS. Returns null when no active theme
241
+ * is threaded through, so the composer skips injecting an empty
242
+ * `<style>` tag. The renderer itself lives in `theme-render.ts`; this
243
+ * wrapper exists so the composer's call site stays readable + the
244
+ * empty-tokens case (active row, `tokens={}`) emits a deterministic
245
+ * empty shell rather than skipping the tag (observable absence in
246
+ * DevTools, per #45's test-strategy "empty-active-theme" assertion).
192
247
  */
193
- function renderThemeCss(sets) {
194
- if (!sets)
195
- 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)
248
+ function renderThemeCss(theme) {
249
+ if (!theme)
206
250
  return null;
207
- return `:root{${tokens.map((t) => `--${t.token}: ${t.value};`).join("")}}`;
251
+ // Empty-tokens case: emit the shell so cascade ordering stays
252
+ // consistent (legacy "no theme/site row" case still returns null
253
+ // above so the tag is skipped entirely).
254
+ if (Object.keys(theme.tokens).length === 0)
255
+ return ":root{}";
256
+ return renderThemeCssFromTokens(theme.tokens);
257
+ }
258
+ /**
259
+ * Substitute `{{name}}` placeholders and `{{#name}}…{{/name}}`
260
+ * sections in module HTML. Thin wrapper around the shared template
261
+ * engine (#71); see `template-engine.ts` for the full substitution
262
+ * grammar and the loud-raw / failure-marker invariants.
263
+ *
264
+ * Compose is the no-DB path: nested-module field kinds (`module`,
265
+ * `module-list`) cannot be resolved here because the partial loader
266
+ * needs a DB walk to fetch nested content_instances. The engine
267
+ * emits a loud HTML comment for those refs so static-gen output is
268
+ * visible-broken (per CLAUDE.md §2) rather than silent-empty. The
269
+ * chat-branched preview path resolves nested refs via
270
+ * `preview-render.ts` BEFORE the substituted HTML reaches the
271
+ * composer, so this branch never trips for that path. Issue #70
272
+ * tracks pre-resolving in static-gen so the loud comment goes away
273
+ * there too.
274
+ *
275
+ * Both `fields` and `contentValues` are optional. Field `kind` is
276
+ * optional for back-compat with callers that haven't been updated;
277
+ * the engine treats absent kinds as primitives (the legacy
278
+ * compose-path behaviour).
279
+ */
280
+ function applyFieldSubstitution(html, fields, contentValues, theme) {
281
+ // The substitution engine (renderTemplate) already unwraps CDATA
282
+ // guards; cover the no-op early-return path so a chrome module with no
283
+ // fields/values/theme is cleaned too.
284
+ if (!fields && !contentValues && !theme)
285
+ return stripCdataGuards(html);
286
+ const engineFields = (fields ?? []).map((f) => ({
287
+ name: f.name,
288
+ kind: f.kind ?? "text",
289
+ default: f.default,
290
+ }));
291
+ return renderTemplate({
292
+ html,
293
+ fields: engineFields,
294
+ contentValues,
295
+ // v0.11.1 (issue #76) — thread the active theme's asset URLs so
296
+ // module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
297
+ // slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
298
+ themeAssets: theme
299
+ ? {
300
+ logo: theme.assets.logo?.url ?? null,
301
+ logoDark: theme.assets.logoDark?.url ?? null,
302
+ favicon: theme.assets.favicon?.url ?? null,
303
+ socialShare: theme.assets.socialShare?.url ?? null,
304
+ }
305
+ : undefined,
306
+ // Compose path has no DB; pass no partials so module/module-list
307
+ // refs emit the loud HTML comment per CLAUDE.md §2.
308
+ }).html;
208
309
  }
209
310
  export function tagModuleId(html, moduleId) {
210
311
  if (!html)
@@ -284,6 +385,14 @@ export function composePageWithLayout(input) {
284
385
  // brittle and reads as a bug).
285
386
  const cssParts = [];
286
387
  const jsParts = [];
388
+ // issue #160 — set when any nav-menu module rendered; pulls the
389
+ // functional nav CSS/JS in exactly once per page.
390
+ let navRendered = false;
391
+ // issue #158 — a module placed N times contributes its CSS/JS ONCE
392
+ // (first occurrence wins; source order is otherwise preserved).
393
+ // Duplicate rule blocks made the cascade order-dependent and bloated
394
+ // every page the same module appeared on twice.
395
+ const seenAssetModules = new Set();
287
396
  if (input.layoutCss.trim().length > 0)
288
397
  cssParts.push(input.layoutCss);
289
398
  if (input.templateCss.trim().length > 0)
@@ -297,18 +406,22 @@ export function composePageWithLayout(input) {
297
406
  const langSelector = lookupLanguageSelector(m.slug, input);
298
407
  let baseHtml;
299
408
  if (navMenuItems !== null) {
409
+ navRendered = true;
300
410
  baseHtml = renderNavMenuHtml(navMenuItems);
301
411
  }
302
412
  else if (langSelector !== null) {
303
413
  baseHtml = langSelector;
304
414
  }
305
415
  else {
306
- baseHtml = m.html;
416
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
307
417
  }
308
418
  return tagModuleId(baseHtml, m.moduleId);
309
419
  });
310
420
  templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
311
421
  for (const m of block.modules) {
422
+ if (seenAssetModules.has(m.moduleId))
423
+ continue;
424
+ seenAssetModules.add(m.moduleId);
312
425
  if (m.css.trim().length > 0)
313
426
  cssParts.push(m.css);
314
427
  if (m.js.trim().length > 0)
@@ -333,33 +446,50 @@ export function composePageWithLayout(input) {
333
446
  const langSelector = lookupLanguageSelector(m.slug, input);
334
447
  let baseHtml;
335
448
  if (navMenuItems !== null) {
449
+ navRendered = true;
336
450
  baseHtml = renderNavMenuHtml(navMenuItems);
337
451
  }
338
452
  else if (langSelector !== null) {
339
453
  baseHtml = langSelector;
340
454
  }
341
455
  else {
342
- baseHtml = m.html;
456
+ baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
343
457
  }
344
458
  return tagModuleId(baseHtml, m.moduleId);
345
459
  });
346
460
  layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
347
461
  for (const m of block.modules) {
462
+ if (seenAssetModules.has(m.moduleId))
463
+ continue;
464
+ seenAssetModules.add(m.moduleId);
348
465
  if (m.css.trim().length > 0)
349
466
  cssParts.push(m.css);
350
467
  if (m.js.trim().length > 0)
351
468
  jsParts.push(m.js);
352
469
  }
353
470
  }
471
+ if (navRendered) {
472
+ cssParts.push(NAV_FUNCTIONAL_CSS);
473
+ jsParts.push(NAV_TOGGLE_JS);
474
+ }
354
475
  // 3. Render the layout HTML, substituting all named slots.
355
476
  const replaced = applySlotReplacements(input.layoutHtml, {
356
477
  contentByName: layoutContentByName,
357
478
  });
358
479
  let html = replaced.html;
359
- const themeCss = renderThemeCss(input.structuredSets);
480
+ // issue #150 — fonts first (URL discovery), then theme vars, then
481
+ // aggregated CSS; source order in <head> mirrors injection order.
482
+ const fontsFragment = fontsHeadFragment(input.fonts);
483
+ if (fontsFragment !== null) {
484
+ html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
485
+ }
486
+ const themeCss = renderThemeCss(input.theme);
360
487
  if (themeCss !== null) {
361
488
  html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="theme">${themeCss}</style>`);
362
489
  }
490
+ // issue #151 — invisible technical baseline (reset only, zero design
491
+ // opinion); module CSS follows and overrides trivially.
492
+ html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
363
493
  if (cssParts.length > 0) {
364
494
  html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="modules">\n${cssParts.join("\n")}\n</style>`);
365
495
  }