@caelo-cms/shared 0.10.72 → 0.11.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/dist/ai-tools.d.ts +103 -142
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +79 -167
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +115 -35
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +31 -12
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +214 -102
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +219 -84
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/css-var-aliases.d.ts +20 -0
- package/dist/css-var-aliases.d.ts.map +1 -0
- package/dist/css-var-aliases.js +95 -0
- package/dist/css-var-aliases.js.map +1 -0
- package/dist/css-var-scan.d.ts +0 -16
- package/dist/css-var-scan.d.ts.map +1 -1
- package/dist/css-var-scan.js +24 -0
- package/dist/css-var-scan.js.map +1 -1
- package/dist/edit-markers.d.ts +41 -5
- package/dist/edit-markers.d.ts.map +1 -1
- package/dist/edit-markers.js +47 -50
- package/dist/edit-markers.js.map +1 -1
- package/dist/escaped-line-breaks.d.ts +44 -0
- package/dist/escaped-line-breaks.d.ts.map +1 -0
- package/dist/escaped-line-breaks.js +203 -0
- package/dist/escaped-line-breaks.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +6 -6
- package/dist/interactions.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +7 -1
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/link-refs.d.ts +1 -1
- package/dist/link-refs.js +3 -3
- package/dist/lists/address.d.ts +34 -0
- package/dist/lists/address.d.ts.map +1 -0
- package/dist/lists/address.js +43 -0
- package/dist/lists/address.js.map +1 -0
- package/dist/lists/default-types.d.ts +22 -0
- package/dist/lists/default-types.d.ts.map +1 -0
- package/dist/lists/default-types.js +127 -0
- package/dist/lists/default-types.js.map +1 -0
- package/dist/lists/fields.d.ts +152 -0
- package/dist/lists/fields.d.ts.map +1 -0
- package/dist/lists/fields.js +259 -0
- package/dist/lists/fields.js.map +1 -0
- package/dist/lists/index.d.ts +14 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +15 -0
- package/dist/lists/index.js.map +1 -0
- package/dist/lists/item-attr.d.ts +26 -0
- package/dist/lists/item-attr.d.ts.map +1 -0
- package/dist/lists/item-attr.js +31 -0
- package/dist/lists/item-attr.js.map +1 -0
- package/dist/lists/items.d.ts +105 -0
- package/dist/lists/items.d.ts.map +1 -0
- package/dist/lists/items.js +304 -0
- package/dist/lists/items.js.map +1 -0
- package/dist/lists/list-type.d.ts +401 -0
- package/dist/lists/list-type.d.ts.map +1 -0
- package/dist/lists/list-type.js +275 -0
- package/dist/lists/list-type.js.map +1 -0
- package/dist/lists/model.d.ts +14 -0
- package/dist/lists/model.d.ts.map +1 -0
- package/dist/lists/model.js +15 -0
- package/dist/lists/model.js.map +1 -0
- package/dist/lists/page-type.d.ts +115 -0
- package/dist/lists/page-type.d.ts.map +1 -0
- package/dist/lists/page-type.js +66 -0
- package/dist/lists/page-type.js.map +1 -0
- package/dist/lists/refs.d.ts +13 -0
- package/dist/lists/refs.d.ts.map +1 -0
- package/dist/lists/refs.js +18 -0
- package/dist/lists/refs.js.map +1 -0
- package/dist/lists/render-data.d.ts +123 -0
- package/dist/lists/render-data.d.ts.map +1 -0
- package/dist/lists/render-data.js +3 -0
- package/dist/lists/render-data.js.map +1 -0
- package/dist/lists/render-deps.d.ts +69 -0
- package/dist/lists/render-deps.d.ts.map +1 -0
- package/dist/lists/render-deps.js +93 -0
- package/dist/lists/render-deps.js.map +1 -0
- package/dist/lists/resolve-values.d.ts +63 -0
- package/dist/lists/resolve-values.d.ts.map +1 -0
- package/dist/lists/resolve-values.js +181 -0
- package/dist/lists/resolve-values.js.map +1 -0
- package/dist/lists/resolve.d.ts +119 -0
- package/dist/lists/resolve.d.ts.map +1 -0
- package/dist/lists/resolve.js +970 -0
- package/dist/lists/resolve.js.map +1 -0
- package/dist/lists/scope.d.ts +81 -0
- package/dist/lists/scope.d.ts.map +1 -0
- package/dist/lists/scope.js +3 -0
- package/dist/lists/scope.js.map +1 -0
- package/dist/markup-structure.d.ts +8 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +2 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +38 -20
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +156 -31
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts +24 -21
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +34 -118
- package/dist/preview-compose.js.map +1 -1
- package/dist/redirect-plan.d.ts +13 -0
- package/dist/redirect-plan.d.ts.map +1 -1
- package/dist/redirect-plan.js +22 -0
- package/dist/redirect-plan.js.map +1 -1
- package/dist/seo.d.ts +21 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +32 -0
- package/dist/seo.js.map +1 -1
- package/dist/source-text-transport.d.ts +43 -0
- package/dist/source-text-transport.d.ts.map +1 -0
- package/dist/source-text-transport.js +79 -0
- package/dist/source-text-transport.js.map +1 -0
- package/dist/template-engine.d.ts +44 -31
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +75 -174
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts +69 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/dist/template-sections.js +436 -0
- package/dist/template-sections.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/src/__tests__/redos-hardening.test.ts +51 -16
- package/src/ai-tools-add-module-modes.test.ts +51 -1
- package/src/ai-tools.test.ts +7 -10
- package/src/ai-tools.ts +90 -180
- package/src/bug-intake.test.ts +1 -1
- package/src/build-page.test.ts +34 -3
- package/src/build-page.ts +34 -11
- package/src/content.test.ts +137 -4
- package/src/content.ts +230 -85
- package/src/css-scope-scan.ts +1 -1
- package/src/css-var-aliases.test.ts +61 -0
- package/src/css-var-aliases.ts +109 -0
- package/src/css-var-scan.ts +9 -0
- package/src/edit-markers.test.ts +37 -21
- package/src/edit-markers.ts +52 -20
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +4 -1
- package/src/interactions.ts +6 -6
- package/src/link-canonicalise.test.ts +36 -6
- package/src/link-canonicalise.ts +6 -1
- package/src/link-refs.test.ts +67 -42
- package/src/link-refs.ts +3 -3
- package/src/lists/address.ts +75 -0
- package/src/lists/default-types.ts +141 -0
- package/src/lists/fields.ts +286 -0
- package/src/lists/index.ts +16 -0
- package/src/lists/item-attr.test.ts +36 -0
- package/src/lists/item-attr.ts +49 -0
- package/src/lists/items.ts +402 -0
- package/src/lists/list-fixtures.test.ts +308 -0
- package/src/lists/list-type.ts +306 -0
- package/src/lists/lists.test.ts +328 -0
- package/src/lists/model.ts +16 -0
- package/src/lists/page-type.ts +71 -0
- package/src/lists/refs.ts +21 -0
- package/src/lists/render-data.ts +134 -0
- package/src/lists/render-deps.test.ts +132 -0
- package/src/lists/render-deps.ts +147 -0
- package/src/lists/resolve-values.ts +224 -0
- package/src/lists/resolve.test.ts +472 -0
- package/src/lists/resolve.ts +1246 -0
- package/src/lists/scope.ts +69 -0
- package/src/lists-compose-parity.test.ts +262 -0
- package/src/markup-structure.ts +10 -0
- package/src/nested-module-render.test.ts +147 -74
- package/src/nested-module-render.ts +120 -48
- package/src/preview-compose.test.ts +55 -19
- package/src/preview-compose.ts +59 -154
- package/src/redirect-plan.test.ts +25 -0
- package/src/redirect-plan.ts +24 -0
- package/src/seo.test.ts +58 -0
- package/src/seo.ts +49 -0
- package/src/source-text-transport.test.ts +158 -0
- package/src/source-text-transport.ts +81 -0
- package/src/template-engine-lists.test.ts +608 -0
- package/src/template-engine.test.ts +62 -80
- package/src/template-engine.ts +102 -245
- package/src/template-sections.ts +514 -0
- package/src/version.ts +1 -1
- package/dist/structured-sets.d.ts +0 -70
- package/dist/structured-sets.d.ts.map +0 -1
- package/dist/structured-sets.js +0 -92
- package/dist/structured-sets.js.map +0 -1
- package/src/structured-sets.ts +0 -114
- package/src/template-engine-data-lists.test.ts +0 -90
package/src/preview-compose.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
|
29
29
|
import type { ModuleFieldKind } from "./content.js";
|
|
30
30
|
import { findInteractionIds, interactionAssets } from "./interactions.js";
|
|
31
31
|
import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
|
|
32
|
+
import type { ListPageContext } from "./lists/resolve.js";
|
|
32
33
|
import { tagModuleId } from "./module-id-tag.js";
|
|
33
34
|
import {
|
|
34
35
|
type NestedModuleResource,
|
|
@@ -60,8 +61,8 @@ export interface ComposeModule {
|
|
|
60
61
|
readonly js: string;
|
|
61
62
|
/**
|
|
62
63
|
* v0.4.0 module-field schema, extended in #71 to carry `kind` so
|
|
63
|
-
* the shared template engine can dispatch
|
|
64
|
-
* module
|
|
64
|
+
* the shared template engine can dispatch list / module-list /
|
|
65
|
+
* module sections (a `list` field carries its binding too). `kind` is optional for back-compat
|
|
65
66
|
* with callers that haven't been updated; the engine treats absent
|
|
66
67
|
* kinds as primitives (the legacy compose-path behaviour).
|
|
67
68
|
*
|
|
@@ -76,7 +77,7 @@ export interface ComposeModule {
|
|
|
76
77
|
* here via the `contentValues` field below. Nested-module refs
|
|
77
78
|
* render through `ComposeInput.nestedModules`.
|
|
78
79
|
*/
|
|
79
|
-
readonly fields?: readonly
|
|
80
|
+
readonly fields?: readonly ComposeField[];
|
|
80
81
|
/**
|
|
81
82
|
* PR #61 follow-up — per-placement content values from the bound
|
|
82
83
|
* `content_instances.values` jsonb. When present, the composer
|
|
@@ -87,20 +88,23 @@ export interface ComposeModule {
|
|
|
87
88
|
* visitors — the bug e2e-livedit's second Stage caught.
|
|
88
89
|
*/
|
|
89
90
|
readonly contentValues?: Readonly<Record<string, unknown>>;
|
|
91
|
+
/**
|
|
92
|
+
* The stored content instance `contentValues` come from — the address
|
|
93
|
+
* of the module's local list entries (overlays, `data-caelo-list-item`).
|
|
94
|
+
* Absent for site chrome rendered from field defaults and for content
|
|
95
|
+
* variants, which are no stored instance.
|
|
96
|
+
*/
|
|
97
|
+
readonly contentInstanceId?: string;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
/** A module field as the composer passes it to the engine; `kind` absent = primitive. */
|
|
101
|
+
export type ComposeField = Omit<TemplateField, "kind"> & { readonly kind?: ModuleFieldKind };
|
|
102
|
+
|
|
92
103
|
export interface ComposeBlock {
|
|
93
104
|
readonly blockName: string;
|
|
94
105
|
readonly modules: readonly ComposeModule[];
|
|
95
106
|
}
|
|
96
107
|
|
|
97
|
-
/** P6.7.5 — structured sets carried into the composer so nav-menu
|
|
98
|
-
* modules render from typed items and theme tokens flow into <head>. */
|
|
99
|
-
export interface ComposeStructuredSets {
|
|
100
|
-
/** Map keyed by `<kind>/<slug>` (e.g. `nav-menu/header-main`). */
|
|
101
|
-
readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
108
|
/**
|
|
105
109
|
* v0.11.0 — Theme context resolved by the preview op + static generator
|
|
106
110
|
* (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
|
|
@@ -149,7 +153,6 @@ export interface ComposeInput {
|
|
|
149
153
|
readonly templateHtml: string;
|
|
150
154
|
readonly templateCss: string;
|
|
151
155
|
readonly blocks: readonly ComposeBlock[];
|
|
152
|
-
readonly structuredSets?: ComposeStructuredSets;
|
|
153
156
|
/**
|
|
154
157
|
* v0.11.0 — active theme threaded through from the preview op + static
|
|
155
158
|
* generator. Undefined when no theme row exists OR when the caller
|
|
@@ -160,13 +163,14 @@ export interface ComposeInput {
|
|
|
160
163
|
/** issue #150 — resolved web fonts; undefined = system stacks only. */
|
|
161
164
|
readonly fonts?: ComposeFonts;
|
|
162
165
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
166
|
+
* The list view of the page being composed (`lists/resolve.ts`
|
|
167
|
+
* `createListRenderContext(data).forPage(pageId, …)`): resolves `list`
|
|
168
|
+
* fields — local, shared, query, system and plugin sources — and
|
|
169
|
+
* `{{page.*}}` in every placed, nested and chrome module alike, so a
|
|
170
|
+
* menu renders the same in the editor and on deploy, including the
|
|
171
|
+
* loud marker when its plugin source is off.
|
|
167
172
|
*/
|
|
168
|
-
readonly
|
|
169
|
-
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
173
|
+
readonly lists?: ListPageContext;
|
|
170
174
|
/**
|
|
171
175
|
* #450 — modules withheld by a plugin, keyed by module id. Resolved
|
|
172
176
|
* by the host before composing; absent means the module renders
|
|
@@ -186,7 +190,7 @@ export interface ComposeInput {
|
|
|
186
190
|
/**
|
|
187
191
|
* Epic #724 — resolves internal references (`caelo:page/<id>`,
|
|
188
192
|
* `caelo:media/<id>`) for THIS page: in every placed and nested
|
|
189
|
-
* module and in
|
|
193
|
+
* module and in list entries. A broken reference becomes a
|
|
190
194
|
* `link-target-missing:<kind>/<id>` module failure; without a resolver
|
|
191
195
|
* every reference fails with `link-resolver-unavailable` (loud — a
|
|
192
196
|
* deploy refuses it).
|
|
@@ -291,25 +295,11 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
291
295
|
// P6.7 — tag every module's outermost element with
|
|
292
296
|
// `data-caelo-module-id="<uuid>"` so the live-edit overlay's iframe
|
|
293
297
|
// hover affordances can identify the clicked module.
|
|
294
|
-
//
|
|
295
|
-
// P6.7.5 — modules whose slug matches a `nav-menu/<slug>` set get
|
|
296
|
-
// their HTML replaced by a fresh render of the menu items. That's
|
|
297
|
-
// what makes a slug change update every menu without touching
|
|
298
|
-
// module HTML.
|
|
299
298
|
const renderedModuleHtml = block.modules.map((m) => {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
baseHtml = nav.html;
|
|
305
|
-
recordFailures(moduleFailures, block.blockName, m, nav.failures);
|
|
306
|
-
} else {
|
|
307
|
-
const rendered = renderModuleFields(m, input);
|
|
308
|
-
baseHtml = rendered.html;
|
|
309
|
-
recordFailures(moduleFailures, block.blockName, m, rendered.failures);
|
|
310
|
-
nestedAssetIds.push(...rendered.nestedModuleIds);
|
|
311
|
-
}
|
|
312
|
-
return tagModuleId(baseHtml, m.moduleId);
|
|
299
|
+
const rendered = renderModuleFields(m, input);
|
|
300
|
+
recordFailures(moduleFailures, block.blockName, m, rendered.failures);
|
|
301
|
+
nestedAssetIds.push(...rendered.nestedModuleIds);
|
|
302
|
+
return tagModuleId(rendered.html, m.moduleId);
|
|
313
303
|
});
|
|
314
304
|
const html = renderedModuleHtml.join("\n");
|
|
315
305
|
contentByName.set(block.blockName, html);
|
|
@@ -388,33 +378,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
388
378
|
};
|
|
389
379
|
}
|
|
390
380
|
|
|
391
|
-
/**
|
|
392
|
-
* P6.7.5 — return the items for a `nav-menu/<slug>` set when a module's
|
|
393
|
-
* slug starts with `nav-menu-`. Returns null when the module is not a
|
|
394
|
-
* nav menu (so the composer falls back to its stored HTML).
|
|
395
|
-
*
|
|
396
|
-
* Convention: a module slug `nav-menu-header-main` resolves to
|
|
397
|
-
* structuredSets[`nav-menu/header-main`].
|
|
398
|
-
*/
|
|
399
|
-
function lookupNavMenuItems(
|
|
400
|
-
moduleSlug: string,
|
|
401
|
-
sets: ComposeStructuredSets | undefined,
|
|
402
|
-
): readonly unknown[] | null {
|
|
403
|
-
if (!sets) return null;
|
|
404
|
-
const prefix = "nav-menu-";
|
|
405
|
-
if (!moduleSlug.startsWith(prefix)) return null;
|
|
406
|
-
const setSlug = moduleSlug.slice(prefix.length);
|
|
407
|
-
const items = sets.byKindSlug[`nav-menu/${setSlug}`];
|
|
408
|
-
return items ?? null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
interface NavMenuItem {
|
|
412
|
-
label: string;
|
|
413
|
-
href: string;
|
|
414
|
-
target?: "_self" | "_blank";
|
|
415
|
-
children?: NavMenuItem[];
|
|
416
|
-
}
|
|
417
|
-
|
|
418
381
|
function escapeAttr(s: string): string {
|
|
419
382
|
return s
|
|
420
383
|
.replace(/&/g, "&")
|
|
@@ -422,49 +385,6 @@ function escapeAttr(s: string): string {
|
|
|
422
385
|
.replace(/</g, "<")
|
|
423
386
|
.replace(/>/g, ">");
|
|
424
387
|
}
|
|
425
|
-
function escapeText(s: string): string {
|
|
426
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Render a nav-menu's typed items into HTML. Recursively handles
|
|
431
|
-
* children for submenus. Plain `<nav><ul><li>` so site CSS can theme
|
|
432
|
-
* it via the `caelo-nav-menu` class.
|
|
433
|
-
*
|
|
434
|
-
* Epic #724 — item hrefs may be internal references; they resolve
|
|
435
|
-
* through `resolver` like module content, and a broken one is returned
|
|
436
|
-
* as a failure attributed to the nav-menu module.
|
|
437
|
-
*/
|
|
438
|
-
function renderNavMenuHtml(
|
|
439
|
-
items: readonly unknown[],
|
|
440
|
-
resolver: LinkResolver | undefined,
|
|
441
|
-
): { html: string; failures: NestedRenderFailure[] } {
|
|
442
|
-
const missing: string[] = [];
|
|
443
|
-
const renderNavItem = (item: NavMenuItem): string => {
|
|
444
|
-
const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
|
|
445
|
-
const inner =
|
|
446
|
-
item.children && item.children.length > 0
|
|
447
|
-
? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
|
|
448
|
-
: "";
|
|
449
|
-
const href = resolveLinkRefsInHtml(item.href, resolver, missing);
|
|
450
|
-
return `<li><a href="${escapeAttr(href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
|
|
451
|
-
};
|
|
452
|
-
const safeItems = items.filter((it): it is NavMenuItem => {
|
|
453
|
-
if (!it || typeof it !== "object") return false;
|
|
454
|
-
const o = it as { label?: unknown; href?: unknown };
|
|
455
|
-
return typeof o.label === "string" && typeof o.href === "string";
|
|
456
|
-
});
|
|
457
|
-
// issue #160 — mobile-ready markup: toggle button (three functional
|
|
458
|
-
// bars via currentColor) + collapsible list. `data-caelo-ui` pulls the
|
|
459
|
-
// functional CSS/JS from ./interactions.ts in once per page.
|
|
460
|
-
const toggle =
|
|
461
|
-
'<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
|
|
462
|
-
'<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
|
|
463
|
-
"</button>";
|
|
464
|
-
const html = `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
|
|
465
|
-
return { html, failures: missing.map((reason) => ({ field: "", reason })) };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
388
|
/**
|
|
469
389
|
* v0.11.0 — Render the active theme's DTCG tokens as `:root { … }` (+
|
|
470
390
|
* optional `:root.dark { … }`) CSS. Returns null when no active theme
|
|
@@ -516,19 +436,16 @@ function renderModuleFields(
|
|
|
516
436
|
m: ComposeModule,
|
|
517
437
|
input: {
|
|
518
438
|
readonly theme?: ComposeTheme;
|
|
519
|
-
readonly
|
|
520
|
-
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
439
|
+
readonly lists?: ListPageContext;
|
|
521
440
|
readonly nestedModules?: NestedRenderResolver;
|
|
522
441
|
readonly linkResolver?: LinkResolver;
|
|
523
442
|
},
|
|
524
443
|
): RenderedModuleFields {
|
|
525
444
|
// The substitution engine (renderTemplate) already unwraps CDATA
|
|
526
445
|
// guards; cover the no-op early-return path so a chrome module with no
|
|
527
|
-
// fields/values/theme is cleaned too.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
Object.keys(input.dormantDataLists ?? {}).length > 0;
|
|
531
|
-
if (!m.fields && !m.contentValues && !input.theme && !hasLists) {
|
|
446
|
+
// fields/values/theme is cleaned too. A list context means the HTML
|
|
447
|
+
// may iterate a plugin source or read `{{page.*}}` without any field.
|
|
448
|
+
if (!m.fields && !m.contentValues && !input.theme && !input.lists) {
|
|
532
449
|
// Still resolve references: pre-rendered (editor preview) and
|
|
533
450
|
// field-less chrome modules carry links in their raw HTML too.
|
|
534
451
|
const missing: string[] = [];
|
|
@@ -539,11 +456,7 @@ function renderModuleFields(
|
|
|
539
456
|
nestedModuleIds: [],
|
|
540
457
|
};
|
|
541
458
|
}
|
|
542
|
-
const fields: TemplateField[] = (m.fields ?? []).map((f) => ({
|
|
543
|
-
name: f.name,
|
|
544
|
-
kind: f.kind ?? "text",
|
|
545
|
-
default: f.default,
|
|
546
|
-
}));
|
|
459
|
+
const fields: TemplateField[] = (m.fields ?? []).map((f) => ({ ...f, kind: f.kind ?? "text" }));
|
|
547
460
|
// v0.11.1 (issue #76) — thread the active theme's asset URLs so
|
|
548
461
|
// module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
|
|
549
462
|
// slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
|
|
@@ -565,9 +478,9 @@ function renderModuleFields(
|
|
|
565
478
|
input.nestedModules,
|
|
566
479
|
{
|
|
567
480
|
themeAssets,
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
linkResolver: input.linkResolver,
|
|
481
|
+
...(input.lists ? { lists: input.lists } : {}),
|
|
482
|
+
...(m.contentInstanceId ? { contentInstanceId: m.contentInstanceId } : {}),
|
|
483
|
+
...(input.linkResolver ? { linkResolver: input.linkResolver } : {}),
|
|
571
484
|
},
|
|
572
485
|
);
|
|
573
486
|
return { html: r.html, failures: r.failures, nestedModuleIds: [...r.touchedModuleIds] };
|
|
@@ -576,8 +489,8 @@ function renderModuleFields(
|
|
|
576
489
|
html: m.html,
|
|
577
490
|
fields,
|
|
578
491
|
contentValues: m.contentValues,
|
|
579
|
-
|
|
580
|
-
|
|
492
|
+
...(input.lists ? { lists: input.lists } : {}),
|
|
493
|
+
listHolder: { contentInstanceId: m.contentInstanceId ?? null },
|
|
581
494
|
themeAssets,
|
|
582
495
|
linkResolver: input.linkResolver,
|
|
583
496
|
});
|
|
@@ -796,35 +709,27 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
796
709
|
const deferral = input.deferredModules?.[m.moduleId];
|
|
797
710
|
// Nested modules rendered inside THIS placement when it is withheld.
|
|
798
711
|
const gatedNestedIds = new Set<string>();
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
// Inside a withheld parent the nested module is withheld too:
|
|
821
|
-
// its CSS/JS go into the parent's <template>, never the page-wide
|
|
822
|
-
// bundles, or its `fetch()` / `url(…)` would run before consent.
|
|
823
|
-
if (deferral) {
|
|
824
|
-
if (id !== m.moduleId) gatedNestedIds.add(id);
|
|
825
|
-
} else {
|
|
826
|
-
nestedAssetIds.push(id);
|
|
827
|
-
}
|
|
712
|
+
const rendered = renderModuleFields(m, input);
|
|
713
|
+
const baseHtml = rendered.html;
|
|
714
|
+
recordFailures(moduleFailures, blockName, m, rendered.failures);
|
|
715
|
+
for (const id of rendered.nestedModuleIds) {
|
|
716
|
+
const nestedDeferral = input.deferredModules?.[id];
|
|
717
|
+
if (nestedDeferral) {
|
|
718
|
+
const nestedSlug = input.nestedModules?.getModule(id)?.slug ?? id;
|
|
719
|
+
throw new ComposeError(
|
|
720
|
+
"nested-module-deferred",
|
|
721
|
+
`module "${nestedSlug}" is withheld by plugin "${nestedDeferral.pluginSlug}" (${nestedDeferral.reason}) but sits inside module "${m.slug}" in block "${blockName}", where it cannot be gated — ` +
|
|
722
|
+
"place it on the page directly instead of inside another module's field",
|
|
723
|
+
input.layoutSlug,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
// Inside a withheld parent the nested module is withheld too:
|
|
727
|
+
// its CSS/JS go into the parent's <template>, never the page-wide
|
|
728
|
+
// bundles, or its `fetch()` / `url(…)` would run before consent.
|
|
729
|
+
if (deferral) {
|
|
730
|
+
if (id !== m.moduleId) gatedNestedIds.add(id);
|
|
731
|
+
} else {
|
|
732
|
+
nestedAssetIds.push(id);
|
|
828
733
|
}
|
|
829
734
|
}
|
|
830
735
|
const tagged = tagModuleId(baseHtml, m.moduleId);
|
|
@@ -905,8 +810,8 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
905
810
|
let html = replaced.html;
|
|
906
811
|
|
|
907
812
|
// issue #160 — every vetted interaction the page's markup asks for
|
|
908
|
-
// (`data-caelo-ui="…"`, from modules, the
|
|
909
|
-
//
|
|
813
|
+
// (`data-caelo-ui="…"`, from modules, the layout or the template,
|
|
814
|
+
// withheld modules included) ships once, after module
|
|
910
815
|
// CSS/JS so its functional rules win over a module's display rules.
|
|
911
816
|
const interactions = interactionAssets(new Set(findInteractionIds(html).known));
|
|
912
817
|
cssParts.push(...interactions.css);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatRedirectsFile,
|
|
12
12
|
parseRedirectsFile,
|
|
13
13
|
type RedirectTableRow,
|
|
14
|
+
redirectsToUnservedPaths,
|
|
14
15
|
} from "./redirect-plan.js";
|
|
15
16
|
|
|
16
17
|
const NO_FILES: ReadonlySet<string> = new Set();
|
|
@@ -141,3 +142,27 @@ describe("buildRedirectPlan", () => {
|
|
|
141
142
|
expect(plan(rows)).toEqual(plan([...rows]));
|
|
142
143
|
});
|
|
143
144
|
});
|
|
145
|
+
|
|
146
|
+
describe("redirectsToUnservedPaths (#1157)", () => {
|
|
147
|
+
const served = new Set(["index.html", "live/index.html", "doc.pdf"]);
|
|
148
|
+
it("reports redirects whose target the build does not serve, nothing else", () => {
|
|
149
|
+
const p = plan(
|
|
150
|
+
[
|
|
151
|
+
row("/a", "/live"),
|
|
152
|
+
row("/b", "/draft-page"),
|
|
153
|
+
row("/c", "/doc.pdf"),
|
|
154
|
+
row("/d", "/missing.pdf"),
|
|
155
|
+
row("/e", "/api/forms/x"),
|
|
156
|
+
row("/f", "/live?ref=1"),
|
|
157
|
+
row("/g", "/"),
|
|
158
|
+
],
|
|
159
|
+
served,
|
|
160
|
+
);
|
|
161
|
+
expect(redirectsToUnservedPaths(p, served).map((r) => r.fromPath)).toEqual(["/b", "/d"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("judges a chain by its final hop", () => {
|
|
165
|
+
const p = plan([row("/x", "/y"), row("/y", "/live")], served);
|
|
166
|
+
expect(redirectsToUnservedPaths(p, served)).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/redirect-plan.ts
CHANGED
|
@@ -178,6 +178,30 @@ export function buildRedirectPlan(args: BuildRedirectPlanArgs): RedirectPlan {
|
|
|
178
178
|
return { redirects, gone, shadowedByBuild };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The planned redirects whose Location is a same-site path the build does
|
|
183
|
+
* not serve (#1157). Redirects reach the live site on save, pages only with
|
|
184
|
+
* Publish live, so a redirect to a page that is still a draft answers 404 at
|
|
185
|
+
* its target until that page is published. Callers report these; they are
|
|
186
|
+
* not an error. Paths under `/api/` are answered by the gateway, not the
|
|
187
|
+
* build, and are never reported.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* redirectsToUnservedPaths(plan, new Set(["index.html"]))
|
|
191
|
+
* // → the entries of `plan.redirects` pointing anywhere but `/`
|
|
192
|
+
*/
|
|
193
|
+
export function redirectsToUnservedPaths(
|
|
194
|
+
plan: RedirectPlan,
|
|
195
|
+
servedFiles: ReadonlySet<string>,
|
|
196
|
+
): PlannedRedirect[] {
|
|
197
|
+
return plan.redirects.filter((r) => {
|
|
198
|
+
const path = pathBeforeQuery(r.location);
|
|
199
|
+
if (path === "/api" || path.startsWith("/api/")) return false;
|
|
200
|
+
if (looksLikeFile(path)) return !servedFiles.has(path.slice(1));
|
|
201
|
+
return !servedByBuild(stripTrailingSlash(path), servedFiles);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
function normaliseRows(rows: readonly RedirectTableRow[]): NormalisedRow[] {
|
|
182
206
|
const byBare = new Map<string, NormalisedRow>();
|
|
183
207
|
const out: NormalisedRow[] = [];
|
package/src/seo.test.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
4
|
import {
|
|
5
|
+
breadcrumbJsonLdItems,
|
|
5
6
|
checkPublicSiteBaseUrl,
|
|
6
7
|
injectSeoIntoHead,
|
|
7
8
|
renderSeoHead,
|
|
@@ -402,3 +403,60 @@ describe("checkPublicSiteBaseUrl", () => {
|
|
|
402
403
|
}
|
|
403
404
|
});
|
|
404
405
|
});
|
|
406
|
+
|
|
407
|
+
describe("BreadcrumbList JSON-LD (#1142)", () => {
|
|
408
|
+
const base = {
|
|
409
|
+
title: "Team",
|
|
410
|
+
metaDescription: "",
|
|
411
|
+
canonical: "https://example.com/about/team/",
|
|
412
|
+
noindex: false,
|
|
413
|
+
ogImageUrl: null,
|
|
414
|
+
organization: {},
|
|
415
|
+
};
|
|
416
|
+
const ldBlocks = (head: string): unknown[] =>
|
|
417
|
+
[...head.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) =>
|
|
418
|
+
JSON.parse(m[1] as string),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
it("lists the trail with positions, short titles and public URLs", () => {
|
|
422
|
+
const head = renderSeoHead({
|
|
423
|
+
...base,
|
|
424
|
+
breadcrumbs: [
|
|
425
|
+
{ name: "Start", url: "https://example.com/" },
|
|
426
|
+
{ name: "About </script>", url: "https://example.com/about/" },
|
|
427
|
+
{ name: "Team", url: "https://example.com/about/team/" },
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
expect(head).not.toContain("About </script>");
|
|
431
|
+
expect(ldBlocks(head)[1]).toEqual({
|
|
432
|
+
"@context": "https://schema.org",
|
|
433
|
+
"@type": "BreadcrumbList",
|
|
434
|
+
itemListElement: [
|
|
435
|
+
{ "@type": "ListItem", position: 1, name: "Start", item: "https://example.com/" },
|
|
436
|
+
{
|
|
437
|
+
"@type": "ListItem",
|
|
438
|
+
position: 2,
|
|
439
|
+
name: "About </script>",
|
|
440
|
+
item: "https://example.com/about/",
|
|
441
|
+
},
|
|
442
|
+
{ "@type": "ListItem", position: 3, name: "Team", item: "https://example.com/about/team/" },
|
|
443
|
+
],
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("emits no BreadcrumbList without a trail", () => {
|
|
448
|
+
expect(ldBlocks(renderSeoHead(base))).toHaveLength(1);
|
|
449
|
+
expect(ldBlocks(renderSeoHead({ ...base, breadcrumbs: [] }))).toHaveLength(1);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("builds items from the trail, leaving out pages without a public URL", () => {
|
|
453
|
+
const known: Record<string, { name: string; url: string }> = {
|
|
454
|
+
home: { name: "Start", url: "https://example.com/" },
|
|
455
|
+
team: { name: "Team", url: "https://example.com/about/team/" },
|
|
456
|
+
};
|
|
457
|
+
expect(
|
|
458
|
+
breadcrumbJsonLdItems(["home", "draft-parent", "team"], (id) => known[id] ?? null),
|
|
459
|
+
).toEqual([known.home, known.team]);
|
|
460
|
+
expect(breadcrumbJsonLdItems(null, () => null)).toEqual([]);
|
|
461
|
+
});
|
|
462
|
+
});
|
package/src/seo.ts
CHANGED
|
@@ -265,6 +265,40 @@ export interface SeoMetaInput {
|
|
|
265
265
|
noindex: boolean;
|
|
266
266
|
ogImageUrl: string | null;
|
|
267
267
|
organization: SiteSeoSettings["organization"];
|
|
268
|
+
/**
|
|
269
|
+
* #1142 — the breadcrumb trail the page SHOWS (home → current), as
|
|
270
|
+
* built by {@link breadcrumbJsonLdItems}; null or empty = the page shows
|
|
271
|
+
* none and gets no `BreadcrumbList`, so structured data never claims a
|
|
272
|
+
* trail a visitor cannot see.
|
|
273
|
+
*/
|
|
274
|
+
breadcrumbs?: readonly BreadcrumbJsonLdItem[] | null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One crumb of a `BreadcrumbList`: the page's short title and public URL. */
|
|
278
|
+
export interface BreadcrumbJsonLdItem {
|
|
279
|
+
readonly name: string;
|
|
280
|
+
readonly url: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The `BreadcrumbList` items of a rendered trail (#1142). `lookup` answers
|
|
285
|
+
* a trail page's short title and public URL, or null when the page has no
|
|
286
|
+
* public URL in this render (not published). Such a crumb is left out:
|
|
287
|
+
* its visible link is already flagged by the unpublished-link gate, and a
|
|
288
|
+
* `ListItem` without a URL is only valid in the last position. The
|
|
289
|
+
* generator and the editor preview both build their items through here.
|
|
290
|
+
*/
|
|
291
|
+
export function breadcrumbJsonLdItems(
|
|
292
|
+
trail: readonly string[] | null,
|
|
293
|
+
lookup: (pageId: string) => BreadcrumbJsonLdItem | null,
|
|
294
|
+
): BreadcrumbJsonLdItem[] {
|
|
295
|
+
if (trail === null) return [];
|
|
296
|
+
const out: BreadcrumbJsonLdItem[] = [];
|
|
297
|
+
for (const id of trail) {
|
|
298
|
+
const item = lookup(id);
|
|
299
|
+
if (item !== null) out.push(item);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
268
302
|
}
|
|
269
303
|
|
|
270
304
|
/**
|
|
@@ -330,6 +364,21 @@ export function renderSeoHead(input: SeoMetaInput): string {
|
|
|
330
364
|
lines.push(
|
|
331
365
|
`<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
|
|
332
366
|
);
|
|
367
|
+
if (input.breadcrumbs && input.breadcrumbs.length > 0) {
|
|
368
|
+
const list = {
|
|
369
|
+
"@context": "https://schema.org",
|
|
370
|
+
"@type": "BreadcrumbList",
|
|
371
|
+
itemListElement: input.breadcrumbs.map((b, i) => ({
|
|
372
|
+
"@type": "ListItem",
|
|
373
|
+
position: i + 1,
|
|
374
|
+
name: b.name,
|
|
375
|
+
item: b.url,
|
|
376
|
+
})),
|
|
377
|
+
};
|
|
378
|
+
lines.push(
|
|
379
|
+
`<script type="application/ld+json">${JSON.stringify(list).replace(/</g, "\\u003c")}</script>`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
333
382
|
return lines.join("\n");
|
|
334
383
|
}
|
|
335
384
|
|