@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.
- package/AGENT-PATTERNS.md +47 -0
- package/dist/admin/audience-editor.d.ts +2 -0
- package/dist/admin/audience-editor.d.ts.map +1 -1
- package/dist/admin/audience-editor.js +4 -2
- package/dist/admin/audience-editor.js.map +1 -1
- package/dist/admin/condition-builder.d.ts.map +1 -1
- package/dist/admin/condition-builder.js +16 -3
- package/dist/admin/condition-builder.js.map +1 -1
- package/dist/admin/rule-tester.d.ts +1 -0
- package/dist/admin/rule-tester.d.ts.map +1 -1
- package/dist/admin/rule-tester.js +7 -4
- package/dist/admin/rule-tester.js.map +1 -1
- package/dist/admin/signal-options.d.ts +17 -0
- package/dist/admin/signal-options.d.ts.map +1 -1
- package/dist/admin/signal-options.js +28 -0
- package/dist/admin/signal-options.js.map +1 -1
- package/dist/admin/surfaces/audiences/audiences-data.d.ts +5 -0
- package/dist/admin/surfaces/audiences/audiences-data.d.ts.map +1 -1
- package/dist/admin/surfaces/audiences/audiences-data.js +3 -0
- package/dist/admin/surfaces/audiences/audiences-data.js.map +1 -1
- package/dist/api/api-key-routes.js +2 -2
- package/dist/api/api-key-routes.js.map +1 -1
- package/dist/api/audience-routes.d.ts.map +1 -1
- package/dist/api/audience-routes.js +10 -4
- package/dist/api/audience-routes.js.map +1 -1
- package/dist/api/audience-runtime-routes.d.ts +17 -0
- package/dist/api/audience-runtime-routes.d.ts.map +1 -1
- package/dist/api/audience-runtime-routes.js +474 -23
- package/dist/api/audience-runtime-routes.js.map +1 -1
- package/dist/api/ax-routes.d.ts.map +1 -1
- package/dist/api/ax-routes.js +7 -3
- package/dist/api/ax-routes.js.map +1 -1
- package/dist/api/category-routes.js +3 -3
- package/dist/api/category-routes.js.map +1 -1
- package/dist/api/changeset-routes.js +2 -2
- package/dist/api/changeset-routes.js.map +1 -1
- package/dist/api/comment-routes.js +4 -4
- package/dist/api/comment-routes.js.map +1 -1
- package/dist/api/component-routes.js +3 -3
- package/dist/api/component-routes.js.map +1 -1
- package/dist/api/config-routes.js +4 -4
- package/dist/api/config-routes.js.map +1 -1
- package/dist/api/content-permission-routes.js +2 -2
- package/dist/api/content-permission-routes.js.map +1 -1
- package/dist/api/content-routes.d.ts.map +1 -1
- package/dist/api/content-routes.js +8 -3
- package/dist/api/content-routes.js.map +1 -1
- package/dist/api/content-type-routes.js +3 -3
- package/dist/api/content-type-routes.js.map +1 -1
- package/dist/api/experiment-routes.js +2 -2
- package/dist/api/experiment-routes.js.map +1 -1
- package/dist/api/extension-routes.js +4 -4
- package/dist/api/extension-routes.js.map +1 -1
- package/dist/api/feed-routes.js +2 -2
- package/dist/api/feed-routes.js.map +1 -1
- package/dist/api/form-routes.js +2 -2
- package/dist/api/form-routes.js.map +1 -1
- package/dist/api/group-routes.js +3 -3
- package/dist/api/group-routes.js.map +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +4 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/layout-routes.d.ts +19 -1
- package/dist/api/layout-routes.d.ts.map +1 -1
- package/dist/api/layout-routes.js +65 -5
- package/dist/api/layout-routes.js.map +1 -1
- package/dist/api/layout-write-guard.d.ts +3 -2
- package/dist/api/layout-write-guard.d.ts.map +1 -1
- package/dist/api/layout-write-guard.js +12 -4
- package/dist/api/layout-write-guard.js.map +1 -1
- package/dist/api/media-routes.d.ts.map +1 -1
- package/dist/api/media-routes.js +50 -9
- package/dist/api/media-routes.js.map +1 -1
- package/dist/api/menu-routes.js +2 -2
- package/dist/api/menu-routes.js.map +1 -1
- package/dist/api/nav-routes.js +3 -3
- package/dist/api/nav-routes.js.map +1 -1
- package/dist/api/oauth-routes.js +2 -2
- package/dist/api/oauth-routes.js.map +1 -1
- package/dist/api/openapi.d.ts.map +1 -1
- package/dist/api/openapi.js +14 -1
- package/dist/api/openapi.js.map +1 -1
- package/dist/api/pagination.d.ts +10 -0
- package/dist/api/pagination.d.ts.map +1 -1
- package/dist/api/pagination.js +7 -1
- package/dist/api/pagination.js.map +1 -1
- package/dist/api/response.d.ts +29 -0
- package/dist/api/response.d.ts.map +1 -1
- package/dist/api/response.js +23 -3
- package/dist/api/response.js.map +1 -1
- package/dist/api/search-routes.d.ts.map +1 -1
- package/dist/api/search-routes.js +17 -3
- package/dist/api/search-routes.js.map +1 -1
- package/dist/api/section-template-routes.js +2 -2
- package/dist/api/section-template-routes.js.map +1 -1
- package/dist/api/site-routes.js +2 -2
- package/dist/api/site-routes.js.map +1 -1
- package/dist/api/tag-routes.d.ts.map +1 -1
- package/dist/api/tag-routes.js +2 -2
- package/dist/api/tag-routes.js.map +1 -1
- package/dist/api/user-routes.js +2 -2
- package/dist/api/user-routes.js.map +1 -1
- package/dist/api/version-routes.js +2 -2
- package/dist/api/version-routes.js.map +1 -1
- package/dist/api/webhook-routes.js +3 -3
- package/dist/api/webhook-routes.js.map +1 -1
- package/dist/api/workflow-routes.d.ts.map +1 -1
- package/dist/api/workflow-routes.js +15 -6
- package/dist/api/workflow-routes.js.map +1 -1
- package/dist/audiences/collectors/index.d.ts +8 -4
- package/dist/audiences/collectors/index.d.ts.map +1 -1
- package/dist/audiences/collectors/index.js +17 -4
- package/dist/audiences/collectors/index.js.map +1 -1
- package/dist/audiences/collectors/selfid.d.ts +25 -0
- package/dist/audiences/collectors/selfid.d.ts.map +1 -0
- package/dist/audiences/collectors/selfid.js +46 -0
- package/dist/audiences/collectors/selfid.js.map +1 -0
- package/dist/audiences/collectors/session.d.ts +41 -0
- package/dist/audiences/collectors/session.d.ts.map +1 -0
- package/dist/audiences/collectors/session.js +50 -0
- package/dist/audiences/collectors/session.js.map +1 -0
- package/dist/audiences/consent.d.ts +13 -0
- package/dist/audiences/consent.d.ts.map +1 -1
- package/dist/audiences/consent.js +13 -0
- package/dist/audiences/consent.js.map +1 -1
- package/dist/audiences/cookies.d.ts +21 -0
- package/dist/audiences/cookies.d.ts.map +1 -1
- package/dist/audiences/cookies.js +26 -0
- package/dist/audiences/cookies.js.map +1 -1
- package/dist/audiences/engine.d.ts.map +1 -1
- package/dist/audiences/engine.js +1 -0
- package/dist/audiences/engine.js.map +1 -1
- package/dist/audiences/entry-page.d.ts +89 -0
- package/dist/audiences/entry-page.d.ts.map +1 -0
- package/dist/audiences/entry-page.js +116 -0
- package/dist/audiences/entry-page.js.map +1 -0
- package/dist/audiences/evaluator/instant-match.d.ts.map +1 -1
- package/dist/audiences/evaluator/instant-match.js +7 -0
- package/dist/audiences/evaluator/instant-match.js.map +1 -1
- package/dist/audiences/index.d.ts +3 -2
- package/dist/audiences/index.d.ts.map +1 -1
- package/dist/audiences/index.js +6 -2
- package/dist/audiences/index.js.map +1 -1
- package/dist/audiences/queries.d.ts +16 -0
- package/dist/audiences/queries.d.ts.map +1 -1
- package/dist/audiences/queries.js +28 -1
- package/dist/audiences/queries.js.map +1 -1
- package/dist/audiences/schema.d.ts +17 -0
- package/dist/audiences/schema.d.ts.map +1 -1
- package/dist/audiences/schema.js +7 -0
- package/dist/audiences/schema.js.map +1 -1
- package/dist/audiences/server-eval.d.ts +19 -0
- package/dist/audiences/server-eval.d.ts.map +1 -1
- package/dist/audiences/server-eval.js +17 -1
- package/dist/audiences/server-eval.js.map +1 -1
- package/dist/audiences/types.d.ts +22 -1
- package/dist/audiences/types.d.ts.map +1 -1
- package/dist/audiences/types.js +1 -0
- package/dist/audiences/types.js.map +1 -1
- package/dist/ax/content-source.d.ts +10 -2
- package/dist/ax/content-source.d.ts.map +1 -1
- package/dist/ax/content-source.js +81 -5
- package/dist/ax/content-source.js.map +1 -1
- package/dist/ax/effective-body.d.ts +28 -0
- package/dist/ax/effective-body.d.ts.map +1 -0
- package/dist/ax/effective-body.js +47 -0
- package/dist/ax/effective-body.js.map +1 -0
- package/dist/ax/index.d.ts +3 -1
- package/dist/ax/index.d.ts.map +1 -1
- package/dist/ax/index.js +2 -0
- package/dist/ax/index.js.map +1 -1
- package/dist/ax/jsonld.d.ts +31 -1
- package/dist/ax/jsonld.d.ts.map +1 -1
- package/dist/ax/jsonld.js +35 -22
- package/dist/ax/jsonld.js.map +1 -1
- package/dist/ax/layout-to-html.d.ts +127 -0
- package/dist/ax/layout-to-html.d.ts.map +1 -0
- package/dist/ax/layout-to-html.js +545 -0
- package/dist/ax/layout-to-html.js.map +1 -0
- package/dist/ax/llms-full-txt.d.ts +3 -1
- package/dist/ax/llms-full-txt.d.ts.map +1 -1
- package/dist/ax/llms-full-txt.js +5 -2
- package/dist/ax/llms-full-txt.js.map +1 -1
- package/dist/ax/page-markdown.d.ts +10 -2
- package/dist/ax/page-markdown.d.ts.map +1 -1
- package/dist/ax/page-markdown.js +16 -3
- package/dist/ax/page-markdown.js.map +1 -1
- package/dist/ax/summary.d.ts +4 -0
- package/dist/ax/summary.d.ts.map +1 -1
- package/dist/ax/summary.js +6 -1
- package/dist/ax/summary.js.map +1 -1
- package/dist/ax/types.d.ts +24 -0
- package/dist/ax/types.d.ts.map +1 -1
- package/dist/cache/content-invalidation.d.ts.map +1 -1
- package/dist/cache/content-invalidation.js +11 -0
- package/dist/cache/content-invalidation.js.map +1 -1
- package/dist/db/apply-snapshot.d.ts +7 -0
- package/dist/db/apply-snapshot.d.ts.map +1 -1
- package/dist/db/apply-snapshot.js +7 -0
- package/dist/db/apply-snapshot.js.map +1 -1
- package/dist/db/ax-queries.d.ts +6 -0
- package/dist/db/ax-queries.d.ts.map +1 -1
- package/dist/db/ax-queries.js +6 -0
- package/dist/db/ax-queries.js.map +1 -1
- package/dist/db/content-queries.d.ts.map +1 -1
- package/dist/db/content-queries.js +8 -0
- package/dist/db/content-queries.js.map +1 -1
- package/dist/db/layout-queries.d.ts +19 -2
- package/dist/db/layout-queries.d.ts.map +1 -1
- package/dist/db/layout-queries.js +11 -3
- package/dist/db/layout-queries.js.map +1 -1
- package/dist/db/search-queries.d.ts.map +1 -1
- package/dist/db/search-queries.js +24 -1
- package/dist/db/search-queries.js.map +1 -1
- package/dist/hooks/ax-invalidation.d.ts.map +1 -1
- package/dist/hooks/ax-invalidation.js +4 -0
- package/dist/hooks/ax-invalidation.js.map +1 -1
- package/dist/layout/index.d.ts +3 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +3 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/module-permissions.d.ts.map +1 -1
- package/dist/layout/module-permissions.js +2 -38
- package/dist/layout/module-permissions.js.map +1 -1
- package/dist/layout/seed-defaults.d.ts +45 -0
- package/dist/layout/seed-defaults.d.ts.map +1 -0
- package/dist/layout/seed-defaults.js +75 -0
- package/dist/layout/seed-defaults.js.map +1 -0
- package/dist/layout/walk.d.ts +14 -0
- package/dist/layout/walk.d.ts.map +1 -0
- package/dist/layout/walk.js +49 -0
- package/dist/layout/walk.js.map +1 -0
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/index.js.map +1 -1
- package/dist/media/upload-allowlist.d.ts +23 -0
- package/dist/media/upload-allowlist.d.ts.map +1 -1
- package/dist/media/upload-allowlist.js +29 -0
- package/dist/media/upload-allowlist.js.map +1 -1
- package/dist/schema/mysql-tables.d.ts +17 -0
- package/dist/schema/mysql-tables.d.ts.map +1 -1
- package/dist/schema/mysql-tables.js +2 -0
- package/dist/schema/mysql-tables.js.map +1 -1
- package/dist/scope/jsonld.d.ts +18 -3
- package/dist/scope/jsonld.d.ts.map +1 -1
- package/dist/scope/jsonld.js +6 -4
- package/dist/scope/jsonld.js.map +1 -1
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
541
|
+
}
|
|
542
|
+
function escAttr(text) {
|
|
543
|
+
return esc(text).replace(/"/g, '"');
|
|
544
|
+
}
|
|
545
|
+
//# sourceMappingURL=layout-to-html.js.map
|