@caelo-cms/shared 0.2.2
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 +571 -0
- package/dist/ai-tools.d.ts.map +1 -0
- package/dist/ai-tools.js +696 -0
- package/dist/ai-tools.js.map +1 -0
- package/dist/auth-forms.d.ts +24 -0
- package/dist/auth-forms.d.ts.map +1 -0
- package/dist/auth-forms.js +27 -0
- package/dist/auth-forms.js.map +1 -0
- package/dist/cap-failures.d.ts +17 -0
- package/dist/cap-failures.d.ts.map +1 -0
- package/dist/cap-failures.js +58 -0
- package/dist/cap-failures.js.map +1 -0
- package/dist/content.d.ts +111 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +137 -0
- package/dist/content.js.map +1 -0
- package/dist/context.d.ts +40 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -0
- package/dist/i18n.d.ts +49 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +154 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +56 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +84 -0
- package/dist/logger.js.map +1 -0
- package/dist/media.d.ts +143 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +168 -0
- package/dist/media.js.map +1 -0
- package/dist/preview-compose.d.ts +84 -0
- package/dist/preview-compose.d.ts.map +1 -0
- package/dist/preview-compose.js +385 -0
- package/dist/preview-compose.js.map +1 -0
- package/dist/preview-scanner.d.ts +44 -0
- package/dist/preview-scanner.d.ts.map +1 -0
- package/dist/preview-scanner.js +177 -0
- package/dist/preview-scanner.js.map +1 -0
- package/dist/result.d.ts +21 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +14 -0
- package/dist/result.js.map +1 -0
- package/dist/seo.d.ts +128 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +176 -0
- package/dist/seo.js.map +1 -0
- package/dist/skills.d.ts +88 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +127 -0
- package/dist/skills.js.map +1 -0
- package/dist/snapshots.d.ts +54 -0
- package/dist/snapshots.d.ts.map +1 -0
- package/dist/snapshots.js +59 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/structured-sets.d.ts +116 -0
- package/dist/structured-sets.d.ts.map +1 -0
- package/dist/structured-sets.js +154 -0
- package/dist/structured-sets.js.map +1 -0
- package/dist/subagents.d.ts +123 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +202 -0
- package/dist/subagents.js.map +1 -0
- package/dist/translation.d.ts +127 -0
- package/dist/translation.d.ts.map +1 -0
- package/dist/translation.js +208 -0
- package/dist/translation.js.map +1 -0
- package/dist/version.d.ts +46 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +46 -0
- package/dist/version.js.map +1 -0
- package/package.json +38 -0
package/dist/media.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Media library — shared primitives.
|
|
4
|
+
*
|
|
5
|
+
* Browser-safe: Zod schemas, MIME allowlist, size caps, the variant
|
|
6
|
+
* convention. Sharp + filesystem adapters live in `@caelo-cms/admin-core`
|
|
7
|
+
* (server-only). The storage-key shape is stable here so the static
|
|
8
|
+
* generator's URL rewriter and the admin's iframe resolver agree on
|
|
9
|
+
* the canonical form `<sha>/<variant>.<ext>`.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
/**
|
|
13
|
+
* Allowlisted MIME types. Anything outside this set is rejected at the
|
|
14
|
+
* upload endpoint with `415 Unsupported Media Type`. SVG is allowed
|
|
15
|
+
* but capped tight to discourage XSS via embedded scripts; the upload
|
|
16
|
+
* pipeline strips `<script>` and event-handler attributes before
|
|
17
|
+
* persisting (see {@link sanitizeSvg} in admin-core).
|
|
18
|
+
*/
|
|
19
|
+
export const MEDIA_ALLOWED_MIMES = [
|
|
20
|
+
"image/jpeg",
|
|
21
|
+
"image/png",
|
|
22
|
+
"image/webp",
|
|
23
|
+
"image/avif",
|
|
24
|
+
"image/gif",
|
|
25
|
+
"image/svg+xml",
|
|
26
|
+
"application/pdf",
|
|
27
|
+
"video/mp4",
|
|
28
|
+
];
|
|
29
|
+
/** Per-MIME size caps (bytes). Server enforces; client display only. */
|
|
30
|
+
export const MEDIA_SIZE_CAPS = {
|
|
31
|
+
"image/jpeg": 10 * 1024 * 1024,
|
|
32
|
+
"image/png": 10 * 1024 * 1024,
|
|
33
|
+
"image/webp": 10 * 1024 * 1024,
|
|
34
|
+
"image/avif": 10 * 1024 * 1024,
|
|
35
|
+
"image/gif": 8 * 1024 * 1024,
|
|
36
|
+
"image/svg+xml": 1 * 1024 * 1024,
|
|
37
|
+
"application/pdf": 20 * 1024 * 1024,
|
|
38
|
+
"video/mp4": 50 * 1024 * 1024,
|
|
39
|
+
};
|
|
40
|
+
/** Hard ceiling on the multipart body. Per-MIME caps narrow further. */
|
|
41
|
+
export const MEDIA_HARD_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* Variant tags. `orig` is always present (re-encoded only for SVG
|
|
44
|
+
* sanitisation). Image-only WebP variants are emitted at breakpoints
|
|
45
|
+
* the source can satisfy — a 600px-wide source skips webp-1200 +
|
|
46
|
+
* webp-1600 entirely.
|
|
47
|
+
*/
|
|
48
|
+
export const MEDIA_VARIANT_TAGS = [
|
|
49
|
+
"orig",
|
|
50
|
+
"webp-1600",
|
|
51
|
+
"webp-1200",
|
|
52
|
+
"webp-800",
|
|
53
|
+
"webp-400",
|
|
54
|
+
];
|
|
55
|
+
/** Width-in-pixels target for each WebP variant. */
|
|
56
|
+
export const MEDIA_VARIANT_WIDTHS = {
|
|
57
|
+
"webp-1600": 1600,
|
|
58
|
+
"webp-1200": 1200,
|
|
59
|
+
"webp-800": 800,
|
|
60
|
+
"webp-400": 400,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Renderer-agnostic asset URL used in module HTML. Both the SvelteKit
|
|
64
|
+
* admin endpoint and the static generator's media-pass parse this
|
|
65
|
+
* shape; the static generator rewrites to `/_assets/...` (or a CDN
|
|
66
|
+
* URL) at deploy time.
|
|
67
|
+
*
|
|
68
|
+
* Format: `/_caelo/media/<asset-id>/<variant>`. The asset id, not the
|
|
69
|
+
* sha, so URLs survive a re-upload of the same content under a new id.
|
|
70
|
+
*/
|
|
71
|
+
export const MEDIA_URL_PREFIX = "/_caelo/media";
|
|
72
|
+
export function buildMediaUrl(assetId, variant) {
|
|
73
|
+
return `${MEDIA_URL_PREFIX}/${assetId}/${variant}`;
|
|
74
|
+
}
|
|
75
|
+
// Variant token: `orig`, `webp-<width>`, or `<crop-name>-<width>`. We
|
|
76
|
+
// accept any kebab-case slug so focal-point crop fan-outs like
|
|
77
|
+
// `square-800` and `wide-1200` (added by P7 optimization #2) round-trip
|
|
78
|
+
// without a regex update per crop name.
|
|
79
|
+
const mediaUrlPattern = new RegExp(`${MEDIA_URL_PREFIX}/([0-9a-f-]{36})/([a-z][a-z0-9-]{0,63})`, "g");
|
|
80
|
+
/**
|
|
81
|
+
* Extract every (assetId, variant) pair referenced in an HTML string.
|
|
82
|
+
* Used by the post-write usage-tracker and by the static-generator
|
|
83
|
+
* media-pass. Returns a deduped list to keep callers' work proportional
|
|
84
|
+
* to unique assets, not raw match count.
|
|
85
|
+
*/
|
|
86
|
+
export function extractMediaRefs(html) {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const m of html.matchAll(mediaUrlPattern)) {
|
|
90
|
+
const key = `${m[1]}/${m[2]}`;
|
|
91
|
+
if (seen.has(key))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
out.push({ assetId: m[1], variant: m[2] });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------
|
|
99
|
+
// Zod schemas — exposed at the Query-API boundary.
|
|
100
|
+
// ---------------------------------------------------------------------
|
|
101
|
+
const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/, "must be hex sha256");
|
|
102
|
+
export const mediaUploadInputSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
sha256: sha256Schema,
|
|
105
|
+
originalName: z.string().min(1).max(512),
|
|
106
|
+
mime: z.enum(MEDIA_ALLOWED_MIMES),
|
|
107
|
+
sizeBytes: z.number().int().positive(),
|
|
108
|
+
width: z.number().int().positive().nullable(),
|
|
109
|
+
height: z.number().int().positive().nullable(),
|
|
110
|
+
alt: z.string().max(2048).default(""),
|
|
111
|
+
storageKey: z.string().min(1),
|
|
112
|
+
/** P7 optimization #3 — stamped by the upload endpoint via getMediaStorageProvider(). */
|
|
113
|
+
storageProvider: z.string().min(1).max(64).default("local"),
|
|
114
|
+
variants: z
|
|
115
|
+
.array(z.object({
|
|
116
|
+
variant: z.string().min(1).max(64),
|
|
117
|
+
format: z.string().min(1).max(32),
|
|
118
|
+
width: z.number().int().positive().nullable(),
|
|
119
|
+
height: z.number().int().positive().nullable(),
|
|
120
|
+
sizeBytes: z.number().int().positive(),
|
|
121
|
+
storageKey: z.string().min(1),
|
|
122
|
+
}))
|
|
123
|
+
.min(1),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
export const mediaListInputSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
query: z.string().max(256).optional(),
|
|
129
|
+
mime: z.enum(MEDIA_ALLOWED_MIMES).optional(),
|
|
130
|
+
sort: z.enum(["recent", "most_used"]).default("recent"),
|
|
131
|
+
limit: z.number().int().positive().max(200).default(60),
|
|
132
|
+
offset: z.number().int().nonnegative().default(0),
|
|
133
|
+
})
|
|
134
|
+
.strict();
|
|
135
|
+
export const mediaUpdateAltInputSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
assetId: z.string().uuid(),
|
|
138
|
+
alt: z.string().max(2048),
|
|
139
|
+
})
|
|
140
|
+
.strict();
|
|
141
|
+
export const mediaDeleteInputSchema = z
|
|
142
|
+
.object({
|
|
143
|
+
assetId: z.string().uuid(),
|
|
144
|
+
force: z.boolean().default(false),
|
|
145
|
+
})
|
|
146
|
+
.strict();
|
|
147
|
+
export const mediaRecordUsageInputSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
/** Map of assetId → net delta (positive when added, negative when removed). */
|
|
150
|
+
deltas: z.record(z.string().uuid(), z.number().int()),
|
|
151
|
+
})
|
|
152
|
+
.strict();
|
|
153
|
+
export const mediaRecentForAiInputSchema = z
|
|
154
|
+
.object({
|
|
155
|
+
limit: z.number().int().positive().max(60).default(30),
|
|
156
|
+
})
|
|
157
|
+
.strict();
|
|
158
|
+
export const mediaSetCdnInputSchema = z
|
|
159
|
+
.object({
|
|
160
|
+
enabled: z.boolean(),
|
|
161
|
+
threshold: z.number().int().min(1).max(10000),
|
|
162
|
+
})
|
|
163
|
+
.strict();
|
|
164
|
+
/** Build the canonical storage key for a given asset variant. */
|
|
165
|
+
export function buildStorageKey(sha256, variant, ext) {
|
|
166
|
+
return `${sha256}/${variant}.${ext}`;
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.js","sourceRoot":"","sources":["../src/media.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface ComposeModule {
|
|
2
|
+
readonly moduleId: string;
|
|
3
|
+
readonly slug: string;
|
|
4
|
+
readonly displayName: string;
|
|
5
|
+
readonly html: string;
|
|
6
|
+
readonly css: string;
|
|
7
|
+
readonly js: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ComposeBlock {
|
|
10
|
+
readonly blockName: string;
|
|
11
|
+
readonly modules: readonly ComposeModule[];
|
|
12
|
+
}
|
|
13
|
+
/** P6.7.5 — structured sets carried into the composer so nav-menu
|
|
14
|
+
* modules render from typed items and theme tokens flow into <head>. */
|
|
15
|
+
export interface ComposeStructuredSets {
|
|
16
|
+
/** Map keyed by `<kind>/<slug>` (e.g. `nav-menu/header-main`). */
|
|
17
|
+
readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* P9 — language-selector context. The caller (preview op + static
|
|
21
|
+
* generator) resolves the current page's per-locale URLs once and
|
|
22
|
+
* threads them in. The composer renders the selector when a module
|
|
23
|
+
* slug starts with `language-selector-`.
|
|
24
|
+
*/
|
|
25
|
+
export interface ComposeLanguageSelector {
|
|
26
|
+
readonly availableLocales: ReadonlyArray<{
|
|
27
|
+
code: string;
|
|
28
|
+
displayName: string;
|
|
29
|
+
href: string;
|
|
30
|
+
isCurrent: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export interface ComposeInput {
|
|
34
|
+
readonly templateHtml: string;
|
|
35
|
+
readonly templateCss: string;
|
|
36
|
+
readonly blocks: readonly ComposeBlock[];
|
|
37
|
+
readonly structuredSets?: ComposeStructuredSets;
|
|
38
|
+
readonly languageSelector?: ComposeLanguageSelector;
|
|
39
|
+
}
|
|
40
|
+
export interface ComposeOutput {
|
|
41
|
+
readonly html: string;
|
|
42
|
+
readonly replacedSlots: readonly string[];
|
|
43
|
+
readonly missingSlots: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
export declare function composePagePreview(input: ComposeInput): ComposeOutput;
|
|
46
|
+
export declare function tagModuleId(html: string, moduleId: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* P6.7.6 — layout-aware composer. Runs the template composer first,
|
|
49
|
+
* extracts the resulting body content, then renders the layout HTML
|
|
50
|
+
* substituting:
|
|
51
|
+
* - `<caelo-slot name="content">` → the body of the rendered template
|
|
52
|
+
* - other layout blocks (header / footer / etc.) → concatenated HTML
|
|
53
|
+
* from `layoutBlocks` (per-block module attachments)
|
|
54
|
+
*
|
|
55
|
+
* Per CLAUDE.md §2 no-fallbacks: validates the layout has the required
|
|
56
|
+
* `<caelo-slot name="content">` slot before rendering. Throws
|
|
57
|
+
* `ComposeError` if the layout is malformed so callers (preview op +
|
|
58
|
+
* static generator) surface it as a structured failure rather than
|
|
59
|
+
* silently emitting broken HTML.
|
|
60
|
+
*/
|
|
61
|
+
export interface ComposeLayoutBlock {
|
|
62
|
+
readonly blockName: string;
|
|
63
|
+
readonly modules: readonly ComposeModule[];
|
|
64
|
+
}
|
|
65
|
+
export interface ComposeWithLayoutInput extends ComposeInput {
|
|
66
|
+
readonly layoutHtml: string;
|
|
67
|
+
readonly layoutCss: string;
|
|
68
|
+
readonly layoutBlocks: readonly ComposeLayoutBlock[];
|
|
69
|
+
/** Optional layout slug carried into ComposeError messages. */
|
|
70
|
+
readonly layoutSlug?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Typed failure for the layout-aware composer. Use `kind` to dispatch:
|
|
74
|
+
* - `layout-missing-content`: the layout HTML lacks
|
|
75
|
+
* `<caelo-slot name="content">…</caelo-slot>` so the page body has
|
|
76
|
+
* nowhere to land.
|
|
77
|
+
*/
|
|
78
|
+
export declare class ComposeError extends Error {
|
|
79
|
+
readonly kind: "layout-missing-content";
|
|
80
|
+
readonly layoutSlug: string | undefined;
|
|
81
|
+
constructor(kind: "layout-missing-content", message: string, layoutSlug?: string);
|
|
82
|
+
}
|
|
83
|
+
export declare function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOutput;
|
|
84
|
+
//# sourceMappingURL=preview-compose.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview-compose.d.ts","sourceRoot":"","sources":["../src/preview-compose.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;CAC5C;AAED;yEACyE;AACzE,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC,CAAC;CACnE;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,gBAAgB,EAAE,aAAa,CAAC;QACvC,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,CAAC;IACzC,QAAQ,CAAC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IAChD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAYD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,aAAa,CA8DrE;AA2HD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAUlE;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,sBAAuB,SAAQ,YAAY;IAC1D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACrD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;gBAC5B,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAMjF;AAkCD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB,GAAG,aAAa,CAwHlF"}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Compose a page's HTML from its template + module references.
|
|
4
|
+
*
|
|
5
|
+
* The composed output is what the admin preview iframe renders. Production
|
|
6
|
+
* static-gen (P6) will reuse this same composer once Astro is wired up, so the
|
|
7
|
+
* function is pure and dependency-free — no DB calls, no IO. The Query API op
|
|
8
|
+
* does the loads and hands the data here.
|
|
9
|
+
*
|
|
10
|
+
* Output shape:
|
|
11
|
+
* 1. `<caelo-slot name="X">` blocks have their inner HTML replaced by the
|
|
12
|
+
* concatenated module HTML for block X (in `position` order).
|
|
13
|
+
* 2. All module CSS is concatenated into a single
|
|
14
|
+
* `<style data-source="modules">` injected before `</head>` — template
|
|
15
|
+
* stays the source of truth for `<head>`; we just append.
|
|
16
|
+
* 3. All module JS is concatenated into a single
|
|
17
|
+
* `<script defer data-source="modules">` injected before `</body>`.
|
|
18
|
+
* 4. Template CSS is injected ahead of module CSS so module rules can
|
|
19
|
+
* override template defaults via specificity.
|
|
20
|
+
*
|
|
21
|
+
* The composer never escapes module HTML — modules ARE the place where raw
|
|
22
|
+
* HTML lives (CMS_REQUIREMENTS §3.1). Templates ARE the place where the
|
|
23
|
+
* `<head>` skeleton lives. Sandboxing happens one layer up (preview iframe in
|
|
24
|
+
* the admin; in P11, plugin Web Components inside Shadow DOM).
|
|
25
|
+
*/
|
|
26
|
+
import { applySlotReplacements, extractInnerOfTopLevelContentSlot, listSlotNames, } from "./preview-scanner.js";
|
|
27
|
+
import { renderLanguageSelector } from "./structured-sets.js";
|
|
28
|
+
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
29
|
+
const BODY_CLOSE_RE = /<\/body\s*>/i;
|
|
30
|
+
function injectBefore(source, marker, fragment) {
|
|
31
|
+
const m = marker.exec(source);
|
|
32
|
+
if (!m)
|
|
33
|
+
return source + fragment; // template lacks the tag — append as fallback
|
|
34
|
+
const idx = m.index;
|
|
35
|
+
return source.slice(0, idx) + fragment + source.slice(idx);
|
|
36
|
+
}
|
|
37
|
+
export function composePagePreview(input) {
|
|
38
|
+
const contentByName = new Map();
|
|
39
|
+
const allCss = [];
|
|
40
|
+
const allJs = [];
|
|
41
|
+
// Template CSS first so module CSS can override it via source-order specificity.
|
|
42
|
+
if (input.templateCss.trim().length > 0)
|
|
43
|
+
allCss.push(input.templateCss);
|
|
44
|
+
for (const block of input.blocks) {
|
|
45
|
+
// P6.7 — tag every module's outermost element with
|
|
46
|
+
// `data-caelo-module-id="<uuid>"` so the live-edit overlay's iframe
|
|
47
|
+
// hover affordances can identify the clicked module.
|
|
48
|
+
//
|
|
49
|
+
// P6.7.5 — modules whose slug matches a `nav-menu/<slug>` set get
|
|
50
|
+
// their HTML replaced by a fresh render of the menu items. That's
|
|
51
|
+
// what makes a slug change update every menu without touching
|
|
52
|
+
// module HTML.
|
|
53
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
54
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
55
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
56
|
+
let baseHtml;
|
|
57
|
+
if (navMenuItems !== null) {
|
|
58
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
59
|
+
}
|
|
60
|
+
else if (langSelector !== null) {
|
|
61
|
+
baseHtml = langSelector;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
baseHtml = m.html;
|
|
65
|
+
}
|
|
66
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
67
|
+
});
|
|
68
|
+
const html = renderedModuleHtml.join("\n");
|
|
69
|
+
contentByName.set(block.blockName, html);
|
|
70
|
+
for (const m of block.modules) {
|
|
71
|
+
if (m.css.trim().length > 0)
|
|
72
|
+
allCss.push(m.css);
|
|
73
|
+
if (m.js.trim().length > 0)
|
|
74
|
+
allJs.push(m.js);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const replaced = applySlotReplacements(input.templateHtml, { contentByName });
|
|
78
|
+
let html = replaced.html;
|
|
79
|
+
// P6.7.5 — theme tokens become CSS custom properties on :root. Goes
|
|
80
|
+
// first so module CSS can `var(--color-primary)` and override.
|
|
81
|
+
const themeCss = renderThemeCss(input.structuredSets);
|
|
82
|
+
if (themeCss !== null) {
|
|
83
|
+
const styleTag = `<style data-source="theme">${themeCss}</style>`;
|
|
84
|
+
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
85
|
+
}
|
|
86
|
+
if (allCss.length > 0) {
|
|
87
|
+
const styleTag = `<style data-source="modules">\n${allCss.join("\n")}\n</style>`;
|
|
88
|
+
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
89
|
+
}
|
|
90
|
+
if (allJs.length > 0) {
|
|
91
|
+
const scriptTag = `<script defer data-source="modules">\n${allJs.join("\n")}\n</script>`;
|
|
92
|
+
html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
html,
|
|
96
|
+
replacedSlots: replaced.replacedSlots,
|
|
97
|
+
missingSlots: replaced.missingSlots,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Insert `data-caelo-module-id="<id>"` into the first opening tag of
|
|
102
|
+
* the module's HTML. Idempotent — re-tagging an already-tagged module
|
|
103
|
+
* is a no-op. Comments / DOCTYPE / leading whitespace before the first
|
|
104
|
+
* tag are tolerated. Modules that have no opening tag (pure text)
|
|
105
|
+
* return unchanged because there's nothing to attach to.
|
|
106
|
+
*
|
|
107
|
+
* Exported so callers (admin preview endpoint, static generator,
|
|
108
|
+
* tests) can reuse the same logic.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* P6.7.5 — return the items for a `nav-menu/<slug>` set when a module's
|
|
112
|
+
* slug starts with `nav-menu-`. Returns null when the module is not a
|
|
113
|
+
* nav menu (so the composer falls back to its stored HTML).
|
|
114
|
+
*
|
|
115
|
+
* Convention: a module slug `nav-menu-header-main` resolves to
|
|
116
|
+
* structuredSets[`nav-menu/header-main`].
|
|
117
|
+
*/
|
|
118
|
+
function lookupNavMenuItems(moduleSlug, sets) {
|
|
119
|
+
if (!sets)
|
|
120
|
+
return null;
|
|
121
|
+
const prefix = "nav-menu-";
|
|
122
|
+
if (!moduleSlug.startsWith(prefix))
|
|
123
|
+
return null;
|
|
124
|
+
const setSlug = moduleSlug.slice(prefix.length);
|
|
125
|
+
const items = sets.byKindSlug[`nav-menu/${setSlug}`];
|
|
126
|
+
return items ?? null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* P9 — return rendered language-selector HTML when a module's slug
|
|
130
|
+
* starts with `language-selector-`. The set's items act as overrides
|
|
131
|
+
* (relabel a locale, hide one); the available-locale list comes from
|
|
132
|
+
* the caller via `input.languageSelector`. Returns null when the
|
|
133
|
+
* module is not a language selector.
|
|
134
|
+
*
|
|
135
|
+
* Convention: a module slug `language-selector-header` resolves to
|
|
136
|
+
* structuredSets[`language-selector/header`] for overrides, and the
|
|
137
|
+
* rendered HTML lists every locale that has a published variant of
|
|
138
|
+
* the current page.
|
|
139
|
+
*/
|
|
140
|
+
function lookupLanguageSelector(moduleSlug, input) {
|
|
141
|
+
const prefix = "language-selector-";
|
|
142
|
+
if (!moduleSlug.startsWith(prefix))
|
|
143
|
+
return null;
|
|
144
|
+
if (!input.languageSelector || input.languageSelector.availableLocales.length === 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const setSlug = moduleSlug.slice(prefix.length);
|
|
148
|
+
const overridesUnknown = input.structuredSets?.byKindSlug[`language-selector/${setSlug}`];
|
|
149
|
+
const overrides = Array.isArray(overridesUnknown)
|
|
150
|
+
? overridesUnknown
|
|
151
|
+
: undefined;
|
|
152
|
+
return renderLanguageSelector({
|
|
153
|
+
availableLocales: input.languageSelector.availableLocales,
|
|
154
|
+
overrides,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function escapeAttr(s) {
|
|
158
|
+
return s
|
|
159
|
+
.replace(/&/g, "&")
|
|
160
|
+
.replace(/"/g, """)
|
|
161
|
+
.replace(/</g, "<")
|
|
162
|
+
.replace(/>/g, ">");
|
|
163
|
+
}
|
|
164
|
+
function escapeText(s) {
|
|
165
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Render a nav-menu's typed items into HTML. Recursively handles
|
|
169
|
+
* children for submenus. Plain `<nav><ul><li>` so site CSS can theme
|
|
170
|
+
* it via the `caelo-nav-menu` class.
|
|
171
|
+
*/
|
|
172
|
+
function renderNavMenuHtml(items) {
|
|
173
|
+
const safeItems = items.filter((it) => {
|
|
174
|
+
if (!it || typeof it !== "object")
|
|
175
|
+
return false;
|
|
176
|
+
const o = it;
|
|
177
|
+
return typeof o.label === "string" && typeof o.href === "string";
|
|
178
|
+
});
|
|
179
|
+
return `<nav class="caelo-nav-menu"><ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
|
|
180
|
+
}
|
|
181
|
+
function renderNavItem(item) {
|
|
182
|
+
const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
|
|
183
|
+
const inner = item.children && item.children.length > 0
|
|
184
|
+
? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
|
|
185
|
+
: "";
|
|
186
|
+
return `<li><a href="${escapeAttr(item.href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Render the theme/site set's tokens as `:root { --token: value; }` CSS.
|
|
190
|
+
* Returns null when no theme is configured so the composer skips
|
|
191
|
+
* injecting an empty <style> tag.
|
|
192
|
+
*/
|
|
193
|
+
function renderThemeCss(sets) {
|
|
194
|
+
if (!sets)
|
|
195
|
+
return null;
|
|
196
|
+
const items = sets.byKindSlug["theme/site"];
|
|
197
|
+
if (!items || items.length === 0)
|
|
198
|
+
return null;
|
|
199
|
+
const tokens = items.filter((it) => {
|
|
200
|
+
if (!it || typeof it !== "object")
|
|
201
|
+
return false;
|
|
202
|
+
const o = it;
|
|
203
|
+
return typeof o.token === "string" && typeof o.value === "string";
|
|
204
|
+
});
|
|
205
|
+
if (tokens.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
return `:root{${tokens.map((t) => `--${t.token}: ${t.value};`).join("")}}`;
|
|
208
|
+
}
|
|
209
|
+
export function tagModuleId(html, moduleId) {
|
|
210
|
+
if (!html)
|
|
211
|
+
return html;
|
|
212
|
+
const firstOpen = /<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/;
|
|
213
|
+
const m = firstOpen.exec(html);
|
|
214
|
+
if (!m)
|
|
215
|
+
return html;
|
|
216
|
+
// Already tagged?
|
|
217
|
+
const tagAttrs = m[2] ?? "";
|
|
218
|
+
if (/\sdata-caelo-module-id\s*=/.test(tagAttrs))
|
|
219
|
+
return html;
|
|
220
|
+
const replaced = `<${m[1]}${tagAttrs} data-caelo-module-id="${moduleId}">`;
|
|
221
|
+
return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Typed failure for the layout-aware composer. Use `kind` to dispatch:
|
|
225
|
+
* - `layout-missing-content`: the layout HTML lacks
|
|
226
|
+
* `<caelo-slot name="content">…</caelo-slot>` so the page body has
|
|
227
|
+
* nowhere to land.
|
|
228
|
+
*/
|
|
229
|
+
export class ComposeError extends Error {
|
|
230
|
+
kind;
|
|
231
|
+
layoutSlug;
|
|
232
|
+
constructor(kind, message, layoutSlug) {
|
|
233
|
+
super(message);
|
|
234
|
+
this.name = "ComposeError";
|
|
235
|
+
this.kind = kind;
|
|
236
|
+
this.layoutSlug = layoutSlug;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const BODY_OPEN_RE = /<body\b[^>]*>/i;
|
|
240
|
+
/**
|
|
241
|
+
* Extract the inner body HTML from a fully rendered template document.
|
|
242
|
+
* If the template HTML has no <body> (legacy fragment templates), the
|
|
243
|
+
* whole composed string is returned as-is — the layout's
|
|
244
|
+
* `<caelo-slot name="content">` becomes a generic mount point and the
|
|
245
|
+
* layout owns <html><head><body>.
|
|
246
|
+
*
|
|
247
|
+
* Legacy templates often wrap their slot in `<body><caelo-slot
|
|
248
|
+
* name="content">…</caelo-slot></body>` — peel off the redundant
|
|
249
|
+
* `<caelo-slot>` so we don't end up with the layout's own slot
|
|
250
|
+
* containing yet another `<caelo-slot>`. The peel uses the same
|
|
251
|
+
* htmlparser2 Parser as `applySlotReplacements` so quoting / attribute
|
|
252
|
+
* ordering / whitespace variations are handled uniformly (the previous
|
|
253
|
+
* regex silently fell through on `name='content'`, attr reordering,
|
|
254
|
+
* etc., producing nested-slot output).
|
|
255
|
+
*/
|
|
256
|
+
function extractBodyInner(composedHtml) {
|
|
257
|
+
const open = BODY_OPEN_RE.exec(composedHtml);
|
|
258
|
+
const close = BODY_CLOSE_RE.exec(composedHtml);
|
|
259
|
+
let inner;
|
|
260
|
+
if (!open || !close || close.index < open.index) {
|
|
261
|
+
inner = composedHtml;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const start = open.index + open[0].length;
|
|
265
|
+
inner = composedHtml.slice(start, close.index);
|
|
266
|
+
}
|
|
267
|
+
const peeled = extractInnerOfTopLevelContentSlot(inner);
|
|
268
|
+
return peeled ?? inner;
|
|
269
|
+
}
|
|
270
|
+
export function composePageWithLayout(input) {
|
|
271
|
+
// No-fallbacks (CLAUDE.md §2): validate the layout declares a
|
|
272
|
+
// `content` slot up-front, before rendering. The htmlparser2-based
|
|
273
|
+
// walk handles attribute quoting / ordering uniformly; a layout
|
|
274
|
+
// without the slot is a misconfiguration that must surface to the
|
|
275
|
+
// caller, not silently emit a body-less page.
|
|
276
|
+
if (!listSlotNames(input.layoutHtml).includes("content")) {
|
|
277
|
+
const slug = input.layoutSlug ?? "(unknown)";
|
|
278
|
+
throw new ComposeError("layout-missing-content", `layout "${slug}" is missing the required \`<caelo-slot name="content">\` slot — fix via /security/layouts`, input.layoutSlug);
|
|
279
|
+
}
|
|
280
|
+
// CSS / JS aggregation order: layout (ground) → template (overrides
|
|
281
|
+
// layout) → modules (highest specificity). The array's source order
|
|
282
|
+
// drives cascade order in the emitted <style> tag, so we push in
|
|
283
|
+
// priority sequence rather than mixing push + unshift (which is
|
|
284
|
+
// brittle and reads as a bug).
|
|
285
|
+
const cssParts = [];
|
|
286
|
+
const jsParts = [];
|
|
287
|
+
if (input.layoutCss.trim().length > 0)
|
|
288
|
+
cssParts.push(input.layoutCss);
|
|
289
|
+
if (input.templateCss.trim().length > 0)
|
|
290
|
+
cssParts.push(input.templateCss);
|
|
291
|
+
// 1. Render the page modules into the template (slot replacement only;
|
|
292
|
+
// no head/body manipulation here — that belongs to the layout).
|
|
293
|
+
const templateContentByName = new Map();
|
|
294
|
+
for (const block of input.blocks) {
|
|
295
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
296
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
297
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
298
|
+
let baseHtml;
|
|
299
|
+
if (navMenuItems !== null) {
|
|
300
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
301
|
+
}
|
|
302
|
+
else if (langSelector !== null) {
|
|
303
|
+
baseHtml = langSelector;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
baseHtml = m.html;
|
|
307
|
+
}
|
|
308
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
309
|
+
});
|
|
310
|
+
templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
311
|
+
for (const m of block.modules) {
|
|
312
|
+
if (m.css.trim().length > 0)
|
|
313
|
+
cssParts.push(m.css);
|
|
314
|
+
if (m.js.trim().length > 0)
|
|
315
|
+
jsParts.push(m.js);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const renderedTemplate = applySlotReplacements(input.templateHtml, {
|
|
319
|
+
contentByName: templateContentByName,
|
|
320
|
+
});
|
|
321
|
+
const innerBody = extractBodyInner(renderedTemplate.html);
|
|
322
|
+
// 2. Build per-layout-block contents (header / footer / etc.) +
|
|
323
|
+
// aggregate their CSS/JS at module specificity (already higher
|
|
324
|
+
// than layout/template because the layout/template parts went
|
|
325
|
+
// in first above).
|
|
326
|
+
const layoutContentByName = new Map();
|
|
327
|
+
layoutContentByName.set("content", innerBody);
|
|
328
|
+
for (const block of input.layoutBlocks) {
|
|
329
|
+
if (block.blockName === "content")
|
|
330
|
+
continue; // reserved for the page body
|
|
331
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
332
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
333
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
334
|
+
let baseHtml;
|
|
335
|
+
if (navMenuItems !== null) {
|
|
336
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
337
|
+
}
|
|
338
|
+
else if (langSelector !== null) {
|
|
339
|
+
baseHtml = langSelector;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
baseHtml = m.html;
|
|
343
|
+
}
|
|
344
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
345
|
+
});
|
|
346
|
+
layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
347
|
+
for (const m of block.modules) {
|
|
348
|
+
if (m.css.trim().length > 0)
|
|
349
|
+
cssParts.push(m.css);
|
|
350
|
+
if (m.js.trim().length > 0)
|
|
351
|
+
jsParts.push(m.js);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// 3. Render the layout HTML, substituting all named slots.
|
|
355
|
+
const replaced = applySlotReplacements(input.layoutHtml, {
|
|
356
|
+
contentByName: layoutContentByName,
|
|
357
|
+
});
|
|
358
|
+
let html = replaced.html;
|
|
359
|
+
const themeCss = renderThemeCss(input.structuredSets);
|
|
360
|
+
if (themeCss !== null) {
|
|
361
|
+
html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="theme">${themeCss}</style>`);
|
|
362
|
+
}
|
|
363
|
+
if (cssParts.length > 0) {
|
|
364
|
+
html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="modules">\n${cssParts.join("\n")}\n</style>`);
|
|
365
|
+
}
|
|
366
|
+
if (jsParts.length > 0) {
|
|
367
|
+
html = injectBefore(html, BODY_CLOSE_RE, `<script defer data-source="modules">\n${jsParts.join("\n")}\n</script>`);
|
|
368
|
+
}
|
|
369
|
+
// De-duplicate slot accounting across both passes — the template's
|
|
370
|
+
// `content` slot and the layout's `content` slot are conceptually the
|
|
371
|
+
// same surface to a caller asking "did content get filled?".
|
|
372
|
+
const replacedSet = new Set([
|
|
373
|
+
...renderedTemplate.replacedSlots,
|
|
374
|
+
...replaced.replacedSlots,
|
|
375
|
+
]);
|
|
376
|
+
const missingSet = new Set([...renderedTemplate.missingSlots, ...replaced.missingSlots]);
|
|
377
|
+
for (const name of replacedSet)
|
|
378
|
+
missingSet.delete(name);
|
|
379
|
+
return {
|
|
380
|
+
html,
|
|
381
|
+
replacedSlots: [...replacedSet],
|
|
382
|
+
missingSlots: [...missingSet],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=preview-compose.js.map
|