@kywi-software/core 0.15.0 → 0.15.1

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/AGENT-PATTERNS.md +47 -0
  2. package/dist/admin/audience-editor.d.ts +2 -0
  3. package/dist/admin/audience-editor.d.ts.map +1 -1
  4. package/dist/admin/audience-editor.js +4 -2
  5. package/dist/admin/audience-editor.js.map +1 -1
  6. package/dist/admin/condition-builder.d.ts.map +1 -1
  7. package/dist/admin/condition-builder.js +16 -3
  8. package/dist/admin/condition-builder.js.map +1 -1
  9. package/dist/admin/rule-tester.d.ts +1 -0
  10. package/dist/admin/rule-tester.d.ts.map +1 -1
  11. package/dist/admin/rule-tester.js +7 -4
  12. package/dist/admin/rule-tester.js.map +1 -1
  13. package/dist/admin/signal-options.d.ts +17 -0
  14. package/dist/admin/signal-options.d.ts.map +1 -1
  15. package/dist/admin/signal-options.js +28 -0
  16. package/dist/admin/signal-options.js.map +1 -1
  17. package/dist/admin/surfaces/audiences/audiences-data.d.ts +5 -0
  18. package/dist/admin/surfaces/audiences/audiences-data.d.ts.map +1 -1
  19. package/dist/admin/surfaces/audiences/audiences-data.js +3 -0
  20. package/dist/admin/surfaces/audiences/audiences-data.js.map +1 -1
  21. package/dist/api/api-key-routes.js +2 -2
  22. package/dist/api/api-key-routes.js.map +1 -1
  23. package/dist/api/audience-routes.d.ts.map +1 -1
  24. package/dist/api/audience-routes.js +10 -4
  25. package/dist/api/audience-routes.js.map +1 -1
  26. package/dist/api/audience-runtime-routes.d.ts +17 -0
  27. package/dist/api/audience-runtime-routes.d.ts.map +1 -1
  28. package/dist/api/audience-runtime-routes.js +474 -23
  29. package/dist/api/audience-runtime-routes.js.map +1 -1
  30. package/dist/api/ax-routes.d.ts.map +1 -1
  31. package/dist/api/ax-routes.js +7 -3
  32. package/dist/api/ax-routes.js.map +1 -1
  33. package/dist/api/category-routes.js +3 -3
  34. package/dist/api/category-routes.js.map +1 -1
  35. package/dist/api/changeset-routes.js +2 -2
  36. package/dist/api/changeset-routes.js.map +1 -1
  37. package/dist/api/comment-routes.js +4 -4
  38. package/dist/api/comment-routes.js.map +1 -1
  39. package/dist/api/component-routes.js +3 -3
  40. package/dist/api/component-routes.js.map +1 -1
  41. package/dist/api/config-routes.js +4 -4
  42. package/dist/api/config-routes.js.map +1 -1
  43. package/dist/api/content-permission-routes.js +2 -2
  44. package/dist/api/content-permission-routes.js.map +1 -1
  45. package/dist/api/content-routes.d.ts.map +1 -1
  46. package/dist/api/content-routes.js +8 -3
  47. package/dist/api/content-routes.js.map +1 -1
  48. package/dist/api/content-type-routes.js +3 -3
  49. package/dist/api/content-type-routes.js.map +1 -1
  50. package/dist/api/experiment-routes.js +2 -2
  51. package/dist/api/experiment-routes.js.map +1 -1
  52. package/dist/api/extension-routes.js +4 -4
  53. package/dist/api/extension-routes.js.map +1 -1
  54. package/dist/api/feed-routes.js +2 -2
  55. package/dist/api/feed-routes.js.map +1 -1
  56. package/dist/api/form-routes.js +2 -2
  57. package/dist/api/form-routes.js.map +1 -1
  58. package/dist/api/group-routes.js +3 -3
  59. package/dist/api/group-routes.js.map +1 -1
  60. package/dist/api/index.d.ts.map +1 -1
  61. package/dist/api/index.js +4 -1
  62. package/dist/api/index.js.map +1 -1
  63. package/dist/api/layout-routes.d.ts +19 -1
  64. package/dist/api/layout-routes.d.ts.map +1 -1
  65. package/dist/api/layout-routes.js +65 -5
  66. package/dist/api/layout-routes.js.map +1 -1
  67. package/dist/api/layout-write-guard.d.ts +3 -2
  68. package/dist/api/layout-write-guard.d.ts.map +1 -1
  69. package/dist/api/layout-write-guard.js +12 -4
  70. package/dist/api/layout-write-guard.js.map +1 -1
  71. package/dist/api/media-routes.d.ts.map +1 -1
  72. package/dist/api/media-routes.js +50 -9
  73. package/dist/api/media-routes.js.map +1 -1
  74. package/dist/api/menu-routes.js +2 -2
  75. package/dist/api/menu-routes.js.map +1 -1
  76. package/dist/api/nav-routes.js +3 -3
  77. package/dist/api/nav-routes.js.map +1 -1
  78. package/dist/api/oauth-routes.js +2 -2
  79. package/dist/api/oauth-routes.js.map +1 -1
  80. package/dist/api/openapi.d.ts.map +1 -1
  81. package/dist/api/openapi.js +14 -1
  82. package/dist/api/openapi.js.map +1 -1
  83. package/dist/api/pagination.d.ts +10 -0
  84. package/dist/api/pagination.d.ts.map +1 -1
  85. package/dist/api/pagination.js +7 -1
  86. package/dist/api/pagination.js.map +1 -1
  87. package/dist/api/response.d.ts +29 -0
  88. package/dist/api/response.d.ts.map +1 -1
  89. package/dist/api/response.js +23 -3
  90. package/dist/api/response.js.map +1 -1
  91. package/dist/api/search-routes.d.ts.map +1 -1
  92. package/dist/api/search-routes.js +17 -3
  93. package/dist/api/search-routes.js.map +1 -1
  94. package/dist/api/section-template-routes.js +2 -2
  95. package/dist/api/section-template-routes.js.map +1 -1
  96. package/dist/api/site-routes.js +2 -2
  97. package/dist/api/site-routes.js.map +1 -1
  98. package/dist/api/tag-routes.d.ts.map +1 -1
  99. package/dist/api/tag-routes.js +2 -2
  100. package/dist/api/tag-routes.js.map +1 -1
  101. package/dist/api/user-routes.js +2 -2
  102. package/dist/api/user-routes.js.map +1 -1
  103. package/dist/api/version-routes.js +2 -2
  104. package/dist/api/version-routes.js.map +1 -1
  105. package/dist/api/webhook-routes.js +3 -3
  106. package/dist/api/webhook-routes.js.map +1 -1
  107. package/dist/api/workflow-routes.d.ts.map +1 -1
  108. package/dist/api/workflow-routes.js +15 -6
  109. package/dist/api/workflow-routes.js.map +1 -1
  110. package/dist/audiences/collectors/index.d.ts +8 -4
  111. package/dist/audiences/collectors/index.d.ts.map +1 -1
  112. package/dist/audiences/collectors/index.js +17 -4
  113. package/dist/audiences/collectors/index.js.map +1 -1
  114. package/dist/audiences/collectors/selfid.d.ts +25 -0
  115. package/dist/audiences/collectors/selfid.d.ts.map +1 -0
  116. package/dist/audiences/collectors/selfid.js +46 -0
  117. package/dist/audiences/collectors/selfid.js.map +1 -0
  118. package/dist/audiences/collectors/session.d.ts +41 -0
  119. package/dist/audiences/collectors/session.d.ts.map +1 -0
  120. package/dist/audiences/collectors/session.js +50 -0
  121. package/dist/audiences/collectors/session.js.map +1 -0
  122. package/dist/audiences/consent.d.ts +13 -0
  123. package/dist/audiences/consent.d.ts.map +1 -1
  124. package/dist/audiences/consent.js +13 -0
  125. package/dist/audiences/consent.js.map +1 -1
  126. package/dist/audiences/cookies.d.ts +21 -0
  127. package/dist/audiences/cookies.d.ts.map +1 -1
  128. package/dist/audiences/cookies.js +26 -0
  129. package/dist/audiences/cookies.js.map +1 -1
  130. package/dist/audiences/engine.d.ts.map +1 -1
  131. package/dist/audiences/engine.js +1 -0
  132. package/dist/audiences/engine.js.map +1 -1
  133. package/dist/audiences/entry-page.d.ts +89 -0
  134. package/dist/audiences/entry-page.d.ts.map +1 -0
  135. package/dist/audiences/entry-page.js +116 -0
  136. package/dist/audiences/entry-page.js.map +1 -0
  137. package/dist/audiences/evaluator/instant-match.d.ts.map +1 -1
  138. package/dist/audiences/evaluator/instant-match.js +7 -0
  139. package/dist/audiences/evaluator/instant-match.js.map +1 -1
  140. package/dist/audiences/index.d.ts +3 -2
  141. package/dist/audiences/index.d.ts.map +1 -1
  142. package/dist/audiences/index.js +6 -2
  143. package/dist/audiences/index.js.map +1 -1
  144. package/dist/audiences/queries.d.ts +16 -0
  145. package/dist/audiences/queries.d.ts.map +1 -1
  146. package/dist/audiences/queries.js +28 -1
  147. package/dist/audiences/queries.js.map +1 -1
  148. package/dist/audiences/schema.d.ts +17 -0
  149. package/dist/audiences/schema.d.ts.map +1 -1
  150. package/dist/audiences/schema.js +7 -0
  151. package/dist/audiences/schema.js.map +1 -1
  152. package/dist/audiences/server-eval.d.ts +19 -0
  153. package/dist/audiences/server-eval.d.ts.map +1 -1
  154. package/dist/audiences/server-eval.js +17 -1
  155. package/dist/audiences/server-eval.js.map +1 -1
  156. package/dist/audiences/types.d.ts +22 -1
  157. package/dist/audiences/types.d.ts.map +1 -1
  158. package/dist/audiences/types.js +1 -0
  159. package/dist/audiences/types.js.map +1 -1
  160. package/dist/ax/content-source.d.ts +10 -2
  161. package/dist/ax/content-source.d.ts.map +1 -1
  162. package/dist/ax/content-source.js +81 -5
  163. package/dist/ax/content-source.js.map +1 -1
  164. package/dist/ax/effective-body.d.ts +28 -0
  165. package/dist/ax/effective-body.d.ts.map +1 -0
  166. package/dist/ax/effective-body.js +47 -0
  167. package/dist/ax/effective-body.js.map +1 -0
  168. package/dist/ax/index.d.ts +3 -1
  169. package/dist/ax/index.d.ts.map +1 -1
  170. package/dist/ax/index.js +2 -0
  171. package/dist/ax/index.js.map +1 -1
  172. package/dist/ax/jsonld.d.ts +31 -1
  173. package/dist/ax/jsonld.d.ts.map +1 -1
  174. package/dist/ax/jsonld.js +35 -22
  175. package/dist/ax/jsonld.js.map +1 -1
  176. package/dist/ax/layout-to-html.d.ts +127 -0
  177. package/dist/ax/layout-to-html.d.ts.map +1 -0
  178. package/dist/ax/layout-to-html.js +545 -0
  179. package/dist/ax/layout-to-html.js.map +1 -0
  180. package/dist/ax/llms-full-txt.d.ts +3 -1
  181. package/dist/ax/llms-full-txt.d.ts.map +1 -1
  182. package/dist/ax/llms-full-txt.js +5 -2
  183. package/dist/ax/llms-full-txt.js.map +1 -1
  184. package/dist/ax/page-markdown.d.ts +10 -2
  185. package/dist/ax/page-markdown.d.ts.map +1 -1
  186. package/dist/ax/page-markdown.js +16 -3
  187. package/dist/ax/page-markdown.js.map +1 -1
  188. package/dist/ax/summary.d.ts +4 -0
  189. package/dist/ax/summary.d.ts.map +1 -1
  190. package/dist/ax/summary.js +6 -1
  191. package/dist/ax/summary.js.map +1 -1
  192. package/dist/ax/types.d.ts +24 -0
  193. package/dist/ax/types.d.ts.map +1 -1
  194. package/dist/cache/content-invalidation.d.ts.map +1 -1
  195. package/dist/cache/content-invalidation.js +11 -0
  196. package/dist/cache/content-invalidation.js.map +1 -1
  197. package/dist/db/apply-snapshot.d.ts +7 -0
  198. package/dist/db/apply-snapshot.d.ts.map +1 -1
  199. package/dist/db/apply-snapshot.js +7 -0
  200. package/dist/db/apply-snapshot.js.map +1 -1
  201. package/dist/db/ax-queries.d.ts +6 -0
  202. package/dist/db/ax-queries.d.ts.map +1 -1
  203. package/dist/db/ax-queries.js +6 -0
  204. package/dist/db/ax-queries.js.map +1 -1
  205. package/dist/db/content-queries.d.ts.map +1 -1
  206. package/dist/db/content-queries.js +8 -0
  207. package/dist/db/content-queries.js.map +1 -1
  208. package/dist/db/layout-queries.d.ts +19 -2
  209. package/dist/db/layout-queries.d.ts.map +1 -1
  210. package/dist/db/layout-queries.js +11 -3
  211. package/dist/db/layout-queries.js.map +1 -1
  212. package/dist/db/search-queries.d.ts.map +1 -1
  213. package/dist/db/search-queries.js +24 -1
  214. package/dist/db/search-queries.js.map +1 -1
  215. package/dist/hooks/ax-invalidation.d.ts.map +1 -1
  216. package/dist/hooks/ax-invalidation.js +4 -0
  217. package/dist/hooks/ax-invalidation.js.map +1 -1
  218. package/dist/layout/index.d.ts +3 -1
  219. package/dist/layout/index.d.ts.map +1 -1
  220. package/dist/layout/index.js +3 -1
  221. package/dist/layout/index.js.map +1 -1
  222. package/dist/layout/module-permissions.d.ts.map +1 -1
  223. package/dist/layout/module-permissions.js +2 -38
  224. package/dist/layout/module-permissions.js.map +1 -1
  225. package/dist/layout/seed-defaults.d.ts +45 -0
  226. package/dist/layout/seed-defaults.d.ts.map +1 -0
  227. package/dist/layout/seed-defaults.js +75 -0
  228. package/dist/layout/seed-defaults.js.map +1 -0
  229. package/dist/layout/walk.d.ts +14 -0
  230. package/dist/layout/walk.d.ts.map +1 -0
  231. package/dist/layout/walk.js +49 -0
  232. package/dist/layout/walk.js.map +1 -0
  233. package/dist/media/index.d.ts +1 -1
  234. package/dist/media/index.d.ts.map +1 -1
  235. package/dist/media/index.js +1 -1
  236. package/dist/media/index.js.map +1 -1
  237. package/dist/media/upload-allowlist.d.ts +23 -0
  238. package/dist/media/upload-allowlist.d.ts.map +1 -1
  239. package/dist/media/upload-allowlist.js +29 -0
  240. package/dist/media/upload-allowlist.js.map +1 -1
  241. package/dist/schema/mysql-tables.d.ts +17 -0
  242. package/dist/schema/mysql-tables.d.ts.map +1 -1
  243. package/dist/schema/mysql-tables.js +2 -0
  244. package/dist/schema/mysql-tables.js.map +1 -1
  245. package/dist/scope/jsonld.d.ts +18 -3
  246. package/dist/scope/jsonld.d.ts.map +1 -1
  247. package/dist/scope/jsonld.js +6 -4
  248. package/dist/scope/jsonld.js.map +1 -1
  249. package/package.json +6 -1
