@caelo-cms/shared 0.10.22 → 0.10.23
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 +290 -214
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +341 -264
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { applySlotReplacements } from "./preview-scanner.js";
|
|
5
|
+
|
|
6
|
+
const map = (entries: [string, string][]) => new Map(entries);
|
|
7
|
+
|
|
8
|
+
describe("applySlotReplacements", () => {
|
|
9
|
+
it("returns input unchanged when there are no slots", () => {
|
|
10
|
+
const html = "<html><body><h1>Static</h1></body></html>";
|
|
11
|
+
const result = applySlotReplacements(html, { contentByName: map([]) });
|
|
12
|
+
expect(result.html).toBe(html);
|
|
13
|
+
expect(result.replacedSlots).toEqual([]);
|
|
14
|
+
expect(result.missingSlots).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("replaces a single named slot's inner HTML", () => {
|
|
18
|
+
const html = `<body><caelo-slot name="content">placeholder</caelo-slot></body>`;
|
|
19
|
+
const result = applySlotReplacements(html, {
|
|
20
|
+
contentByName: map([["content", "<p>HELLO</p>"]]),
|
|
21
|
+
});
|
|
22
|
+
expect(result.html).toBe(`<body><caelo-slot name="content"><p>HELLO</p></caelo-slot></body>`);
|
|
23
|
+
expect(result.replacedSlots).toEqual(["content"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preserves the slot wrapper so CSS selectors still match", () => {
|
|
27
|
+
const html = `<caelo-slot name="x"></caelo-slot>`;
|
|
28
|
+
const result = applySlotReplacements(html, { contentByName: map([["x", "FILLED"]]) });
|
|
29
|
+
expect(result.html).toBe(`<caelo-slot name="x">FILLED</caelo-slot>`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("replaces multiple slots in order", () => {
|
|
33
|
+
const html = `<caelo-slot name="header">_</caelo-slot><caelo-slot name="footer">_</caelo-slot>`;
|
|
34
|
+
const result = applySlotReplacements(html, {
|
|
35
|
+
contentByName: map([
|
|
36
|
+
["header", "TOP"],
|
|
37
|
+
["footer", "BOT"],
|
|
38
|
+
]),
|
|
39
|
+
});
|
|
40
|
+
expect(result.html).toBe(
|
|
41
|
+
`<caelo-slot name="header">TOP</caelo-slot><caelo-slot name="footer">BOT</caelo-slot>`,
|
|
42
|
+
);
|
|
43
|
+
expect(result.replacedSlots).toEqual(["header", "footer"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("records slots without a replacement and preserves their original inner HTML", () => {
|
|
47
|
+
const html = `<caelo-slot name="missing">DEFAULT</caelo-slot>`;
|
|
48
|
+
const result = applySlotReplacements(html, { contentByName: map([]) });
|
|
49
|
+
expect(result.html).toBe(`<caelo-slot name="missing">DEFAULT</caelo-slot>`);
|
|
50
|
+
expect(result.missingSlots).toEqual(["missing"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles single-quoted name attributes", () => {
|
|
54
|
+
const html = `<caelo-slot name='content'>x</caelo-slot>`;
|
|
55
|
+
const result = applySlotReplacements(html, { contentByName: map([["content", "Y"]]) });
|
|
56
|
+
expect(result.html).toBe(`<caelo-slot name='content'>Y</caelo-slot>`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws on an unterminated slot opener", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
applySlotReplacements(`<caelo-slot name="x">no close`, { contentByName: map([]) }),
|
|
62
|
+
).toThrow(/unterminated/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("throws on a nested slot", () => {
|
|
66
|
+
const html = `<caelo-slot name="a"><caelo-slot name="b">y</caelo-slot></caelo-slot>`;
|
|
67
|
+
expect(() => applySlotReplacements(html, { contentByName: map([]) })).toThrow(/nested/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("leaves bytes outside slots unchanged", () => {
|
|
71
|
+
const before = `<!doctype html><html lang="en"><head><title>T</title></head><body>`;
|
|
72
|
+
const after = `</body></html>`;
|
|
73
|
+
const html = `${before}<caelo-slot name="x">_</caelo-slot>${after}`;
|
|
74
|
+
const result = applySlotReplacements(html, { contentByName: map([["x", "Y"]]) });
|
|
75
|
+
expect(result.html).toBe(`${before}<caelo-slot name="x">Y</caelo-slot>${after}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores caelo-slot inside an HTML comment", () => {
|
|
79
|
+
// The regex-based version mistakenly treated this as a real slot. The
|
|
80
|
+
// parser-based version ignores everything inside <!-- … -->.
|
|
81
|
+
const html = `<!-- <caelo-slot name="ghost">x</caelo-slot> --><caelo-slot name="real">_</caelo-slot>`;
|
|
82
|
+
const result = applySlotReplacements(html, { contentByName: map([["real", "Y"]]) });
|
|
83
|
+
expect(result.html).toBe(
|
|
84
|
+
`<!-- <caelo-slot name="ghost">x</caelo-slot> --><caelo-slot name="real">Y</caelo-slot>`,
|
|
85
|
+
);
|
|
86
|
+
expect(result.replacedSlots).toEqual(["real"]);
|
|
87
|
+
expect(result.missingSlots).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ignores caelo-slot mention inside an attribute value", () => {
|
|
91
|
+
const html = `<div data-doc="example: <caelo-slot name='fake'>"></div><caelo-slot name="real">_</caelo-slot>`;
|
|
92
|
+
const result = applySlotReplacements(html, { contentByName: map([["real", "Y"]]) });
|
|
93
|
+
expect(result.html).toContain(`<caelo-slot name="real">Y</caelo-slot>`);
|
|
94
|
+
expect(result.replacedSlots).toEqual(["real"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<caelo-slot name="…">…</caelo-slot>` scanner backed by `htmlparser2`'s
|
|
5
|
+
* Parser. Replaces the inner HTML of each named slot with caller-supplied
|
|
6
|
+
* content, leaving every byte outside the slots unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Why a real parser: the previous regex-based version mishandled comments
|
|
9
|
+
* (`<!-- <caelo-slot> -->`), CDATA, and attributes containing `<`, and
|
|
10
|
+
* required hand-rolled "no nested slots" detection. htmlparser2 is MIT,
|
|
11
|
+
* tiny, and dependency-free — same parser the larger Cheerio/Jsdom-style
|
|
12
|
+
* tools use under the hood.
|
|
13
|
+
*
|
|
14
|
+
* Invariants preserved from the regex version:
|
|
15
|
+
* - Bytes outside `<caelo-slot>` ranges are byte-stable in the output.
|
|
16
|
+
* - The `<caelo-slot ...>` opening + closing tags themselves are
|
|
17
|
+
* preserved (only inner HTML is replaced) so CSS selectors keep matching.
|
|
18
|
+
* - Nested `<caelo-slot>` inside another `<caelo-slot>` is rejected.
|
|
19
|
+
* - Unterminated openers are rejected.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Parser } from "htmlparser2";
|
|
23
|
+
|
|
24
|
+
const SLOT_TAG = "caelo-slot";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* P6.7.6 review pass — return the list of `<caelo-slot name="…">` slot
|
|
28
|
+
* names declared in `html`, in source order. Used by the layout-aware
|
|
29
|
+
* composer to validate that a layout declares the required `content`
|
|
30
|
+
* slot before rendering, so misconfigurations error loudly per the
|
|
31
|
+
* no-fallbacks invariant. Walks the same htmlparser2 Parser as
|
|
32
|
+
* `applySlotReplacements` so attribute quoting / ordering / whitespace
|
|
33
|
+
* are handled uniformly.
|
|
34
|
+
*/
|
|
35
|
+
export function listSlotNames(html: string): readonly string[] {
|
|
36
|
+
const names: string[] = [];
|
|
37
|
+
const parser = new Parser(
|
|
38
|
+
{
|
|
39
|
+
onopentag(name, attrs) {
|
|
40
|
+
if (name !== SLOT_TAG) return;
|
|
41
|
+
names.push(attrs.name ?? "");
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{ lowerCaseTags: true, recognizeSelfClosing: false },
|
|
45
|
+
);
|
|
46
|
+
parser.write(html);
|
|
47
|
+
parser.end();
|
|
48
|
+
return names;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* P6.7.6 review pass — when a rendered template body is exactly one
|
|
53
|
+
* top-level `<caelo-slot name="content">…</caelo-slot>` element
|
|
54
|
+
* (typical of legacy templates whose body wraps the slot directly),
|
|
55
|
+
* peel the wrapper and return just its inner HTML. Returns `null` when
|
|
56
|
+
* the body has multiple top-level elements, no top-level content slot,
|
|
57
|
+
* or any other shape — so the caller can fall back to using the body
|
|
58
|
+
* as-is.
|
|
59
|
+
*
|
|
60
|
+
* Replaces the previous regex-based peel which silently failed on
|
|
61
|
+
* single-quote attributes, attribute reordering, or whitespace
|
|
62
|
+
* variations.
|
|
63
|
+
*/
|
|
64
|
+
export function extractInnerOfTopLevelContentSlot(html: string): string | null {
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let topLevelOpens = 0;
|
|
67
|
+
let topLevelTextBetween = false;
|
|
68
|
+
let contentSlotInnerStart = -1;
|
|
69
|
+
let contentSlotInnerEnd = -1;
|
|
70
|
+
let foundContentSlot = false;
|
|
71
|
+
const parser = new Parser(
|
|
72
|
+
{
|
|
73
|
+
onopentag(name, attrs) {
|
|
74
|
+
if (depth === 0) {
|
|
75
|
+
topLevelOpens += 1;
|
|
76
|
+
if (name === SLOT_TAG && attrs.name === "content" && topLevelOpens === 1) {
|
|
77
|
+
foundContentSlot = true;
|
|
78
|
+
contentSlotInnerStart = parser.endIndex + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
depth += 1;
|
|
82
|
+
},
|
|
83
|
+
onclosetag(name) {
|
|
84
|
+
depth -= 1;
|
|
85
|
+
if (depth === 0 && name === SLOT_TAG && foundContentSlot && contentSlotInnerEnd === -1) {
|
|
86
|
+
contentSlotInnerEnd = parser.startIndex;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
ontext(text) {
|
|
90
|
+
if (depth === 0 && text.trim().length > 0) topLevelTextBetween = true;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{ lowerCaseTags: true, recognizeSelfClosing: false },
|
|
94
|
+
);
|
|
95
|
+
parser.write(html);
|
|
96
|
+
parser.end();
|
|
97
|
+
if (
|
|
98
|
+
!foundContentSlot ||
|
|
99
|
+
topLevelOpens !== 1 ||
|
|
100
|
+
topLevelTextBetween ||
|
|
101
|
+
contentSlotInnerStart === -1 ||
|
|
102
|
+
contentSlotInnerEnd === -1
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return html.slice(contentSlotInnerStart, contentSlotInnerEnd);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SlotReplacement {
|
|
110
|
+
/** Inner HTML for each `<caelo-slot name="…">`. */
|
|
111
|
+
readonly contentByName: ReadonlyMap<string, string>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ScanResult {
|
|
115
|
+
readonly html: string;
|
|
116
|
+
readonly replacedSlots: readonly string[];
|
|
117
|
+
readonly missingSlots: readonly string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface SlotRange {
|
|
121
|
+
readonly name: string;
|
|
122
|
+
/** Byte offset just after the opening `>`. */
|
|
123
|
+
readonly innerStart: number;
|
|
124
|
+
/** Byte offset of the opening `<` of the closing tag. */
|
|
125
|
+
readonly innerEnd: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns a copy of `templateHtml` where each `<caelo-slot name="X">…</caelo-slot>`
|
|
130
|
+
* has its inner HTML replaced by `replacement.contentByName.get(X)`. Slots
|
|
131
|
+
* with no entry render with their original inner HTML preserved (so a
|
|
132
|
+
* template-author placeholder stays visible in the editor preview).
|
|
133
|
+
*
|
|
134
|
+
* Throws on nested or unbalanced slot markers — those are template-author
|
|
135
|
+
* bugs and silently smoothing them over would just defer the discovery.
|
|
136
|
+
*/
|
|
137
|
+
export function applySlotReplacements(
|
|
138
|
+
templateHtml: string,
|
|
139
|
+
replacement: SlotReplacement,
|
|
140
|
+
): ScanResult {
|
|
141
|
+
const ranges: SlotRange[] = [];
|
|
142
|
+
let openName: string | null = null;
|
|
143
|
+
let openInnerStart = 0;
|
|
144
|
+
let openOpenStart = 0;
|
|
145
|
+
|
|
146
|
+
const parser = new Parser(
|
|
147
|
+
{
|
|
148
|
+
onopentag(name, attrs) {
|
|
149
|
+
if (name !== SLOT_TAG) return;
|
|
150
|
+
if (openName !== null) {
|
|
151
|
+
throw new Error(`nested <caelo-slot> not allowed (at offset ${parser.startIndex})`);
|
|
152
|
+
}
|
|
153
|
+
openName = attrs.name ?? "";
|
|
154
|
+
// `endIndex` of the opening tag points at the closing `>`; the inner
|
|
155
|
+
// HTML therefore starts one byte later.
|
|
156
|
+
openOpenStart = parser.startIndex;
|
|
157
|
+
openInnerStart = parser.endIndex + 1;
|
|
158
|
+
},
|
|
159
|
+
onclosetag(name) {
|
|
160
|
+
if (name !== SLOT_TAG) return;
|
|
161
|
+
if (openName === null) {
|
|
162
|
+
throw new Error(`closing </caelo-slot> with no opener (at offset ${parser.startIndex})`);
|
|
163
|
+
}
|
|
164
|
+
// htmlparser2 auto-closes unterminated tags at EOF — detect that case
|
|
165
|
+
// by verifying the source actually contains `</caelo-slot>` at the
|
|
166
|
+
// reported offset. Auto-closed tags fire onclosetag at EOF without a
|
|
167
|
+
// matching `</` in the input.
|
|
168
|
+
const hasExplicitClose = templateHtml
|
|
169
|
+
.slice(parser.startIndex, parser.startIndex + SLOT_TAG.length + 3)
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.startsWith(`</${SLOT_TAG}`);
|
|
172
|
+
if (!hasExplicitClose) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`unterminated <caelo-slot name="${openName}"> at offset ${openOpenStart}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
ranges.push({
|
|
178
|
+
name: openName,
|
|
179
|
+
innerStart: openInnerStart,
|
|
180
|
+
innerEnd: parser.startIndex,
|
|
181
|
+
});
|
|
182
|
+
openName = null;
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
// recognizeSelfClosing handles `<caelo-slot name="x" />` style; we still
|
|
186
|
+
// require an explicit close tag so that case skips replacement (matches
|
|
187
|
+
// the documented "self-close passes through" limitation).
|
|
188
|
+
{ lowerCaseTags: true, recognizeSelfClosing: false },
|
|
189
|
+
);
|
|
190
|
+
parser.write(templateHtml);
|
|
191
|
+
parser.end();
|
|
192
|
+
|
|
193
|
+
if (openName !== null) {
|
|
194
|
+
throw new Error(`unterminated <caelo-slot name="${openName}"> at offset ${openOpenStart}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
const replaced: string[] = [];
|
|
199
|
+
const missing: string[] = [];
|
|
200
|
+
let cursor = 0;
|
|
201
|
+
for (const range of ranges) {
|
|
202
|
+
parts.push(templateHtml.slice(cursor, range.innerStart));
|
|
203
|
+
if (replacement.contentByName.has(range.name)) {
|
|
204
|
+
parts.push(replacement.contentByName.get(range.name) ?? "");
|
|
205
|
+
replaced.push(range.name);
|
|
206
|
+
} else {
|
|
207
|
+
missing.push(range.name);
|
|
208
|
+
parts.push(templateHtml.slice(range.innerStart, range.innerEnd));
|
|
209
|
+
}
|
|
210
|
+
cursor = range.innerEnd;
|
|
211
|
+
}
|
|
212
|
+
parts.push(templateHtml.slice(cursor));
|
|
213
|
+
return { html: parts.join(""), replacedSlots: replaced, missingSlots: missing };
|
|
214
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit guards for the canonical propose/execute status enum. The
|
|
5
|
+
* load-bearing invariants: the four states never silently change order or
|
|
6
|
+
* membership when ~14 pending-op schemas migrate to this shared source, and
|
|
7
|
+
* the *proposal* enum never absorbs the semantically-different `"accepted"`
|
|
8
|
+
* review-status family (CLAUDE.md §11.A, plan Out-of-scope §3).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { PROPOSAL_STATUSES, type ProposalStatus, proposalStatus } from "./proposal-status.js";
|
|
14
|
+
|
|
15
|
+
describe("proposalStatus", () => {
|
|
16
|
+
it("has exactly the four states, in canonical order", () => {
|
|
17
|
+
expect(PROPOSAL_STATUSES).toEqual(["pending", "applied", "rejected", "superseded"]);
|
|
18
|
+
expect(proposalStatus.options).toEqual(["pending", "applied", "rejected", "superseded"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("parses every member back to itself", () => {
|
|
22
|
+
for (const s of PROPOSAL_STATUSES) {
|
|
23
|
+
expect(proposalStatus.parse(s)).toBe(s);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects values outside the vocabulary (incl. the review-status family)", () => {
|
|
28
|
+
// "accepted" is the skill/ai-memory review vocabulary, NOT a proposal
|
|
29
|
+
// state — this guard is why the two enums must stay separate.
|
|
30
|
+
expect(proposalStatus.safeParse("accepted").success).toBe(false);
|
|
31
|
+
expect(proposalStatus.safeParse("draft").success).toBe(false);
|
|
32
|
+
expect(proposalStatus.safeParse("all").success).toBe(false);
|
|
33
|
+
expect(proposalStatus.safeParse("").success).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("derives the documented domain variants without re-typing the 4-tuple", () => {
|
|
37
|
+
// locales list-filter: + "all"
|
|
38
|
+
expect(z.enum([...PROPOSAL_STATUSES, "all"] as const).options).toEqual([
|
|
39
|
+
"pending",
|
|
40
|
+
"applied",
|
|
41
|
+
"rejected",
|
|
42
|
+
"superseded",
|
|
43
|
+
"all",
|
|
44
|
+
]);
|
|
45
|
+
// themes: + "cancelled"
|
|
46
|
+
expect(z.enum([...PROPOSAL_STATUSES, "cancelled"] as const).options).toEqual([
|
|
47
|
+
"pending",
|
|
48
|
+
"applied",
|
|
49
|
+
"rejected",
|
|
50
|
+
"superseded",
|
|
51
|
+
"cancelled",
|
|
52
|
+
]);
|
|
53
|
+
// deploy proposals: no "superseded"
|
|
54
|
+
expect(proposalStatus.exclude(["superseded"]).options).toEqual([
|
|
55
|
+
"pending",
|
|
56
|
+
"applied",
|
|
57
|
+
"rejected",
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("exports a ProposalStatus type that is the union and nothing wider", () => {
|
|
62
|
+
const ok: ProposalStatus = "pending";
|
|
63
|
+
expect(ok).toBe("pending");
|
|
64
|
+
// @ts-expect-error "accepted" is not a ProposalStatus
|
|
65
|
+
const bad: ProposalStatus = "accepted";
|
|
66
|
+
// reference `bad` so the binding isn't elided before the ts-expect-error applies
|
|
67
|
+
expect(typeof bad).toBe("string");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical status vocabulary for the propose/execute pattern (CLAUDE.md
|
|
5
|
+
* §11.A, `docs/propose-execute-pattern.md`). Every gated domain's
|
|
6
|
+
* `<domain>_pending_actions` row moves through these four states:
|
|
7
|
+
*
|
|
8
|
+
* pending — AI/Owner queued the proposal; awaiting a human click.
|
|
9
|
+
* applied — Owner approved; `execute_proposal` ran the real op.
|
|
10
|
+
* rejected — Owner declined (with an optional reason).
|
|
11
|
+
* superseded — a newer proposal for the same target replaced this one
|
|
12
|
+
* (dedup/GC), so it can never be approved.
|
|
13
|
+
*
|
|
14
|
+
* Lives in `@caelo-cms/shared` so the ~14 pending-op Zod schemas import one
|
|
15
|
+
* source instead of re-typing the literal — which is how the four states
|
|
16
|
+
* stay value-identical across every domain that adopted the pattern.
|
|
17
|
+
*
|
|
18
|
+
* Domains that need extra states extend this rather than re-typing it:
|
|
19
|
+
* - list-filter ops add `"all"`: `z.enum([...PROPOSAL_STATUSES, "all"] as const)`
|
|
20
|
+
* - themes adds `"cancelled"`: `z.enum([...PROPOSAL_STATUSES, "cancelled"] as const)`
|
|
21
|
+
* - deploy proposals never get `"superseded"`: `proposalStatus.exclude(["superseded"])`
|
|
22
|
+
* Keep the `as const` on any spread so `z.enum` infers the literal union,
|
|
23
|
+
* not `string[]`.
|
|
24
|
+
*
|
|
25
|
+
* NOTE: this is NOT the vocabulary for skill/AI-memory *review* status
|
|
26
|
+
* (`accepted`/`rejected`) nor for deploy/translation *run* status
|
|
27
|
+
* (`running`/`succeeded`/…) — those are different value sets and must not
|
|
28
|
+
* be collapsed into this enum.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
/** The four propose/execute states, in canonical order. */
|
|
34
|
+
export const PROPOSAL_STATUSES = ["pending", "applied", "rejected", "superseded"] as const;
|
|
35
|
+
|
|
36
|
+
/** Zod enum over {@link PROPOSAL_STATUSES} — the single source for every gated-domain `status` schema. */
|
|
37
|
+
export const proposalStatus = z.enum(PROPOSAL_STATUSES);
|
|
38
|
+
|
|
39
|
+
/** Union of the four propose/execute states. */
|
|
40
|
+
export type ProposalStatus = z.infer<typeof proposalStatus>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #162 — shared responsive-image enrichment: production shape
|
|
5
|
+
* (src rewritten to _assets) vs preview shape (src kept on the admin
|
|
6
|
+
* media route), author precedence, and the two-entry ladder rule.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
enrichResponsiveImages,
|
|
12
|
+
type ImageVariantInfo,
|
|
13
|
+
pickAiImageVariant,
|
|
14
|
+
} from "./responsive-images.js";
|
|
15
|
+
|
|
16
|
+
const ASSET = "12345678-1234-4123-8123-123456789abc";
|
|
17
|
+
const VARIANTS: ImageVariantInfo[] = [
|
|
18
|
+
{ variant: "webp-400", format: "webp" },
|
|
19
|
+
{ variant: "webp-800", format: "webp" },
|
|
20
|
+
{ variant: "webp-1200", format: "webp" },
|
|
21
|
+
{ variant: "orig", format: "jpeg" },
|
|
22
|
+
];
|
|
23
|
+
const byAsset = new Map([[ASSET, VARIANTS]]);
|
|
24
|
+
const IMG = `<img src="/_caelo/media/${ASSET}/webp-800" alt="Team">`;
|
|
25
|
+
|
|
26
|
+
describe("enrichResponsiveImages (issue #162)", () => {
|
|
27
|
+
it("production mode rewrites src and builds the _assets ladder", () => {
|
|
28
|
+
const out = enrichResponsiveImages(IMG, byAsset, {
|
|
29
|
+
rewriteSrc: true,
|
|
30
|
+
urlFor: (id, v, f) => `/_assets/${id}/${v}.${f === "webp" ? "webp" : "jpg"}`,
|
|
31
|
+
});
|
|
32
|
+
expect(out).toContain(`src="/_assets/${ASSET}/webp-800.webp"`);
|
|
33
|
+
expect(out).toContain(`/_assets/${ASSET}/webp-400.webp 400w`);
|
|
34
|
+
expect(out).toContain('loading="lazy"');
|
|
35
|
+
expect(out).toContain('decoding="async"');
|
|
36
|
+
expect(out).toContain('alt="Team"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preview mode keeps the admin src but emits the SAME attribute shape", () => {
|
|
40
|
+
const out = enrichResponsiveImages(IMG, byAsset, {
|
|
41
|
+
rewriteSrc: false,
|
|
42
|
+
urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
|
|
43
|
+
});
|
|
44
|
+
expect(out).toContain(`src="/_caelo/media/${ASSET}/webp-800"`);
|
|
45
|
+
expect(out).toContain(`/_caelo/media/${ASSET}/webp-400 400w`);
|
|
46
|
+
expect(out).toContain(`sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 800px"`);
|
|
47
|
+
expect(out).toContain('loading="lazy"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("author-supplied attributes keep precedence", () => {
|
|
51
|
+
const authored = `<img loading="eager" srcset="x 1w" src="/_caelo/media/${ASSET}/webp-800">`;
|
|
52
|
+
const out = enrichResponsiveImages(authored, byAsset, {
|
|
53
|
+
rewriteSrc: false,
|
|
54
|
+
urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
|
|
55
|
+
});
|
|
56
|
+
expect(out).toContain('loading="eager"');
|
|
57
|
+
expect(out).toContain('srcset="x 1w"');
|
|
58
|
+
expect(out.match(/srcset=/g)).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips srcset for sub-two-entry ladders and non-media images", () => {
|
|
62
|
+
const single = new Map([[ASSET, [{ variant: "webp-800", format: "webp" }]]]);
|
|
63
|
+
const out = enrichResponsiveImages(IMG, single, {
|
|
64
|
+
rewriteSrc: false,
|
|
65
|
+
urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
|
|
66
|
+
});
|
|
67
|
+
expect(out).not.toContain("srcset=");
|
|
68
|
+
const external = '<img src="https://example.com/x.png">';
|
|
69
|
+
expect(
|
|
70
|
+
enrichResponsiveImages(external, byAsset, {
|
|
71
|
+
rewriteSrc: false,
|
|
72
|
+
urlFor: (id, v) => `/${id}/${v}`,
|
|
73
|
+
}),
|
|
74
|
+
).toBe(external);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run #10 D4 — AI-facing surfaces must advertise a variant that EXISTS.
|
|
80
|
+
* The pipeline never emits webp-800 for sub-800px sources or animated
|
|
81
|
+
* GIFs; advertising it anyway blocked the whole staging build.
|
|
82
|
+
*/
|
|
83
|
+
describe("pickAiImageVariant", () => {
|
|
84
|
+
it("prefers webp-800 when present", () => {
|
|
85
|
+
expect(pickAiImageVariant(["orig", "webp-400", "webp-800", "webp-1200"])).toBe("webp-800");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("falls back to the largest webp below 800 (small source, no upscaling)", () => {
|
|
89
|
+
expect(pickAiImageVariant(["orig", "webp-400"])).toBe("webp-400");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to the smallest webp above 800 when nothing smaller exists", () => {
|
|
93
|
+
expect(pickAiImageVariant(["orig", "webp-1200", "webp-1600"])).toBe("webp-1200");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns orig when no webp exists (animated GIF, SVG, PDF, pipeline gap)", () => {
|
|
97
|
+
expect(pickAiImageVariant(["orig"])).toBe("orig");
|
|
98
|
+
expect(pickAiImageVariant([])).toBe("orig");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores crop-family variants when picking", () => {
|
|
102
|
+
expect(pickAiImageVariant(["orig", "square-800", "webp-400"])).toBe("webp-400");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #162 — responsive-image markup, ONE implementation for both
|
|
5
|
+
* render surfaces. Production (the static generator's media pass) and
|
|
6
|
+
* the editor preview must emit the same `srcset`/`sizes`/`loading`/
|
|
7
|
+
* `decoding` shape — the operator tunes visuals against the preview,
|
|
8
|
+
* and the #155 self-review loop makes design decisions from preview
|
|
9
|
+
* screenshots, so markup drift there means optimizing the wrong
|
|
10
|
+
* artifact. Only the URL form differs (parameterized `urlFor`):
|
|
11
|
+
* `/_assets/<id>/<variant>.<ext>` in builds, `/_caelo/media/<id>/<variant>`
|
|
12
|
+
* in the admin preview.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from apps/static-generator/src/media-pass.ts verbatim in
|
|
15
|
+
* behaviour: author-supplied srcset/sizes/loading/decoding always keep
|
|
16
|
+
* precedence; ladders under two entries add no srcset.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ImageVariantInfo {
|
|
20
|
+
readonly variant: string;
|
|
21
|
+
readonly format: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Extract `webp-800` → 800; `square-400` → 400; `orig` → null. */
|
|
25
|
+
export function parseVariantWidth(variant: string): number | null {
|
|
26
|
+
const m = variant.match(/-(\d+)$/);
|
|
27
|
+
return m?.[1] ? Number.parseInt(m[1], 10) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `webp-800` → 'webp'; `square-800` → 'square'; `orig` → 'orig'. */
|
|
31
|
+
export function variantFamily(variant: string): string {
|
|
32
|
+
const m = variant.match(/^([a-z][a-z0-9-]*?)-\d+$/);
|
|
33
|
+
return m?.[1] ? m[1] : variant;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pick the variant tag AI-facing surfaces should hand out for an
|
|
38
|
+
* asset, given the variant tags that ACTUALLY exist on it.
|
|
39
|
+
*
|
|
40
|
+
* Run #10 D4: `find_media` and the `## Media` system-prompt block
|
|
41
|
+
* advertised `webp-800` for every raster mime, but the pipeline never
|
|
42
|
+
* emits `webp-800` for sources narrower than 800px (no upscaling) or
|
|
43
|
+
* for animated GIFs — the AI wrote those URLs into module HTML and the
|
|
44
|
+
* static generator's media pass failed the whole staging build on
|
|
45
|
+
* "asset/variant pairs missing". Advertising must be grounded in the
|
|
46
|
+
* `media_variants` rows, not in the mime.
|
|
47
|
+
*
|
|
48
|
+
* Preference order: `webp-800` when present; else the LARGEST webp at
|
|
49
|
+
* or below 800 (best quality that exists without shipping a hero-sized
|
|
50
|
+
* file); else the smallest webp above 800; else `orig` (always exists).
|
|
51
|
+
*
|
|
52
|
+
* @param existingVariants variant tags present in `media_variants` for the asset.
|
|
53
|
+
*/
|
|
54
|
+
export function pickAiImageVariant(existingVariants: readonly string[]): string {
|
|
55
|
+
const webps = existingVariants
|
|
56
|
+
.map((v) => ({ variant: v, width: parseVariantWidth(v) }))
|
|
57
|
+
.filter(
|
|
58
|
+
(v): v is { variant: string; width: number } =>
|
|
59
|
+
variantFamily(v.variant) === "webp" && v.width !== null,
|
|
60
|
+
);
|
|
61
|
+
const exact = webps.find((v) => v.width === 800);
|
|
62
|
+
if (exact) return exact.variant;
|
|
63
|
+
const below = webps.filter((v) => v.width < 800).sort((a, b) => b.width - a.width)[0];
|
|
64
|
+
if (below) return below.variant;
|
|
65
|
+
const above = webps.filter((v) => v.width > 800).sort((a, b) => a.width - b.width)[0];
|
|
66
|
+
if (above) return above.variant;
|
|
67
|
+
return "orig";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface EnrichResponsiveImagesOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Build the URL an enriched attribute should reference for
|
|
73
|
+
* (ref, variant, format), where `ref` is the asset SLUG (current form)
|
|
74
|
+
* or a legacy UUID id. Return null to leave `src` untouched (the preview
|
|
75
|
+
* keeps its admin-served URLs).
|
|
76
|
+
*/
|
|
77
|
+
readonly urlFor: (ref: string, variant: string, format: string) => string;
|
|
78
|
+
/** Rewrite the `src` attribute itself (production) or keep it (preview). */
|
|
79
|
+
readonly rewriteSrc: boolean;
|
|
80
|
+
/** Format lookup per (ref, variant); defaults to webp. */
|
|
81
|
+
readonly formatFor?: (ref: string, variant: string) => string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ENRICH_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Walk every `<img …>` whose src points at `/_caelo/media/<slug>` (or the
|
|
88
|
+
* legacy `/_caelo/media/<uuid>/<variant>`) and append the responsive
|
|
89
|
+
* attributes. `variantsByAsset` is keyed by the same `ref` the caller uses
|
|
90
|
+
* in `urlFor` (slug for current embeds, id for legacy). String-level
|
|
91
|
+
* rewrite; existing attributes always win.
|
|
92
|
+
*/
|
|
93
|
+
export function enrichResponsiveImages(
|
|
94
|
+
html: string,
|
|
95
|
+
variantsByAsset: ReadonlyMap<string, readonly ImageVariantInfo[]>,
|
|
96
|
+
options: EnrichResponsiveImagesOptions,
|
|
97
|
+
): string {
|
|
98
|
+
return html.replace(/<img\b[^>]*>/g, (tag) => {
|
|
99
|
+
const srcMatch = tag.match(
|
|
100
|
+
/\bsrc=("|')\/_caelo\/media\/([a-z0-9][a-z0-9-]{0,63})(?:\/([a-z][a-z0-9-]{0,63}))?\1/,
|
|
101
|
+
);
|
|
102
|
+
if (!srcMatch) return tag;
|
|
103
|
+
const seg1 = srcMatch[2] as string;
|
|
104
|
+
const seg2 = srcMatch[3];
|
|
105
|
+
// Legacy id form only when a full uuid is followed by an explicit
|
|
106
|
+
// variant; otherwise `ref` is the slug and orig is implied.
|
|
107
|
+
const isLegacyId = ENRICH_UUID_RE.test(seg1) && seg2 !== undefined;
|
|
108
|
+
const ref = seg1;
|
|
109
|
+
const variant = isLegacyId ? (seg2 as string) : (seg2 ?? "orig");
|
|
110
|
+
const assetId = ref;
|
|
111
|
+
const variants = variantsByAsset.get(ref) ?? [];
|
|
112
|
+
|
|
113
|
+
const family = variantFamily(variant);
|
|
114
|
+
const familyVariants = variants
|
|
115
|
+
.filter((v) => variantFamily(v.variant) === family && v.format === "webp")
|
|
116
|
+
.map((v) => ({ variant: v.variant, width: parseVariantWidth(v.variant) }))
|
|
117
|
+
.filter((v): v is { variant: string; width: number } => v.width !== null)
|
|
118
|
+
.sort((a, b) => a.width - b.width);
|
|
119
|
+
|
|
120
|
+
const requestedWidth = parseVariantWidth(variant);
|
|
121
|
+
const requestedFormat = options.formatFor?.(assetId, variant) ?? "webp";
|
|
122
|
+
|
|
123
|
+
let out = tag;
|
|
124
|
+
if (options.rewriteSrc) {
|
|
125
|
+
out = out.replace(srcMatch[0], `src="${options.urlFor(assetId, variant, requestedFormat)}"`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (familyVariants.length >= 2 && !/\bsrcset=/i.test(out)) {
|
|
129
|
+
const srcset = familyVariants
|
|
130
|
+
.map((v) => `${options.urlFor(assetId, v.variant, "webp")} ${v.width}w`)
|
|
131
|
+
.join(", ");
|
|
132
|
+
const sizes =
|
|
133
|
+
requestedWidth !== null
|
|
134
|
+
? `(max-width: 600px) 400px, (max-width: 1200px) 800px, ${requestedWidth}px`
|
|
135
|
+
: "100vw";
|
|
136
|
+
out = out.replace(
|
|
137
|
+
/<img\b/,
|
|
138
|
+
`<img srcset="${srcset}" sizes="${/\bsizes=/i.test(out) ? "" : sizes}"`,
|
|
139
|
+
);
|
|
140
|
+
out = out.replace(/\bsizes=""\s*/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!/\bloading=/i.test(out)) {
|
|
144
|
+
out = out.replace(/<img\b/, '<img loading="lazy"');
|
|
145
|
+
}
|
|
146
|
+
if (!/\bdecoding=/i.test(out)) {
|
|
147
|
+
out = out.replace(/<img\b/, '<img decoding="async"');
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
});
|
|
151
|
+
}
|