@caelo-cms/shared 0.10.23 → 0.10.26
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 +15 -70
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +14 -74
- package/dist/ai-tools.js.map +1 -1
- package/dist/base-css.d.ts +11 -0
- package/dist/base-css.d.ts.map +1 -1
- package/dist/base-css.js +13 -1
- package/dist/base-css.js.map +1 -1
- package/dist/build-page.d.ts +0 -2
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +2 -3
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +0 -3
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +91 -7
- package/dist/content.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/design-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.js.map +1 -0
- package/dist/font-assets.d.ts +89 -0
- package/dist/font-assets.d.ts.map +1 -0
- package/dist/font-assets.js +61 -0
- package/dist/font-assets.js.map +1 -0
- package/dist/genesis.d.ts +43 -3
- package/dist/genesis.d.ts.map +1 -1
- package/dist/genesis.js +72 -5
- package/dist/genesis.js.map +1 -1
- package/dist/google-models.d.ts +35 -0
- package/dist/google-models.d.ts.map +1 -0
- package/dist/google-models.js +20 -0
- package/dist/google-models.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +0 -3
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +0 -5
- package/dist/media.js.map +1 -1
- package/dist/plugin-preview.d.ts +44 -0
- package/dist/plugin-preview.d.ts.map +1 -0
- package/dist/plugin-preview.js +69 -0
- package/dist/plugin-preview.js.map +1 -0
- package/dist/preview-compose.d.ts +35 -15
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +95 -89
- package/dist/preview-compose.js.map +1 -1
- package/dist/responsive-images.d.ts +6 -5
- package/dist/responsive-images.d.ts.map +1 -1
- package/dist/responsive-images.js +6 -5
- package/dist/responsive-images.js.map +1 -1
- package/dist/seo.d.ts +11 -28
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +12 -31
- package/dist/seo.js.map +1 -1
- package/dist/structured-sets.d.ts +0 -36
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +1 -52
- package/dist/structured-sets.js.map +1 -1
- package/dist/template-engine.d.ts +18 -0
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +39 -2
- package/dist/template-engine.js.map +1 -1
- package/dist/theme-importers/loose.js +1 -1
- package/dist/theme-importers/loose.js.map +1 -1
- package/dist/theme-normalize.d.ts +9 -0
- package/dist/theme-normalize.d.ts.map +1 -1
- package/dist/theme-normalize.js +65 -6
- package/dist/theme-normalize.js.map +1 -1
- package/dist/theme-render.d.ts +15 -0
- package/dist/theme-render.d.ts.map +1 -1
- package/dist/theme-render.js +39 -0
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts +15 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +48 -1
- package/dist/themes.js.map +1 -1
- package/dist/url.d.ts +45 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +64 -0
- package/dist/url.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/redos-hardening.test.ts +1 -1
- package/src/ai-tools.ts +14 -90
- package/src/base-css.ts +13 -1
- package/src/build-page.ts +3 -5
- package/src/content.test.ts +99 -17
- package/src/content.ts +95 -8
- package/src/context.ts +6 -0
- package/src/design-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -0
- package/src/font-assets.ts +63 -0
- package/src/genesis.ts +76 -5
- package/src/google-models.ts +20 -0
- package/src/index.ts +5 -3
- package/src/media.ts +0 -6
- package/src/plugin-preview.test.ts +79 -0
- package/src/plugin-preview.ts +81 -0
- package/src/preview-compose-deferrals.test.ts +144 -0
- package/src/preview-compose.test.ts +51 -0
- package/src/preview-compose.ts +128 -93
- package/src/responsive-images.ts +6 -5
- package/src/seo.test.ts +8 -48
- package/src/seo.ts +16 -52
- package/src/structured-sets.ts +1 -67
- package/src/template-engine-data-lists.test.ts +90 -0
- package/src/template-engine.ts +72 -1
- package/src/theme-importers/loose.ts +1 -1
- package/src/theme-normalize.ts +70 -8
- package/src/theme-render.ts +36 -0
- package/src/theme-token-roles.test.ts +182 -0
- package/src/themes.ts +53 -0
- package/src/url.test.ts +40 -0
- package/src/url.ts +69 -0
- package/src/version.ts +1 -1
- package/dist/design-manifest.d.ts +0 -36
- package/dist/design-manifest.d.ts.map +0 -1
- package/dist/design-manifest.js +0 -90
- package/dist/design-manifest.js.map +0 -1
- package/dist/i18n.d.ts +0 -92
- package/dist/i18n.d.ts.map +0 -1
- package/dist/i18n.js +0 -226
- package/dist/i18n.js.map +0 -1
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- package/dist/translation.js.map +0 -1
- package/src/design-manifest.ts +0 -93
- package/src/i18n.test.ts +0 -274
- package/src/i18n.ts +0 -269
- package/src/translation.test.ts +0 -160
- package/src/translation.ts +0 -295
package/src/preview-compose.ts
CHANGED
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
listSlotNames,
|
|
35
35
|
} from "./preview-scanner.js";
|
|
36
36
|
import { stripCdataGuards } from "./strip-cdata.js";
|
|
37
|
-
import { type LanguageSelectorOverride, renderLanguageSelector } from "./structured-sets.js";
|
|
38
37
|
import { renderTemplate, type TemplateField } from "./template-engine.js";
|
|
39
38
|
import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
|
|
40
39
|
import type { ThemeDocument } from "./themes.js";
|
|
@@ -90,21 +89,6 @@ export interface ComposeStructuredSets {
|
|
|
90
89
|
readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
/**
|
|
94
|
-
* P9 — language-selector context. The caller (preview op + static
|
|
95
|
-
* generator) resolves the current page's per-locale URLs once and
|
|
96
|
-
* threads them in. The composer renders the selector when a module
|
|
97
|
-
* slug starts with `language-selector-`.
|
|
98
|
-
*/
|
|
99
|
-
export interface ComposeLanguageSelector {
|
|
100
|
-
readonly availableLocales: ReadonlyArray<{
|
|
101
|
-
code: string;
|
|
102
|
-
displayName: string;
|
|
103
|
-
href: string;
|
|
104
|
-
isCurrent: boolean;
|
|
105
|
-
}>;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
92
|
/**
|
|
109
93
|
* v0.11.0 — Theme context resolved by the preview op + static generator
|
|
110
94
|
* (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
|
|
@@ -147,7 +131,6 @@ export interface ComposeInput {
|
|
|
147
131
|
readonly templateCss: string;
|
|
148
132
|
readonly blocks: readonly ComposeBlock[];
|
|
149
133
|
readonly structuredSets?: ComposeStructuredSets;
|
|
150
|
-
readonly languageSelector?: ComposeLanguageSelector;
|
|
151
134
|
/**
|
|
152
135
|
* v0.11.0 — active theme threaded through from the preview op + static
|
|
153
136
|
* generator. Undefined when no theme row exists OR when the caller
|
|
@@ -157,6 +140,21 @@ export interface ComposeInput {
|
|
|
157
140
|
readonly theme?: ComposeTheme;
|
|
158
141
|
/** issue #150 — resolved web fonts; undefined = system stacks only. */
|
|
159
142
|
readonly fonts?: ComposeFonts;
|
|
143
|
+
/**
|
|
144
|
+
* Plugin-provided lists for the page being composed, plus the names
|
|
145
|
+
* declared by installed-but-inactive plugins. Threaded so a module
|
|
146
|
+
* iterating `{{#language_links}}` renders the same on deploy as in
|
|
147
|
+
* the editor — including the loud marker when its plugin is off.
|
|
148
|
+
*/
|
|
149
|
+
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
150
|
+
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
151
|
+
/**
|
|
152
|
+
* #450 — modules withheld by a plugin, keyed by module id. Resolved
|
|
153
|
+
* by the host before composing; absent means the module renders
|
|
154
|
+
* normally, which is the case for every module on a site with no
|
|
155
|
+
* gating plugin active.
|
|
156
|
+
*/
|
|
157
|
+
readonly deferredModules?: Readonly<Record<string, ComposeDeferral>>;
|
|
160
158
|
}
|
|
161
159
|
|
|
162
160
|
export interface ComposeOutput {
|
|
@@ -180,11 +178,16 @@ function injectBefore(source: string, marker: RegExp, fragment: string): string
|
|
|
180
178
|
* `crossorigin` attribute is REQUIRED for font preloads even same-origin,
|
|
181
179
|
* per the fetch spec's font-destination CORS rule) + the @font-face
|
|
182
180
|
* block. Empty css with no preloads → null (system-stack-only theme).
|
|
181
|
+
* Exported for the design-draft theme shell (#375) so draft previews
|
|
182
|
+
* load the identical fonts as the page preview.
|
|
183
183
|
*/
|
|
184
|
-
function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
|
|
184
|
+
export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
|
|
185
185
|
if (fonts === undefined) return null;
|
|
186
186
|
const links = fonts.preloads
|
|
187
|
-
.map((href) =>
|
|
187
|
+
.map((href) => {
|
|
188
|
+
const format = /\.(woff2?|ttf|otf)(?:[?#]|$)/.exec(href)?.[1];
|
|
189
|
+
return `<link rel="preload" as="font"${format ? ` type="font/${format}"` : ""} crossorigin href="${href}">`;
|
|
190
|
+
})
|
|
188
191
|
.join("");
|
|
189
192
|
const style =
|
|
190
193
|
fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
|
|
@@ -193,6 +196,13 @@ function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
|
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
199
|
+
// No withholding path here; rendering a withheld module would ship it
|
|
200
|
+
// ungated, so refuse instead of degrading silently (CLAUDE.md §2).
|
|
201
|
+
if (input.deferredModules && Object.keys(input.deferredModules).length > 0) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
"composePagePreview cannot withhold modules; compose pages with deferrals through composePageWithLayout",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
196
206
|
const contentByName = new Map<string, string>();
|
|
197
207
|
const allCss: string[] = [];
|
|
198
208
|
const allJs: string[] = [];
|
|
@@ -214,15 +224,12 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
214
224
|
// module HTML.
|
|
215
225
|
const renderedModuleHtml = block.modules.map((m) => {
|
|
216
226
|
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
217
|
-
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
218
227
|
let baseHtml: string;
|
|
219
228
|
if (navMenuItems !== null) {
|
|
220
229
|
navRendered = true;
|
|
221
230
|
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
222
|
-
} else if (langSelector !== null) {
|
|
223
|
-
baseHtml = langSelector;
|
|
224
231
|
} else {
|
|
225
|
-
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
232
|
+
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
|
|
226
233
|
}
|
|
227
234
|
return tagModuleId(baseHtml, m.moduleId);
|
|
228
235
|
});
|
|
@@ -314,35 +321,6 @@ function lookupNavMenuItems(
|
|
|
314
321
|
return items ?? null;
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
/**
|
|
318
|
-
* P9 — return rendered language-selector HTML when a module's slug
|
|
319
|
-
* starts with `language-selector-`. The set's items act as overrides
|
|
320
|
-
* (relabel a locale, hide one); the available-locale list comes from
|
|
321
|
-
* the caller via `input.languageSelector`. Returns null when the
|
|
322
|
-
* module is not a language selector.
|
|
323
|
-
*
|
|
324
|
-
* Convention: a module slug `language-selector-header` resolves to
|
|
325
|
-
* structuredSets[`language-selector/header`] for overrides, and the
|
|
326
|
-
* rendered HTML lists every locale that has a published variant of
|
|
327
|
-
* the current page.
|
|
328
|
-
*/
|
|
329
|
-
function lookupLanguageSelector(moduleSlug: string, input: ComposeInput): string | null {
|
|
330
|
-
const prefix = "language-selector-";
|
|
331
|
-
if (!moduleSlug.startsWith(prefix)) return null;
|
|
332
|
-
if (!input.languageSelector || input.languageSelector.availableLocales.length === 0) {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
const setSlug = moduleSlug.slice(prefix.length);
|
|
336
|
-
const overridesUnknown = input.structuredSets?.byKindSlug[`language-selector/${setSlug}`];
|
|
337
|
-
const overrides = Array.isArray(overridesUnknown)
|
|
338
|
-
? (overridesUnknown as LanguageSelectorOverride[])
|
|
339
|
-
: undefined;
|
|
340
|
-
return renderLanguageSelector({
|
|
341
|
-
availableLocales: input.languageSelector.availableLocales,
|
|
342
|
-
overrides,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
324
|
interface NavMenuItem {
|
|
347
325
|
label: string;
|
|
348
326
|
href: string;
|
|
@@ -436,11 +414,18 @@ function applyFieldSubstitution(
|
|
|
436
414
|
fields: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[] | undefined,
|
|
437
415
|
contentValues: Readonly<Record<string, unknown>> | undefined,
|
|
438
416
|
theme: ComposeTheme | undefined,
|
|
417
|
+
lists: {
|
|
418
|
+
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
419
|
+
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
420
|
+
},
|
|
439
421
|
): string {
|
|
440
422
|
// The substitution engine (renderTemplate) already unwraps CDATA
|
|
441
423
|
// guards; cover the no-op early-return path so a chrome module with no
|
|
442
424
|
// fields/values/theme is cleaned too.
|
|
443
|
-
|
|
425
|
+
const hasLists =
|
|
426
|
+
Object.keys(lists.dataLists ?? {}).length > 0 ||
|
|
427
|
+
Object.keys(lists.dormantDataLists ?? {}).length > 0;
|
|
428
|
+
if (!fields && !contentValues && !theme && !hasLists) return stripCdataGuards(html);
|
|
444
429
|
const engineFields: TemplateField[] = (fields ?? []).map((f) => ({
|
|
445
430
|
name: f.name,
|
|
446
431
|
kind: f.kind ?? "text",
|
|
@@ -450,6 +435,8 @@ function applyFieldSubstitution(
|
|
|
450
435
|
html,
|
|
451
436
|
fields: engineFields,
|
|
452
437
|
contentValues,
|
|
438
|
+
dataLists: lists.dataLists,
|
|
439
|
+
dormantDataLists: lists.dormantDataLists,
|
|
453
440
|
// v0.11.1 (issue #76) — thread the active theme's asset URLs so
|
|
454
441
|
// module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
|
|
455
442
|
// slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
|
|
@@ -492,6 +479,58 @@ export function tagModuleId(html: string, moduleId: string): string {
|
|
|
492
479
|
* static generator) surface it as a structured failure rather than
|
|
493
480
|
* silently emitting broken HTML.
|
|
494
481
|
*/
|
|
482
|
+
/**
|
|
483
|
+
* A module withheld from this render, as resolved by the plugin host
|
|
484
|
+
* (#450). The composer never decides this — it only knows the verdict.
|
|
485
|
+
*/
|
|
486
|
+
export interface ComposeDeferral {
|
|
487
|
+
readonly pluginSlug: string;
|
|
488
|
+
readonly reason: string;
|
|
489
|
+
readonly placeholderModuleSlug: string;
|
|
490
|
+
readonly placeholderHtml: string;
|
|
491
|
+
readonly placeholderCss: string;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Emit a withheld module: the visible placeholder, plus the real markup
|
|
496
|
+
* parked in an inert `<template>`.
|
|
497
|
+
*
|
|
498
|
+
* `<template>` is the whole point. Browsers parse its contents but
|
|
499
|
+
* instantiate nothing — no image, iframe, script or stylesheet inside
|
|
500
|
+
* one is ever fetched. So a video module behind a consent gate does not
|
|
501
|
+
* touch YouTube until the plugin's runtime clones the content out,
|
|
502
|
+
* which is a fact about the network rather than a promise about the
|
|
503
|
+
* DOM. Hiding the module with CSS or stripping attributes in script
|
|
504
|
+
* would both leave the request already sent.
|
|
505
|
+
*
|
|
506
|
+
* The module's CSS and JS go into the same `<template>` and NEVER into
|
|
507
|
+
* the page-wide bundles: a `url(https://maps.gstatic.com/…)` in its CSS
|
|
508
|
+
* or a `fetch()` in its JS reaches the vendor exactly as surely as an
|
|
509
|
+
* `<iframe src>` does. The JS is parked as `type="text/plain"` (inert
|
|
510
|
+
* even once cloned) and the plugin runtime executes it once per module,
|
|
511
|
+
* after the markup it expects is in the DOM.
|
|
512
|
+
*/
|
|
513
|
+
function wrapDeferredModule(
|
|
514
|
+
moduleHtml: string,
|
|
515
|
+
module: Pick<ComposeModule, "moduleId" | "slug" | "css" | "js">,
|
|
516
|
+
deferral: ComposeDeferral,
|
|
517
|
+
): string {
|
|
518
|
+
const attr = (v: string): string =>
|
|
519
|
+
v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
520
|
+
const css =
|
|
521
|
+
module.css.trim().length > 0 ? `<style data-source="module">${module.css}</style>` : "";
|
|
522
|
+
const js =
|
|
523
|
+
module.js.trim().length > 0
|
|
524
|
+
? `<script type="text/plain" data-caelo-deferred-script="${attr(module.moduleId)}">${module.js}</script>`
|
|
525
|
+
: "";
|
|
526
|
+
return [
|
|
527
|
+
`<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}">`,
|
|
528
|
+
`<div data-caelo-deferred-placeholder>${deferral.placeholderHtml}</div>`,
|
|
529
|
+
`<template data-caelo-deferred-content>${css}${moduleHtml}${js}</template>`,
|
|
530
|
+
`</div>`,
|
|
531
|
+
].join("");
|
|
532
|
+
}
|
|
533
|
+
|
|
495
534
|
export interface ComposeLayoutBlock {
|
|
496
535
|
readonly blockName: string;
|
|
497
536
|
readonly modules: readonly ComposeModule[];
|
|
@@ -584,34 +623,44 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
584
623
|
// Duplicate rule blocks made the cascade order-dependent and bloated
|
|
585
624
|
// every page the same module appeared on twice.
|
|
586
625
|
const seenAssetModules = new Set<string>();
|
|
626
|
+
// Placeholder CSS, keyed by placeholder slug: one withheld module's
|
|
627
|
+
// placeholder used on five placements is emitted once.
|
|
628
|
+
const deferredCss = new Map<string, string>();
|
|
587
629
|
if (input.layoutCss.trim().length > 0) cssParts.push(input.layoutCss);
|
|
588
630
|
if (input.templateCss.trim().length > 0) cssParts.push(input.templateCss);
|
|
589
631
|
|
|
590
632
|
// 1. Render the page modules into the template (slot replacement only;
|
|
591
633
|
// no head/body manipulation here — that belongs to the layout).
|
|
592
634
|
const templateContentByName = new Map<string, string>();
|
|
635
|
+
const renderPlaced = (m: ComposeModule): string => {
|
|
636
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
637
|
+
let baseHtml: string;
|
|
638
|
+
if (navMenuItems !== null) {
|
|
639
|
+
navRendered = true;
|
|
640
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
641
|
+
} else {
|
|
642
|
+
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme, input);
|
|
643
|
+
}
|
|
644
|
+
const tagged = tagModuleId(baseHtml, m.moduleId);
|
|
645
|
+
const deferral = input.deferredModules?.[m.moduleId];
|
|
646
|
+
if (!deferral) return tagged;
|
|
647
|
+
deferredCss.set(deferral.placeholderModuleSlug, deferral.placeholderCss);
|
|
648
|
+
return wrapDeferredModule(tagged, m, deferral);
|
|
649
|
+
};
|
|
650
|
+
// A withheld module's CSS/JS travel inside its <template> (see
|
|
651
|
+
// wrapDeferredModule); only modules that render normally feed the
|
|
652
|
+
// page-wide bundles.
|
|
653
|
+
const collectAssets = (m: ComposeModule): void => {
|
|
654
|
+
if (seenAssetModules.has(m.moduleId)) return;
|
|
655
|
+
seenAssetModules.add(m.moduleId);
|
|
656
|
+
if (input.deferredModules?.[m.moduleId]) return;
|
|
657
|
+
if (m.css.trim().length > 0) cssParts.push(m.css);
|
|
658
|
+
if (m.js.trim().length > 0) jsParts.push(m.js);
|
|
659
|
+
};
|
|
593
660
|
for (const block of input.blocks) {
|
|
594
|
-
const renderedModuleHtml = block.modules.map(
|
|
595
|
-
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
596
|
-
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
597
|
-
let baseHtml: string;
|
|
598
|
-
if (navMenuItems !== null) {
|
|
599
|
-
navRendered = true;
|
|
600
|
-
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
601
|
-
} else if (langSelector !== null) {
|
|
602
|
-
baseHtml = langSelector;
|
|
603
|
-
} else {
|
|
604
|
-
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
605
|
-
}
|
|
606
|
-
return tagModuleId(baseHtml, m.moduleId);
|
|
607
|
-
});
|
|
661
|
+
const renderedModuleHtml = block.modules.map(renderPlaced);
|
|
608
662
|
templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
609
|
-
for (const m of block.modules)
|
|
610
|
-
if (seenAssetModules.has(m.moduleId)) continue;
|
|
611
|
-
seenAssetModules.add(m.moduleId);
|
|
612
|
-
if (m.css.trim().length > 0) cssParts.push(m.css);
|
|
613
|
-
if (m.js.trim().length > 0) jsParts.push(m.js);
|
|
614
|
-
}
|
|
663
|
+
for (const m of block.modules) collectAssets(m);
|
|
615
664
|
}
|
|
616
665
|
const renderedTemplate = applySlotReplacements(input.templateHtml, {
|
|
617
666
|
contentByName: templateContentByName,
|
|
@@ -626,27 +675,13 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
626
675
|
layoutContentByName.set("content", innerBody);
|
|
627
676
|
for (const block of input.layoutBlocks) {
|
|
628
677
|
if (block.blockName === "content") continue; // reserved for the page body
|
|
629
|
-
const renderedModuleHtml = block.modules.map(
|
|
630
|
-
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
631
|
-
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
632
|
-
let baseHtml: string;
|
|
633
|
-
if (navMenuItems !== null) {
|
|
634
|
-
navRendered = true;
|
|
635
|
-
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
636
|
-
} else if (langSelector !== null) {
|
|
637
|
-
baseHtml = langSelector;
|
|
638
|
-
} else {
|
|
639
|
-
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
640
|
-
}
|
|
641
|
-
return tagModuleId(baseHtml, m.moduleId);
|
|
642
|
-
});
|
|
678
|
+
const renderedModuleHtml = block.modules.map(renderPlaced);
|
|
643
679
|
layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
644
|
-
for (const m of block.modules)
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
680
|
+
for (const m of block.modules) collectAssets(m);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
for (const css of deferredCss.values()) {
|
|
684
|
+
if (css.trim().length > 0) cssParts.push(css);
|
|
650
685
|
}
|
|
651
686
|
|
|
652
687
|
if (navRendered) {
|
package/src/responsive-images.ts
CHANGED
|
@@ -37,11 +37,12 @@ export function variantFamily(variant: string): string {
|
|
|
37
37
|
* Pick the variant tag AI-facing surfaces should hand out for an
|
|
38
38
|
* asset, given the variant tags that ACTUALLY exist on it.
|
|
39
39
|
*
|
|
40
|
-
* Run #10 D4: `find_media` and the `## Media`
|
|
41
|
-
* advertised `webp-800` for every raster mime, but
|
|
42
|
-
* emits `webp-800` for sources narrower than 800px
|
|
43
|
-
* for animated GIFs — the AI wrote those URLs into
|
|
44
|
-
* static generator's media pass failed the whole
|
|
40
|
+
* Run #10 D4: `find_media` (and the since-removed `## Media`
|
|
41
|
+
* system-prompt block) advertised `webp-800` for every raster mime, but
|
|
42
|
+
* the pipeline never emits `webp-800` for sources narrower than 800px
|
|
43
|
+
* (no upscaling) or for animated GIFs — the AI wrote those URLs into
|
|
44
|
+
* module HTML and the static generator's media pass failed the whole
|
|
45
|
+
* staging build on
|
|
45
46
|
* "asset/variant pairs missing". Advertising must be grounded in the
|
|
46
47
|
* `media_variants` rows, not in the mime.
|
|
47
48
|
*
|
package/src/seo.test.ts
CHANGED
|
@@ -16,19 +16,17 @@ describe("resolveCanonicalUrl", () => {
|
|
|
16
16
|
expect(
|
|
17
17
|
resolveCanonicalUrl({
|
|
18
18
|
siteBaseUrl: "https://example.com",
|
|
19
|
-
|
|
20
|
-
pageLocale: "en",
|
|
19
|
+
pagePath: "/anything",
|
|
21
20
|
override: "https://canonical.example.com/x",
|
|
22
21
|
}),
|
|
23
22
|
).toBe("https://canonical.example.com/x");
|
|
24
23
|
});
|
|
25
24
|
|
|
26
|
-
it("renders
|
|
25
|
+
it("renders the composed root path as the bare base URL", () => {
|
|
27
26
|
expect(
|
|
28
27
|
resolveCanonicalUrl({
|
|
29
28
|
siteBaseUrl: "https://example.com",
|
|
30
|
-
|
|
31
|
-
pageLocale: "en",
|
|
29
|
+
pagePath: "/",
|
|
32
30
|
override: null,
|
|
33
31
|
}),
|
|
34
32
|
).toBe("https://example.com/");
|
|
@@ -38,8 +36,7 @@ describe("resolveCanonicalUrl", () => {
|
|
|
38
36
|
expect(
|
|
39
37
|
resolveCanonicalUrl({
|
|
40
38
|
siteBaseUrl: "https://example.com/",
|
|
41
|
-
|
|
42
|
-
pageLocale: "en",
|
|
39
|
+
pagePath: "/about",
|
|
43
40
|
override: null,
|
|
44
41
|
}),
|
|
45
42
|
).toBe("https://example.com/about/");
|
|
@@ -50,50 +47,29 @@ describe("resolveCanonicalUrl", () => {
|
|
|
50
47
|
expect(
|
|
51
48
|
resolveCanonicalUrl({
|
|
52
49
|
siteBaseUrl: "https://example.com",
|
|
53
|
-
|
|
54
|
-
pageLocale: "en",
|
|
50
|
+
pagePath: "/about",
|
|
55
51
|
override: null,
|
|
56
52
|
pageUrlStyle: "no-extension",
|
|
57
53
|
}),
|
|
58
54
|
).toBe("https://example.com/about");
|
|
59
55
|
});
|
|
60
56
|
|
|
61
|
-
it("keeps the root URL for the
|
|
57
|
+
it("keeps the root URL for the composed root path", () => {
|
|
62
58
|
expect(
|
|
63
59
|
resolveCanonicalUrl({
|
|
64
60
|
siteBaseUrl: "https://example.com",
|
|
65
|
-
|
|
66
|
-
pageLocale: "en",
|
|
61
|
+
pagePath: "/",
|
|
67
62
|
override: null,
|
|
68
63
|
pageUrlStyle: "no-extension",
|
|
69
64
|
}),
|
|
70
65
|
).toBe("https://example.com/");
|
|
71
66
|
});
|
|
72
67
|
|
|
73
|
-
it("subdirectory locale strategy keeps the locale prefix, no trailing slash on slug", () => {
|
|
74
|
-
expect(
|
|
75
|
-
resolveCanonicalUrl({
|
|
76
|
-
siteBaseUrl: "https://example.com",
|
|
77
|
-
pageSlug: "about",
|
|
78
|
-
pageLocale: "de",
|
|
79
|
-
override: null,
|
|
80
|
-
pageUrlStyle: "no-extension",
|
|
81
|
-
localeConfig: {
|
|
82
|
-
code: "de",
|
|
83
|
-
urlStrategy: "subdirectory",
|
|
84
|
-
urlHost: null,
|
|
85
|
-
isDefault: false,
|
|
86
|
-
},
|
|
87
|
-
}),
|
|
88
|
-
).toBe("https://example.com/de/about");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
68
|
it("default style preserves pre-v0.2.85 trailing-slash behavior", () => {
|
|
92
69
|
expect(
|
|
93
70
|
resolveCanonicalUrl({
|
|
94
71
|
siteBaseUrl: "https://example.com",
|
|
95
|
-
|
|
96
|
-
pageLocale: "en",
|
|
72
|
+
pagePath: "/about",
|
|
97
73
|
override: null,
|
|
98
74
|
}),
|
|
99
75
|
).toBe("https://example.com/about/");
|
|
@@ -108,7 +84,6 @@ describe("renderSeoHead", () => {
|
|
|
108
84
|
canonical: "https://example.com/",
|
|
109
85
|
noindex: false,
|
|
110
86
|
ogImageUrl: null,
|
|
111
|
-
hreflang: [] as { locale: string; url: string }[],
|
|
112
87
|
organization: {},
|
|
113
88
|
};
|
|
114
89
|
|
|
@@ -136,21 +111,6 @@ describe("renderSeoHead", () => {
|
|
|
136
111
|
expect(head).toContain('<meta name="twitter:card" content="summary_large_image" />');
|
|
137
112
|
});
|
|
138
113
|
|
|
139
|
-
it("emits hreflang per row + x-default when any rows are present", () => {
|
|
140
|
-
const head = renderSeoHead({
|
|
141
|
-
...base,
|
|
142
|
-
hreflang: [
|
|
143
|
-
{ locale: "de", url: "https://example.com/de/" },
|
|
144
|
-
{ locale: "fr", url: "https://example.com/fr/" },
|
|
145
|
-
],
|
|
146
|
-
});
|
|
147
|
-
expect(head).toContain('<link rel="alternate" hreflang="de" href="https://example.com/de/" />');
|
|
148
|
-
expect(head).toContain('<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />');
|
|
149
|
-
expect(head).toContain(
|
|
150
|
-
'<link rel="alternate" hreflang="x-default" href="https://example.com/" />',
|
|
151
|
-
);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
114
|
it("emits a JSON-LD WebPage block; encodes the < character to avoid script-tag breaks", () => {
|
|
155
115
|
const head = renderSeoHead({
|
|
156
116
|
...base,
|
package/src/seo.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import {
|
|
11
|
+
import { trimSlashes } from "./url.js";
|
|
12
12
|
|
|
13
13
|
export const CHANGEFREQ_VALUES = [
|
|
14
14
|
"always",
|
|
@@ -108,58 +108,31 @@ export interface SiteSeoSettings {
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Resolve the canonical URL for a page. If `pages_seo.canonical_url`
|
|
111
|
-
* is set it wins; otherwise
|
|
112
|
-
*
|
|
113
|
-
*
|
|
111
|
+
* is set it wins; otherwise `<siteBaseUrl><pagePath>` — where
|
|
112
|
+
* `pagePath` is the COMPOSED public path from `pages.current_path`
|
|
113
|
+
* (#390: the URL composition point materializes prefixes, slug
|
|
114
|
+
* formats, and the home designation into that one column; canonical
|
|
115
|
+
* simply follows it).
|
|
114
116
|
*/
|
|
115
117
|
export function resolveCanonicalUrl(args: {
|
|
116
118
|
siteBaseUrl: string;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
/** The page's composed path (`pages.current_path`): leading slash,
|
|
120
|
+
* "/" for the site root. */
|
|
121
|
+
pagePath: string;
|
|
119
122
|
override: string | null;
|
|
120
|
-
/** P9 — when present, the locale's url_strategy decides the URL shape. */
|
|
121
|
-
localeConfig?: {
|
|
122
|
-
code: string;
|
|
123
|
-
urlStrategy: "none" | "subdirectory" | "subdomain" | "domain";
|
|
124
|
-
urlHost: string | null;
|
|
125
|
-
isDefault: boolean;
|
|
126
|
-
};
|
|
127
123
|
/**
|
|
128
124
|
* v0.2.85 — page emission style. 'directory' (default) → URLs end
|
|
129
|
-
* in
|
|
130
|
-
*
|
|
131
|
-
* pages are emitted as bare slugs.
|
|
125
|
+
* in `/…/`; 'no-extension' → no trailing slash, matching what the
|
|
126
|
+
* bucket serves when pages are emitted as bare files.
|
|
132
127
|
*/
|
|
133
128
|
pageUrlStyle?: "directory" | "no-extension";
|
|
134
|
-
/**
|
|
135
|
-
* 0184 — explicit homepage designation (`locales.home_page_id`). When
|
|
136
|
-
* true this page IS the locale root and canonicalizes to `<base>/`
|
|
137
|
-
* regardless of its slug. Uses the same predicate (`isHomeSlug`) as
|
|
138
|
-
* the magic-slug fallback so canonical agrees with hreflang + the
|
|
139
|
-
* emitted output path.
|
|
140
|
-
*/
|
|
141
|
-
isHomePage?: boolean;
|
|
142
129
|
}): string {
|
|
143
130
|
if (args.override && args.override.length > 0) return args.override;
|
|
144
|
-
const base = args.siteBaseUrl.
|
|
145
|
-
const
|
|
146
|
-
|
|
131
|
+
const base = args.siteBaseUrl.endsWith("/") ? args.siteBaseUrl.slice(0, -1) : args.siteBaseUrl;
|
|
132
|
+
const trimmed = trimSlashes(args.pagePath);
|
|
133
|
+
if (trimmed.length === 0) return `${base}/`;
|
|
147
134
|
const style = args.pageUrlStyle ?? "directory";
|
|
148
|
-
|
|
149
|
-
// shape dictated by the page-emission style. Home pages always
|
|
150
|
-
// resolve to the base URL (no tail) regardless of style.
|
|
151
|
-
const tail = cleanSlug ? (style === "no-extension" ? cleanSlug : `${cleanSlug}/`) : "";
|
|
152
|
-
const cfg = args.localeConfig;
|
|
153
|
-
if (cfg && cfg.urlStrategy !== "none") {
|
|
154
|
-
if (cfg.urlStrategy === "subdirectory") {
|
|
155
|
-
return tail ? `${base}/${cfg.code}/${tail}` : `${base}/${cfg.code}/`;
|
|
156
|
-
}
|
|
157
|
-
if ((cfg.urlStrategy === "subdomain" || cfg.urlStrategy === "domain") && cfg.urlHost) {
|
|
158
|
-
const protocol = base.startsWith("http://") ? "http://" : "https://";
|
|
159
|
-
return tail ? `${protocol}${cfg.urlHost}/${tail}` : `${protocol}${cfg.urlHost}/`;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return tail ? `${base}/${tail}` : `${base}/`;
|
|
135
|
+
return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
|
|
163
136
|
}
|
|
164
137
|
|
|
165
138
|
export interface SeoMetaInput {
|
|
@@ -168,7 +141,6 @@ export interface SeoMetaInput {
|
|
|
168
141
|
canonical: string;
|
|
169
142
|
noindex: boolean;
|
|
170
143
|
ogImageUrl: string | null;
|
|
171
|
-
hreflang: { locale: string; url: string }[];
|
|
172
144
|
organization: SiteSeoSettings["organization"];
|
|
173
145
|
}
|
|
174
146
|
|
|
@@ -177,7 +149,7 @@ export interface SeoMetaInput {
|
|
|
177
149
|
* the renderer injects just before </head>. Order is canonical
|
|
178
150
|
* (W3C-recommended): charset / viewport stay in the layout; meta
|
|
179
151
|
* description + canonical + robots + Open Graph + Twitter card +
|
|
180
|
-
*
|
|
152
|
+
* JSON-LD.
|
|
181
153
|
*
|
|
182
154
|
* No raw HTML escape hatch — every variable is HTML-attribute-encoded.
|
|
183
155
|
*/
|
|
@@ -208,14 +180,6 @@ export function renderSeoHead(input: SeoMetaInput): string {
|
|
|
208
180
|
lines.push(
|
|
209
181
|
`<meta name="twitter:card" content="${input.ogImageUrl ? "summary_large_image" : "summary"}" />`,
|
|
210
182
|
);
|
|
211
|
-
// Hreflang per locale + x-default. P8 ships zero rows; P9 i18n
|
|
212
|
-
// populates pages_hreflang and the existing renderer iterates.
|
|
213
|
-
for (const h of input.hreflang) {
|
|
214
|
-
lines.push(`<link rel="alternate" hreflang="${enc(h.locale)}" href="${enc(h.url)}" />`);
|
|
215
|
-
}
|
|
216
|
-
if (input.hreflang.length > 0) {
|
|
217
|
-
lines.push(`<link rel="alternate" hreflang="x-default" href="${enc(input.canonical)}" />`);
|
|
218
|
-
}
|
|
219
183
|
// JSON-LD WebPage block referencing the Organization (when set).
|
|
220
184
|
const ld: Record<string, unknown> = {
|
|
221
185
|
"@context": "https://schema.org",
|
package/src/structured-sets.ts
CHANGED
|
@@ -75,38 +75,13 @@ export const linkListItem = z
|
|
|
75
75
|
})
|
|
76
76
|
.strict();
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
* P9 review-pass — `language-selector` kind. Items are user-supplied
|
|
80
|
-
* overrides (e.g. force a specific display label per locale); empty
|
|
81
|
-
* array means "auto-populate from the locale registry at render time."
|
|
82
|
-
* The renderer (apps/static-generator + preview op) reads the locale
|
|
83
|
-
* registry + the current page's slug + resolveLocaleUrl to emit
|
|
84
|
-
* `<a hreflang lang href>` rows. Closes CMS_REQUIREMENTS §7.8.
|
|
85
|
-
*/
|
|
86
|
-
export const languageSelectorOverride = z
|
|
87
|
-
.object({
|
|
88
|
-
/** BCP-47 locale code; must match a row in the locales registry. */
|
|
89
|
-
locale: z.string().min(2).max(10),
|
|
90
|
-
/** Override the locale's display_name when rendering this entry. */
|
|
91
|
-
label: z.string().min(1).max(120).optional(),
|
|
92
|
-
/** Hide this locale from the rendered selector even if it has a published page. */
|
|
93
|
-
hidden: z.boolean().optional(),
|
|
94
|
-
})
|
|
95
|
-
.strict();
|
|
96
|
-
|
|
97
78
|
/** Discriminated kind → items array. The Query API op picks the
|
|
98
79
|
* validator at runtime by reading `kind` first.
|
|
99
80
|
*
|
|
100
81
|
* v0.11.0 (#45) — `theme` is no longer a structured-set kind. The
|
|
101
82
|
* theme primitive moved to its own `themes` table with DTCG-shaped
|
|
102
83
|
* jsonb tokens; see `themes.ts` + `theme-render.ts`. */
|
|
103
|
-
export const structuredSetKind = z.enum([
|
|
104
|
-
"nav-menu",
|
|
105
|
-
"taxonomy",
|
|
106
|
-
"tags",
|
|
107
|
-
"link-list",
|
|
108
|
-
"language-selector",
|
|
109
|
-
]);
|
|
84
|
+
export const structuredSetKind = z.enum(["nav-menu", "taxonomy", "tags", "link-list"]);
|
|
110
85
|
export type StructuredSetKind = z.infer<typeof structuredSetKind>;
|
|
111
86
|
|
|
112
87
|
/** Validate `items` against the right schema for `kind`. Throws a
|
|
@@ -130,8 +105,6 @@ export function validateStructuredSetItems(kind: StructuredSetKind, items: unkno
|
|
|
130
105
|
return items.map((it) => tagItem.parse(it));
|
|
131
106
|
case "link-list":
|
|
132
107
|
return items.map((it) => linkListItem.parse(it));
|
|
133
|
-
case "language-selector":
|
|
134
|
-
return items.map((it) => languageSelectorOverride.parse(it));
|
|
135
108
|
}
|
|
136
109
|
}
|
|
137
110
|
|
|
@@ -139,42 +112,3 @@ export type NavMenuItem = z.infer<typeof navMenuItem>;
|
|
|
139
112
|
export type TaxonomyItem = z.infer<typeof taxonomyItem>;
|
|
140
113
|
export type TagItem = z.infer<typeof tagItem>;
|
|
141
114
|
export type LinkListItem = z.infer<typeof linkListItem>;
|
|
142
|
-
export type LanguageSelectorOverride = z.infer<typeof languageSelectorOverride>;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Render a `<nav class="caelo-language-selector">` with one `<a>`
|
|
146
|
-
* per locale that has a published variant of the current page. Pure
|
|
147
|
-
* function — caller threads the page's published-locale list and a
|
|
148
|
-
* URL resolver. Used by `composePagePreview` (admin) and the static
|
|
149
|
-
* generator (deploy) so byte-for-byte output parity holds.
|
|
150
|
-
*
|
|
151
|
-
* Per CMS_REQUIREMENTS §7.8: locales with no published variant for the
|
|
152
|
-
* current page are excluded.
|
|
153
|
-
*/
|
|
154
|
-
export function renderLanguageSelector(args: {
|
|
155
|
-
/** Each locale that has a published variant of the current page. */
|
|
156
|
-
readonly availableLocales: ReadonlyArray<{
|
|
157
|
-
code: string;
|
|
158
|
-
displayName: string;
|
|
159
|
-
href: string;
|
|
160
|
-
isCurrent: boolean;
|
|
161
|
-
}>;
|
|
162
|
-
/** Owner-supplied overrides (relabel a locale, hide one). */
|
|
163
|
-
readonly overrides?: ReadonlyArray<LanguageSelectorOverride>;
|
|
164
|
-
}): string {
|
|
165
|
-
const overrideByLocale = new Map<string, LanguageSelectorOverride>(
|
|
166
|
-
(args.overrides ?? []).map((o) => [o.locale, o]),
|
|
167
|
-
);
|
|
168
|
-
const enc = (s: string): string =>
|
|
169
|
-
s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
170
|
-
const items = args.availableLocales
|
|
171
|
-
.filter((l) => !overrideByLocale.get(l.code)?.hidden)
|
|
172
|
-
.map((l) => {
|
|
173
|
-
const override = overrideByLocale.get(l.code);
|
|
174
|
-
const label = override?.label ?? l.displayName;
|
|
175
|
-
const aria = l.isCurrent ? ' aria-current="true"' : "";
|
|
176
|
-
return `<a hreflang="${enc(l.code)}" lang="${enc(l.code)}" href="${enc(l.href)}"${aria}>${enc(label)}</a>`;
|
|
177
|
-
});
|
|
178
|
-
if (items.length === 0) return "";
|
|
179
|
-
return `<nav class="caelo-language-selector" aria-label="Language">${items.join("")}</nav>`;
|
|
180
|
-
}
|