@@ -0,0 +1,127 @@
1
+ import type { ModuleRegistry } from '../layout/registry.js';
2
+ import type { ComponentDetachSource } from '../layout/component-refs.js';
3
+ /**
4
+ * layout-to-html.ts — the AX layer's projection of a stored LAYOUT DOCUMENT
5
+ * into semantic HTML (kywi-cms#195).
6
+ *
7
+ * WHY THIS EXISTS. Every AX surface (llms-full.txt, `/<path>.md`, the summary
8
+ * fallback chain, JSON-LD descriptions) reads a node's `body` column. A page
9
+ * composed in the layout editor — i.e. every page on a real Kywi site — has an
10
+ * empty or seed `body`, so those surfaces exported a title and a URL and nothing
11
+ * else. The page's actual copy lives in `layout`, and this is the one function
12
+ * that reads it out.
13
+ *
14
+ * It is PURE and DETERMINISTIC: same document in, same string out, no database,
15
+ * no React, no language model (the Tier-1 boundary). Callers that can resolve
16
+ * saved components and custom modules pass them in through {@link
17
+ * LayoutToHtmlOptions}; a caller with neither still gets every built-in
18
+ * module's copy.
19
+ *
20
+ * ─── THE SERIALIZATION RULES ────────────────────────────────────────────────
21
+ *
22
+ * READING ORDER. `layout.regions` in stored key order, each region's nodes in
23
+ * order, each section's columns left to right, each column's nodes top to
24
+ * bottom. `pageVariants` and `abExperiments` are NOT walked: they are alternate
25
+ * versions of the page, and an agent corpus that concatenated them would present
26
+ * contradictory copy as one document.
27
+ *
28
+ * THE ARM AN ANONYMOUS VISITOR IS SERVED. Both container kinds are resolved
29
+ * through `selectVariantArm(arms, container, null)` — the same precedence the
30
+ * renderer applies, with no personalization state. That answers Default for a
31
+ * personalization container and for a running experiment, and the WINNING ARM
32
+ * for an `ab_test` whose winner is locked, because a locked winner IS the public
33
+ * page: every visitor gets it, so exporting Default there would publish copy
34
+ * nobody can see. A targeted audience arm is never reachable without a resolved
35
+ * audience, so audience-specific copy can never leak into a public corpus.
36
+ *
37
+ * COMPONENTS. Linked placements (`componentId` on a module/section/container)
38
+ * are resolved through {@link resolveComponentPlacements} — the same
39
+ * materialisation the renderer and detach use — so the projection shows what the
40
+ * page shows, not the stale inline fallback copy. A `component` MODULE
41
+ * placement resolves through the same hook by `props.componentId` (or
42
+ * `props.slug`), or emits its pre-hydrated `props.content` when the host already
43
+ * filled it in. Unresolvable placements emit nothing, and resolution is
44
+ * cycle-guarded.
45
+ *
46
+ * ROBUSTNESS. Every walk site goes through {@link asArray}/{@link isRecord}, and
47
+ * component pre-resolution is wrapped. `setContentLayout` stores what it is
48
+ * handed — validation lives on the write ROUTES — so malformed documents exist
49
+ * in the wild (a hand-written INSERT, a restored dump, a pre-validation row),
50
+ * and these projections run on PUBLIC surfaces: `/search`, `llms-full.txt`,
51
+ * `/<slug>.md`, per-page JSON-LD. A `TypeError` here would be a 500 on all four,
52
+ * so a broken subtree is skipped and the sound part of the document still
53
+ * exports. Expansion is bounded three ways: a visited set (cycles), a depth cap,
54
+ * and an output budget (a diamond of components re-expands exponentially).
55
+ *
56
+ * MODULES. Built-ins with copy get a hand-written emitter (below). Anything else
57
+ * the registry knows — a CUSTOM module, or a built-in with no emitter — is
58
+ * serialized from its registered prop schema: the first `text` prop becomes an
59
+ * `<h2>`, every other `text`/`textarea` prop a `<p>`, every `richText` prop its
60
+ * (sanitized) HTML. Values that look like JSON are skipped, since a
61
+ * `textarea`-typed prop is how the palette stores structured item lists.
62
+ * Reference props are skipped too — a link target, id, slug or class named by a
63
+ * `text` prop because the palette has no reference type (`ctaHref`,
64
+ * `experimentId`, `feedSlug`) is a destination, not copy. A module the registry
65
+ * does not know is skipped SILENTLY — a host's unregistered module is not an
66
+ * error, it just has no schema to read.
67
+ *
68
+ * DELIBERATELY SKIPPED: `html` and `embed` (arbitrary markup, not authored
69
+ * copy), `form`/`formEmbed` (interaction, not content), every navigation and
70
+ * search module (site chrome repeated on every page, already covered by
71
+ * llms.txt/sitemap.xml), `cookieConsent` (compliance chrome), the pure-layout
72
+ * modules (`section`, `container`, `columns`, `spacer`, `divider`), and the
73
+ * decorative/interactive ones with no standalone copy (`icon`, `video`,
74
+ * `carousel`, `tagCloud`, `countdown`, `map`, `socialShare`, `categoryList`,
75
+ * `comments`, `personalizationBadge`, the retired `variantContainer` module).
76
+ *
77
+ * IMAGES emit their alt text as `<p><em>alt</em></p>` and their caption as a
78
+ * paragraph — the only human-readable copy an image carries — and nothing at all
79
+ * when both are empty. The image itself is not linked: an AX corpus is text.
80
+ *
81
+ * `feedDisplay` emits only its heading, when it has one: its items are other
82
+ * published nodes, which appear in the corpus in their own right.
83
+ */
84
+ /**
85
+ * Resolve a saved component for the projection, by id (a linked placement) or by
86
+ * slug/name (a `component` module placement). Returns null/undefined when the
87
+ * component cannot be resolved — the placement is then skipped.
88
+ *
89
+ * The production implementation lives in the DB-backed AX content source; unit
90
+ * tests pass a plain map lookup.
91
+ */
92
+ export type AxComponentResolver = (ref: {
93
+ componentId?: string;
94
+ slug?: string;
95
+ }) => ComponentDetachSource | null | undefined;
96
+ export interface LayoutToHtmlOptions {
97
+ /**
98
+ * The site's module registry, so CUSTOM modules serialize from their declared
99
+ * prop schema. Defaults to the built-ins alone.
100
+ */
101
+ modules?: ModuleRegistry;
102
+ /** Resolve saved components. Omitted → component placements emit nothing. */
103
+ resolveComponent?: AxComponentResolver;
104
+ }
105
+ /**
106
+ * Serialize a stored layout document to semantic HTML in reading order.
107
+ * Returns `''` for a null/empty/unrecognizable document.
108
+ */
109
+ export declare function layoutToHtml(layout: unknown, opts?: LayoutToHtmlOptions): string;
110
+ /**
111
+ * The module registry for a config's `modules`, memoised on the array itself.
112
+ *
113
+ * The per-NODE surfaces — `/<path>.md`, per-page JSON-LD — are handed a node by
114
+ * the host rather than by the AX content source, so nothing precomputed their
115
+ * layout projection and they must build the registry themselves. Doing it
116
+ * unmemoised would rebuild a ~45-entry map on every request; the config's
117
+ * `modules` array is a stable object for the process's lifetime, so it keys the
118
+ * cache exactly.
119
+ *
120
+ * NOTE what this deliberately does NOT carry: a component resolver. Resolving a
121
+ * saved component is a database read, and these two surfaces are synchronous and
122
+ * DB-free by contract. A linked placement on a node that did not come through
123
+ * the content source therefore contributes its inline fallback copy — the same
124
+ * copy the renderer falls back to when a link cannot be resolved.
125
+ */
126
+ export declare function registryForModules(modules: unknown): ModuleRegistry;
127
+ //# sourceMappingURL=layout-to-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout-to-html.d.ts","sourceRoot":"","sources":["../../src/ax/layout-to-html.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE3D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAA;AAaxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgFG;AAIH;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG,CAChC,GAAG,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KACzC,qBAAqB,GAAG,IAAI,GAAG,SAAS,CAAA;AAE7C,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,mBAAmB,CAAA;CACvC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,GAAE,mBAAwB,GAAG,MAAM,CAiCpF;AAibD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,cAAc,CAOnE"}
@@ -0,0 +1,545 @@
1
+ import { sanitizeHtml } from '../util/sanitize.js';
2
+ import { createModuleRegistry } from '../layout/registry.js';
3
+ import { resolveComponentPlacements } from '../layout/component-refs.js';
4
+ import { isModuleVariantContainer } from '../layout/types.js';
5
+ import { selectVariantArm } from '../layout/variant-selection.js';
6
+ /**
7
+ * Serialize a stored layout document to semantic HTML in reading order.
8
+ * Returns `''` for a null/empty/unrecognizable document.
9
+ */
10
+ export function layoutToHtml(layout, opts = {}) {
11
+ const doc = asLayoutDocument(layout);
12
+ if (!doc)
13
+ return '';
14
+ const resolver = opts.resolveComponent;
15
+ // Pre-resolve linked placements exactly as the renderer does, so the
16
+ // projection reads the definition rather than a placement's fallback copy.
17
+ // A malformed subtree (or a resolver that throws) degrades to the UNRESOLVED
18
+ // document rather than to no output at all — the inline fallback copy a
19
+ // placement carries is still the page's copy.
20
+ let resolved = doc;
21
+ if (resolver) {
22
+ try {
23
+ resolved = resolveComponentPlacements(doc, (componentId) => resolver({ componentId }));
24
+ }
25
+ catch {
26
+ resolved = doc;
27
+ }
28
+ }
29
+ const ctx = {
30
+ registry: opts.modules ?? builtInRegistry(),
31
+ ...(resolver ? { resolveComponent: resolver } : {}),
32
+ visitedComponents: new Set(),
33
+ depth: 0,
34
+ };
35
+ const sink = createSink();
36
+ if (isRecord(resolved.regions)) {
37
+ for (const nodes of Object.values(resolved.regions)) {
38
+ for (const node of asArray(nodes))
39
+ emitRegionNode(node, ctx, sink);
40
+ }
41
+ }
42
+ return sink.blocks.join('\n');
43
+ }
44
+ // ─── Output sink (the budget lives here) ─────────────────────────────────────
45
+ /**
46
+ * Total characters one projection may emit. Generous for any real page (a long
47
+ * article body is a few tens of KB) and small enough that a pathological
48
+ * document — a deep diamond of components, a machine-generated layout — cannot
49
+ * turn one uncached `/search` hit into a memory event.
50
+ */
51
+ const MAX_OUTPUT_CHARS = 262_144;
52
+ function createSink() {
53
+ const blocks = [];
54
+ let used = 0;
55
+ return {
56
+ blocks,
57
+ push(html) {
58
+ if (used >= MAX_OUTPUT_CHARS)
59
+ return;
60
+ blocks.push(html);
61
+ used += html.length + 1;
62
+ },
63
+ full() {
64
+ return used >= MAX_OUTPUT_CHARS;
65
+ },
66
+ };
67
+ }
68
+ // ─── Walking ──────────────────────────────────────────────────────────────────
69
+ /** How many component expansions may nest before the walk gives up. */
70
+ const MAX_COMPONENT_DEPTH = 10;
71
+ function emitRegionNode(node, ctx, out) {
72
+ if (!isRecord(node) || out.full())
73
+ return;
74
+ if (node.type === 'section') {
75
+ emitSection(node, ctx, out);
76
+ return;
77
+ }
78
+ if (node.type === 'variantContainer') {
79
+ for (const section of servedSections(node)) {
80
+ emitSection(section, ctx, out);
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * The sections a section-level container serves an anonymous visitor: the
86
+ * locked winner's arm for an `ab_test`, otherwise Default. `selectVariantArm` is
87
+ * the renderer's own precedence — one implementation, so the corpus and the page
88
+ * cannot disagree about which arm is public.
89
+ */
90
+ function servedSections(container) {
91
+ const arms = asArray(container.variants);
92
+ const arm = selectVariantArm(arms, container, null);
93
+ return arm ? asArray(arm.sections) : asArray(container.defaultSections);
94
+ }
95
+ /** {@link servedSections} for a MODULE-level container: one module, not sections. */
96
+ function servedModule(container) {
97
+ const arms = asArray(container.variants);
98
+ const arm = selectVariantArm(arms, container, null);
99
+ return arm ? arm.module : container.defaultModule;
100
+ }
101
+ function emitSection(section, ctx, out) {
102
+ if (!isRecord(section) || out.full())
103
+ return;
104
+ for (const column of asArray(section.columns)) {
105
+ if (!isRecord(column))
106
+ continue;
107
+ for (const node of asArray(column['nodes']))
108
+ emitColumnNode(node, ctx, out);
109
+ }
110
+ }
111
+ function emitColumnNode(node, ctx, out) {
112
+ if (!isRecord(node) || out.full())
113
+ return;
114
+ if (isModuleVariantContainer(node)) {
115
+ emitModule(servedModule(node), ctx, out);
116
+ return;
117
+ }
118
+ emitModule(node, ctx, out);
119
+ }
120
+ function emitModule(node, ctx, out) {
121
+ if (!isRecord(node) || typeof node.type !== 'string' || out.full())
122
+ return;
123
+ const props = isRecord(node.props) ? node.props : {};
124
+ if (node.type === 'component') {
125
+ emitComponentPlacement(props, ctx, out);
126
+ return;
127
+ }
128
+ emitModuleOfType(node.type, props, ctx, out);
129
+ }
130
+ function emitModuleOfType(type, props, ctx, out) {
131
+ const entry = ctx.registry.get(type);
132
+ // A host that OVERRIDES a built-in name owns its prop shape, so the built-in
133
+ // emitter would be reading props that no longer exist. Its declared schema is
134
+ // the only trustworthy description of it.
135
+ //
136
+ // This runs BEFORE the skip list on purpose: the skip list describes KYWI's
137
+ // built-ins, and a host that registers its own `video` (with a transcript) or
138
+ // `carousel` (with slide copy) under that name has authored real content the
139
+ // built-in never carried.
140
+ if (entry && !entry.isBuiltIn) {
141
+ emitFromPropSchema(entry.props, props, out);
142
+ return;
143
+ }
144
+ if (SKIPPED_MODULES.has(type))
145
+ return;
146
+ const emitter = BUILT_IN_EMITTERS[type];
147
+ if (emitter) {
148
+ emitter(props, out);
149
+ return;
150
+ }
151
+ // Unregistered → skip silently. Registered but with no emitter (a built-in
152
+ // that carries copy in plain props) → read its schema.
153
+ if (entry)
154
+ emitFromPropSchema(entry.props, props, out);
155
+ }
156
+ /**
157
+ * A `component` MODULE placement (the "Saved → Component" palette entry), whose
158
+ * target lives in `props`, not in a node-level `componentId`.
159
+ */
160
+ function emitComponentPlacement(props, ctx, out) {
161
+ // A host that already hydrated the placement wins: that HTML is what rendered.
162
+ const content = str(props['content']);
163
+ if (content) {
164
+ pushHtml(content, out);
165
+ return;
166
+ }
167
+ const resolve = ctx.resolveComponent;
168
+ if (!resolve)
169
+ return;
170
+ const componentId = str(props['componentId']);
171
+ const slug = str(props['slug']);
172
+ if (!componentId && !slug)
173
+ return;
174
+ const key = componentId || `slug:${slug}`;
175
+ // The visited set catches a definition that points back at ITSELF; the depth
176
+ // cap catches a chain that never repeats a key (A→B→C→…), which a visited set
177
+ // alone cannot bound.
178
+ if (ctx.visitedComponents.has(key) || ctx.depth >= MAX_COMPONENT_DEPTH)
179
+ return;
180
+ ctx.visitedComponents.add(key);
181
+ ctx.depth += 1;
182
+ try {
183
+ const source = resolve({
184
+ ...(componentId ? { componentId } : {}),
185
+ ...(slug ? { slug } : {}),
186
+ });
187
+ if (!source)
188
+ return;
189
+ emitComponentSource(source, ctx, out);
190
+ }
191
+ finally {
192
+ ctx.depth -= 1;
193
+ ctx.visitedComponents.delete(key);
194
+ }
195
+ }
196
+ function emitComponentSource(source, ctx, out) {
197
+ switch (source.kind) {
198
+ case 'module':
199
+ emitModuleOfType(source.moduleType, isRecord(source.props) ? source.props : {}, ctx, out);
200
+ return;
201
+ case 'section':
202
+ emitSection(source.section, ctx, out);
203
+ return;
204
+ case 'variantContainer':
205
+ if (!isRecord(source.container))
206
+ return;
207
+ for (const section of servedSections(source.container))
208
+ emitSection(section, ctx, out);
209
+ return;
210
+ case 'moduleVariantContainer':
211
+ if (!isRecord(source.container))
212
+ return;
213
+ emitModule(servedModule(source.container), ctx, out);
214
+ return;
215
+ default:
216
+ // 'unresolvable' — the renderer shows the placement's own (empty) props.
217
+ return;
218
+ }
219
+ }
220
+ // ─── Prop-schema projection (custom modules + schema-only built-ins) ──────────
221
+ function emitFromPropSchema(schema, props, out) {
222
+ if (!schema)
223
+ return;
224
+ let headingUsed = false;
225
+ for (const [key, def] of Object.entries(schema)) {
226
+ const value = str(props[key]);
227
+ if (!value)
228
+ continue;
229
+ if (def.type === 'richText') {
230
+ pushHtml(value, out);
231
+ continue;
232
+ }
233
+ if (def.type !== 'text' && def.type !== 'textarea')
234
+ continue;
235
+ if (isReferenceProp(key))
236
+ continue;
237
+ // A textarea prop is also how the palette stores JSON item lists; a serialized
238
+ // array is data, not copy.
239
+ if (looksLikeJson(value) || looksLikeLinkTarget(value))
240
+ continue;
241
+ if (!headingUsed && def.type === 'text') {
242
+ headingUsed = true;
243
+ out.push(`<h2>${esc(value)}</h2>`);
244
+ }
245
+ else {
246
+ out.push(`<p>${esc(value)}</p>`);
247
+ }
248
+ }
249
+ }
250
+ const HEADING_LEVELS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
251
+ const BUILT_IN_EMITTERS = {
252
+ heading(props, out) {
253
+ const text = str(props['text']);
254
+ if (!text)
255
+ return;
256
+ const level = str(props['level']);
257
+ const tag = HEADING_LEVELS.has(level) ? level : 'h2';
258
+ out.push(`<${tag}>${esc(text)}</${tag}>`);
259
+ },
260
+ richText(props, out) {
261
+ pushHtml(str(props['content']), out);
262
+ },
263
+ text(props, out) {
264
+ pushParagraph(str(props['content']), out);
265
+ },
266
+ button(props, out) {
267
+ pushLinkParagraph(str(props['label']), str(props['href']), out);
268
+ },
269
+ image(props, out) {
270
+ const alt = str(props['alt']);
271
+ if (alt)
272
+ out.push(`<p><em>${esc(alt)}</em></p>`);
273
+ pushParagraph(str(props['caption']), out);
274
+ },
275
+ card(props, out) {
276
+ const title = str(props['title']);
277
+ const href = safeHref(str(props['href']));
278
+ if (title) {
279
+ out.push(href ? `<h3><a href="${escAttr(href)}">${esc(title)}</a></h3>` : `<h3>${esc(title)}</h3>`);
280
+ }
281
+ pushParagraph(str(props['body']), out);
282
+ pushParagraph(str(props['footer']), out);
283
+ },
284
+ cta(props, out) {
285
+ pushHeading(2, str(props['headline']), out);
286
+ pushParagraph(str(props['subtext']), out);
287
+ pushLinkParagraph(str(props['buttonLabel']), str(props['buttonHref']), out);
288
+ },
289
+ hero(props, out) {
290
+ // h1 to match what HeroModule renders.
291
+ pushHeading(1, str(props['headline']), out);
292
+ pushParagraph(str(props['subtext']), out);
293
+ pushLinkParagraph(str(props['buttonLabel']), str(props['buttonHref']), out);
294
+ },
295
+ testimonial(props, out) {
296
+ pushQuote(str(props['quote']), out);
297
+ const author = str(props['author']);
298
+ const role = str(props['role']);
299
+ if (author)
300
+ pushParagraph(role ? `${author}, ${role}` : author, out);
301
+ },
302
+ blockquote(props, out) {
303
+ pushQuote(str(props['content']), out);
304
+ pushParagraph(str(props['author']), out);
305
+ },
306
+ faq(props, out) {
307
+ pushHeading(3, str(props['question']), out);
308
+ pushHtml(str(props['answer']), out);
309
+ },
310
+ accordion(props, out) {
311
+ pushHeading(3, str(props['title']), out);
312
+ pushHtml(str(props['content']), out);
313
+ },
314
+ pricing(props, out) {
315
+ pushHeading(3, str(props['planName']), out);
316
+ const price = str(props['price']);
317
+ const period = str(props['period']);
318
+ if (price)
319
+ pushParagraph(period ? `${price}/${period}` : price, out);
320
+ const features = parseJsonArray(props['features']).map(str).filter(Boolean);
321
+ if (features.length > 0) {
322
+ out.push(`<ul>${features.map(f => `<li>${esc(f)}</li>`).join('')}</ul>`);
323
+ }
324
+ pushLinkParagraph(str(props['buttonLabel']), str(props['buttonHref']), out);
325
+ },
326
+ alert(props, out) {
327
+ pushParagraph(str(props['message']), out);
328
+ },
329
+ badge(props, out) {
330
+ const label = str(props['label']);
331
+ if (!label)
332
+ return;
333
+ const href = str(props['href']);
334
+ if (href)
335
+ pushLinkParagraph(label, href, out);
336
+ else
337
+ pushParagraph(label, out);
338
+ },
339
+ tabs(props, out) {
340
+ for (const raw of parseJsonArray(props['tabs'])) {
341
+ if (!isRecord(raw))
342
+ continue;
343
+ pushHeading(3, str(raw['label']), out);
344
+ pushHtml(str(raw['content']), out);
345
+ }
346
+ },
347
+ feedDisplay(props, out) {
348
+ // Items are other published nodes — they enter the corpus on their own.
349
+ pushHeading(2, str(props['heading']) || str(props['title']), out);
350
+ },
351
+ };
352
+ /**
353
+ * Modules that carry no standalone authored copy, or whose copy is site chrome
354
+ * repeated on every page. See the module docblock for the reasoning per group.
355
+ */
356
+ const SKIPPED_MODULES = new Set([
357
+ // Layout scaffolding
358
+ 'section', 'container', 'columns', 'spacer', 'divider',
359
+ // Arbitrary markup / embeds
360
+ 'html', 'embed', 'video',
361
+ // Interaction
362
+ 'form', 'formEmbed', 'comments',
363
+ // Navigation + search chrome
364
+ 'breadcrumbs', 'navMenu', 'navigation', 'siteMap', 'searchBox', 'search',
365
+ // Compliance chrome
366
+ 'cookieConsent',
367
+ // Decorative / structured-data-only
368
+ 'icon', 'map', 'socialShare', 'countdown', 'carousel', 'tagCloud', 'categoryList',
369
+ // Personalization chrome + the retired container MODULE (the real container is
370
+ // a layout NODE, handled in the walk above).
371
+ 'personalizationBadge', 'variantContainer',
372
+ ]);
373
+ // ─── Emit helpers ─────────────────────────────────────────────────────────────
374
+ function pushHeading(level, text, out) {
375
+ if (!text)
376
+ return;
377
+ out.push(`<h${level}>${esc(text)}</h${level}>`);
378
+ }
379
+ function pushParagraph(text, out) {
380
+ if (!text)
381
+ return;
382
+ out.push(`<p>${esc(text)}</p>`);
383
+ }
384
+ function pushLinkParagraph(label, rawHref, out) {
385
+ if (!label)
386
+ return;
387
+ const href = safeHref(rawHref);
388
+ out.push(href ? `<p><a href="${escAttr(href)}">${esc(label)}</a></p>` : `<p>${esc(label)}</p>`);
389
+ }
390
+ /**
391
+ * The href allowlist for links this module ASSEMBLES.
392
+ *
393
+ * Rich text goes through `sanitizeHtml`, which enforces its own scheme list, but
394
+ * a `button`/`cta`/`card` href is interpolated straight into markup built here.
395
+ * A stored `javascript:` or `data:` href is inert in an agent corpus and live in
396
+ * anything that renders this HTML, and there is no reason for a projection to
397
+ * carry one. Relative targets (`/`, `#`, `?`, `.`, a bare path) pass; an
398
+ * absolute one must be http(s)/mailto/tel. Dropped → the label still emits as
399
+ * plain copy, since the words are the content.
400
+ */
401
+ const SAFE_SCHEME = /^(https?|mailto|tel):/i;
402
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
403
+ function safeHref(href) {
404
+ if (!href)
405
+ return '';
406
+ // Control characters are how `java\0script:` style bypasses are written.
407
+ const cleaned = href.replace(/[\u0000-\u001f\u007f]/g, '');
408
+ if (!cleaned)
409
+ return '';
410
+ if (!HAS_SCHEME.test(cleaned))
411
+ return cleaned;
412
+ return SAFE_SCHEME.test(cleaned) ? cleaned : '';
413
+ }
414
+ function pushQuote(text, out) {
415
+ if (!text)
416
+ return;
417
+ out.push(`<blockquote><p>${esc(text)}</p></blockquote>`);
418
+ }
419
+ /** Stored rich text is CMS-authored HTML: sanitize it exactly as the renderer does. */
420
+ function pushHtml(html, out) {
421
+ if (!html)
422
+ return;
423
+ const clean = sanitizeHtml(html).trim();
424
+ if (clean)
425
+ out.push(clean);
426
+ }
427
+ // ─── Small pure helpers ───────────────────────────────────────────────────────
428
+ let cachedBuiltInRegistry = null;
429
+ /** The built-ins-only registry, built once (it is immutable and stateless). */
430
+ function builtInRegistry() {
431
+ cachedBuiltInRegistry ??= createModuleRegistry([]);
432
+ return cachedBuiltInRegistry;
433
+ }
434
+ const registryByModules = new WeakMap();
435
+ /**
436
+ * The module registry for a config's `modules`, memoised on the array itself.
437
+ *
438
+ * The per-NODE surfaces — `/<path>.md`, per-page JSON-LD — are handed a node by
439
+ * the host rather than by the AX content source, so nothing precomputed their
440
+ * layout projection and they must build the registry themselves. Doing it
441
+ * unmemoised would rebuild a ~45-entry map on every request; the config's
442
+ * `modules` array is a stable object for the process's lifetime, so it keys the
443
+ * cache exactly.
444
+ *
445
+ * NOTE what this deliberately does NOT carry: a component resolver. Resolving a
446
+ * saved component is a database read, and these two surfaces are synchronous and
447
+ * DB-free by contract. A linked placement on a node that did not come through
448
+ * the content source therefore contributes its inline fallback copy — the same
449
+ * copy the renderer falls back to when a link cannot be resolved.
450
+ */
451
+ export function registryForModules(modules) {
452
+ if (!Array.isArray(modules) || modules.length === 0)
453
+ return builtInRegistry();
454
+ const hit = registryByModules.get(modules);
455
+ if (hit)
456
+ return hit;
457
+ const built = createModuleRegistry(modules);
458
+ registryByModules.set(modules, built);
459
+ return built;
460
+ }
461
+ function asLayoutDocument(value) {
462
+ if (!isRecord(value))
463
+ return null;
464
+ const regions = value['regions'];
465
+ if (!isRecord(regions))
466
+ return null;
467
+ return value;
468
+ }
469
+ function isRecord(value) {
470
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
471
+ }
472
+ /**
473
+ * THE guard for every walk site. A stored layout is raw JSONB that
474
+ * `setContentLayout` never validated, so any of `regions.main`, `columns`,
475
+ * `nodes`, `defaultSections` and `variants` may be an object, a string, or
476
+ * absent. `for (const x of somethingNotIterable)` is a TypeError, and these
477
+ * projections run on public routes.
478
+ */
479
+ function asArray(value) {
480
+ return Array.isArray(value) ? value : [];
481
+ }
482
+ function str(value) {
483
+ return typeof value === 'string' ? value.trim() : '';
484
+ }
485
+ /**
486
+ * Structured data stored in a `textarea` prop (the palette's item lists). The
487
+ * `JSON.parse` is not decoration: prose legitimately opens with a brace
488
+ * ("{curly} quotes", "[draft] title"), and dropping it would silently lose real
489
+ * copy.
490
+ */
491
+ function looksLikeJson(value) {
492
+ const first = value[0];
493
+ if (first !== '[' && first !== '{')
494
+ return false;
495
+ try {
496
+ JSON.parse(value);
497
+ return true;
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ }
503
+ /**
504
+ * A prop that NAMES something rather than saying something: a link target, an
505
+ * id, a slug, a CSS class.
506
+ *
507
+ * The palette has no "reference" prop type, so a custom module's `ctaHref`,
508
+ * `experimentId` or `videoSrc` is declared `text` alongside its `headline`. Emitting those as
509
+ * paragraphs fills an agent corpus with bare paths and UUIDs, and the schema
510
+ * path is the only projection that cannot tell them apart from copy (a built-in
511
+ * emitter names the props it reads). Matching on the name is the cheap, stable
512
+ * half of that; {@link looksLikeLinkTarget} catches the rest by value.
513
+ */
514
+ function isReferenceProp(name) {
515
+ // Whole name, or a camelCase suffix. The camelCase half must require the
516
+ // capital: a plain `/id$/i` would also swallow `valid`, `grid` and `hybrid`.
517
+ return (/^(href|url|uri|id|slug|class|classname|link|path|route|target|src)$/i.test(name) ||
518
+ /[a-z](Href|Url|Uri|Id|Slug|Class|ClassName|Link|Path|Route|Target|Src)$/.test(name));
519
+ }
520
+ /** A bare path, anchor, or absolute URL — a destination, not a sentence. */
521
+ function looksLikeLinkTarget(value) {
522
+ if (/\s/.test(value))
523
+ return false;
524
+ return /^(\/|#|https?:\/\/|mailto:|tel:)/i.test(value);
525
+ }
526
+ function parseJsonArray(raw) {
527
+ if (Array.isArray(raw))
528
+ return raw;
529
+ if (typeof raw !== 'string')
530
+ return [];
531
+ try {
532
+ const parsed = JSON.parse(raw);
533
+ return Array.isArray(parsed) ? parsed : [];
534
+ }
535
+ catch {
536
+ return [];
537
+ }
538
+ }
539
+ function esc(text) {
540
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
541
+ }
542
+ function escAttr(text) {
543
+ return esc(text).replace(/"/g, '&quot;');
544
+ }
545
+ //# sourceMappingURL=layout-to-html.js.map