@caelo-cms/shared 0.10.22 → 0.10.24
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 +289 -273
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +342 -323
- 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 +35 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +40 -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-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/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 +102 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +145 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +29 -36
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +53 -128
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- 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 +85 -12
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +157 -67
- 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 +11 -16
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -23
- 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 -52
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +6 -68
- 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 +1105 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +42 -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-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -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 +158 -0
- package/src/i18n.test.ts +58 -0
- package/src/i18n.ts +91 -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 +637 -0
- package/src/preview-compose.ts +656 -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 +194 -0
- package/src/seo.ts +233 -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 +114 -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/version.ts +66 -0
- 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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #164 (CodeQL follow-up) — linear CSS gradient scanner.
|
|
5
|
+
*
|
|
6
|
+
* A `*-gradient\([^;{}]*\)` regex over whole stylesheets is the
|
|
7
|
+
* polynomial-ReDoS shape (#113): on adversarial input full of unclosed
|
|
8
|
+
* `conic-gradient(` starts, every attempt rescans to end-of-input.
|
|
9
|
+
* Gradient arguments legitimately contain nested parens (rgba() stops),
|
|
10
|
+
* so the class can't simply exclude `(`. This scanner walks the string
|
|
11
|
+
* once: find the next `-gradient(` head, then match parens with a
|
|
12
|
+
* depth counter, bounded by a hard argument-length cap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** No real gradient needs more; unclosed heads stop scanning here. */
|
|
16
|
+
const MAX_GRADIENT_LENGTH = 2_000;
|
|
17
|
+
|
|
18
|
+
const NEEDLE = "-gradient(";
|
|
19
|
+
const BASES = ["linear", "radial", "conic"] as const;
|
|
20
|
+
const REPEATING = "repeating-";
|
|
21
|
+
|
|
22
|
+
export interface CssGradientMatch {
|
|
23
|
+
readonly start: number;
|
|
24
|
+
readonly end: number;
|
|
25
|
+
readonly literal: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* All complete gradient literals, in document order. Linear: ONE
|
|
30
|
+
* indexOf needle per hit (each advancing the cursor), a bounded
|
|
31
|
+
* backwards check for the base keyword, and a capped paren walk.
|
|
32
|
+
*/
|
|
33
|
+
export function scanCssGradients(text: string): CssGradientMatch[] {
|
|
34
|
+
const lower = text.toLowerCase();
|
|
35
|
+
const out: CssGradientMatch[] = [];
|
|
36
|
+
let from = 0;
|
|
37
|
+
while (from < lower.length) {
|
|
38
|
+
const g = lower.indexOf(NEEDLE, from);
|
|
39
|
+
if (g === -1) break;
|
|
40
|
+
let headStart = -1;
|
|
41
|
+
for (const base of BASES) {
|
|
42
|
+
if (g >= base.length && lower.startsWith(base, g - base.length)) {
|
|
43
|
+
headStart = g - base.length;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (headStart === -1) {
|
|
48
|
+
from = g + NEEDLE.length;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
headStart >= REPEATING.length &&
|
|
53
|
+
lower.startsWith(REPEATING, headStart - REPEATING.length)
|
|
54
|
+
) {
|
|
55
|
+
headStart -= REPEATING.length;
|
|
56
|
+
}
|
|
57
|
+
let depth = 1;
|
|
58
|
+
let i = g + NEEDLE.length;
|
|
59
|
+
const limit = Math.min(lower.length, i + MAX_GRADIENT_LENGTH);
|
|
60
|
+
while (i < limit && depth > 0) {
|
|
61
|
+
const ch = lower.charCodeAt(i);
|
|
62
|
+
if (ch === 40 /* ( */) depth += 1;
|
|
63
|
+
else if (ch === 41 /* ) */) depth -= 1;
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
if (depth === 0) {
|
|
67
|
+
out.push({ start: headStart, end: i, literal: text.slice(headStart, i) });
|
|
68
|
+
from = i;
|
|
69
|
+
} else {
|
|
70
|
+
// Unclosed (or over-long) head — advance past it, never rescan.
|
|
71
|
+
from = g + NEEDLE.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Replace each complete gradient via `cb` (return the literal to keep). */
|
|
78
|
+
export function replaceCssGradients(text: string, cb: (literal: string) => string): string {
|
|
79
|
+
const matches = scanCssGradients(text);
|
|
80
|
+
if (matches.length === 0) return text;
|
|
81
|
+
let out = "";
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
for (const m of matches) {
|
|
84
|
+
out += text.slice(cursor, m.start) + cb(m.literal);
|
|
85
|
+
cursor = m.end;
|
|
86
|
+
}
|
|
87
|
+
return out + text.slice(cursor);
|
|
88
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #156 — scanner unit coverage: reference/definition extraction
|
|
5
|
+
* (nested fallbacks, comments-in-values, strings), locally-defined
|
|
6
|
+
* custom properties, did-you-mean suggestions, warning formatting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
extractCssVarDefinitions,
|
|
12
|
+
extractCssVarReferences,
|
|
13
|
+
formatUnknownCssVarWarning,
|
|
14
|
+
scanCssVars,
|
|
15
|
+
unknownCssVarMarker,
|
|
16
|
+
} from "./css-var-scan.js";
|
|
17
|
+
|
|
18
|
+
const KNOWN = ["--color-primary", "--color-foreground", "--color-background", "--spacing-md"];
|
|
19
|
+
|
|
20
|
+
describe("extractCssVarReferences", () => {
|
|
21
|
+
it("finds plain and fallback-carrying references, deduped", () => {
|
|
22
|
+
const refs = extractCssVarReferences(
|
|
23
|
+
".a{color:var(--color-primary)}.b{color:var(--color-primary);background:var( --color-bg , #fff )}",
|
|
24
|
+
);
|
|
25
|
+
expect(refs).toEqual([
|
|
26
|
+
{ name: "--color-primary", hasFallback: false },
|
|
27
|
+
{ name: "--color-bg", hasFallback: true },
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles nested var() fallbacks", () => {
|
|
32
|
+
const refs = extractCssVarReferences("p{color:var(--a, var(--b, red))}");
|
|
33
|
+
expect(refs.map((r) => r.name).sort()).toEqual(["--a", "--b"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("extractCssVarDefinitions", () => {
|
|
38
|
+
it("collects custom-property declarations, not references", () => {
|
|
39
|
+
const defs = extractCssVarDefinitions(
|
|
40
|
+
":root{--site-gutter:2rem}.x{--local: 1px; color:var(--other)}",
|
|
41
|
+
);
|
|
42
|
+
expect([...defs].sort()).toEqual(["--local", "--site-gutter"]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("scanCssVars", () => {
|
|
47
|
+
it("reports unknown refs with a did-you-mean suggestion", () => {
|
|
48
|
+
const unknown = scanCssVars({ css: "h1{color:var(--color-foregruond)}", knownVars: KNOWN });
|
|
49
|
+
expect(unknown).toHaveLength(1);
|
|
50
|
+
expect(unknown[0]?.name).toBe("--color-foregruond");
|
|
51
|
+
expect(unknown[0]?.suggestion).toBe("--color-foreground");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats locally-defined custom properties as known", () => {
|
|
55
|
+
const unknown = scanCssVars({
|
|
56
|
+
css: ".hero{--hero-angle:12deg; transform:rotate(var(--hero-angle))}",
|
|
57
|
+
knownVars: KNOWN,
|
|
58
|
+
});
|
|
59
|
+
expect(unknown).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("suppresses far-fetched suggestions", () => {
|
|
63
|
+
const unknown = scanCssVars({ css: "p{width:var(--zzz-totally-unrelated)}", knownVars: KNOWN });
|
|
64
|
+
expect(unknown[0]?.suggestion).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is silent on clean css", () => {
|
|
68
|
+
expect(
|
|
69
|
+
scanCssVars({
|
|
70
|
+
css: "p{color:var(--color-primary);padding:var(--spacing-md)}",
|
|
71
|
+
knownVars: KNOWN,
|
|
72
|
+
}),
|
|
73
|
+
).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("formatUnknownCssVarWarning / marker", () => {
|
|
78
|
+
it("formats an AI-actionable one-liner and distinguishes fallback vs invalid", () => {
|
|
79
|
+
const msg = formatUnknownCssVarWarning([
|
|
80
|
+
{ name: "--color-bg", hasFallback: true, suggestion: "--color-background" },
|
|
81
|
+
{ name: "--nope", hasFallback: false, suggestion: null },
|
|
82
|
+
]);
|
|
83
|
+
expect(msg).toContain("--color-bg");
|
|
84
|
+
expect(msg).toContain("did you mean `--color-background`?");
|
|
85
|
+
expect(msg).toContain("hardcoded fallback");
|
|
86
|
+
expect(msg).toContain("won't apply");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null when nothing is unknown (no noise)", () => {
|
|
90
|
+
expect(formatUnknownCssVarWarning([])).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("marker follows the missing-content convention", () => {
|
|
94
|
+
expect(unknownCssVarMarker("--color-bg")).toBe("unknown-css-var:--color-bg");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #156 — CSS custom-property reference scanner.
|
|
5
|
+
*
|
|
6
|
+
* The silent-fallback trap: module/template/layout CSS referencing a
|
|
7
|
+
* `var(--…)` the active theme never emits renders the hardcoded
|
|
8
|
+
* fallback (or nothing), so the page quietly diverges from the theme —
|
|
9
|
+
* the monochrome regression class documented in theme-render.ts. The
|
|
10
|
+
* prompt-level var inventory guards the happy path; this scanner is the
|
|
11
|
+
* mechanical write-time guard (CLAUDE.md §2: misses must be loud).
|
|
12
|
+
*
|
|
13
|
+
* Pure string analysis — callers (authoring tools, preview op) supply
|
|
14
|
+
* the known-var set from `listThemeCssVarNames(activeTheme.tokens)`.
|
|
15
|
+
* Custom properties DEFINED anywhere in the scanned bundle are treated
|
|
16
|
+
* as known: a layout may define `--site-gutter` for its modules, and
|
|
17
|
+
* that is legitimate authoring, not drift.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One `var(--x)` reference found in CSS. */
|
|
21
|
+
export interface CssVarReference {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** True when the reference carries a literal fallback (`var(--x, #fff)`). */
|
|
24
|
+
readonly hasFallback: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VAR_REFERENCE_RE = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g;
|
|
28
|
+
const VAR_DEFINITION_RE = /(?:^|[{;\s])(--[a-zA-Z0-9_-]+)\s*:/g;
|
|
29
|
+
|
|
30
|
+
/** Extract every `var(--x[, fallback])` reference (deduped by name+fallback). */
|
|
31
|
+
export function extractCssVarReferences(css: string): CssVarReference[] {
|
|
32
|
+
const seen = new Map<string, CssVarReference>();
|
|
33
|
+
for (const m of css.matchAll(VAR_REFERENCE_RE)) {
|
|
34
|
+
const name = m[1] ?? "";
|
|
35
|
+
const hasFallback = m[2] === ",";
|
|
36
|
+
const key = `${name}|${hasFallback}`;
|
|
37
|
+
if (!seen.has(key)) seen.set(key, { name, hasFallback });
|
|
38
|
+
}
|
|
39
|
+
return [...seen.values()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Custom properties DEFINED in the CSS (`--x: value;`). */
|
|
43
|
+
export function extractCssVarDefinitions(css: string): Set<string> {
|
|
44
|
+
const out = new Set<string>();
|
|
45
|
+
for (const m of css.matchAll(VAR_DEFINITION_RE)) {
|
|
46
|
+
out.add(m[1] ?? "");
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Levenshtein distance, small-string use only (var names). */
|
|
52
|
+
function editDistance(a: string, b: string): number {
|
|
53
|
+
const prev = new Array<number>(b.length + 1);
|
|
54
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
55
|
+
for (let i = 1; i <= a.length; i++) {
|
|
56
|
+
let diag = prev[0] as number;
|
|
57
|
+
prev[0] = i;
|
|
58
|
+
for (let j = 1; j <= b.length; j++) {
|
|
59
|
+
const tmp = prev[j] as number;
|
|
60
|
+
prev[j] = Math.min(
|
|
61
|
+
(prev[j] as number) + 1,
|
|
62
|
+
(prev[j - 1] as number) + 1,
|
|
63
|
+
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
64
|
+
);
|
|
65
|
+
diag = tmp;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return prev[b.length] as number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An unknown reference + the closest known name worth suggesting. */
|
|
72
|
+
export interface UnknownCssVar {
|
|
73
|
+
readonly name: string;
|
|
74
|
+
readonly hasFallback: boolean;
|
|
75
|
+
/** Closest known var within edit-distance 40% of length, or null. */
|
|
76
|
+
readonly suggestion: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CssVarScanInput {
|
|
80
|
+
/** The CSS bundle to scan (concatenate layout+template+module parts). */
|
|
81
|
+
readonly css: string;
|
|
82
|
+
/** Vars the active theme emits (`listThemeCssVarNames`). */
|
|
83
|
+
readonly knownVars: readonly string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Report `var()` references that neither the theme emits nor the
|
|
88
|
+
* bundle itself defines, with a nearest-match suggestion so the fix is
|
|
89
|
+
* one edit, not a research task (CLAUDE.md §11: AI-actionable).
|
|
90
|
+
*/
|
|
91
|
+
export function scanCssVars(input: CssVarScanInput): UnknownCssVar[] {
|
|
92
|
+
const known = new Set(input.knownVars);
|
|
93
|
+
for (const def of extractCssVarDefinitions(input.css)) known.add(def);
|
|
94
|
+
|
|
95
|
+
const unknown: UnknownCssVar[] = [];
|
|
96
|
+
const reported = new Set<string>();
|
|
97
|
+
for (const ref of extractCssVarReferences(input.css)) {
|
|
98
|
+
if (known.has(ref.name) || reported.has(ref.name)) continue;
|
|
99
|
+
reported.add(ref.name);
|
|
100
|
+
let best: string | null = null;
|
|
101
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
102
|
+
for (const candidate of known) {
|
|
103
|
+
const d = editDistance(ref.name, candidate);
|
|
104
|
+
if (d < bestDist) {
|
|
105
|
+
bestDist = d;
|
|
106
|
+
best = candidate;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const threshold = Math.max(2, Math.floor(ref.name.length * 0.4));
|
|
110
|
+
unknown.push({
|
|
111
|
+
name: ref.name,
|
|
112
|
+
hasFallback: ref.hasFallback,
|
|
113
|
+
suggestion: best !== null && bestDist <= threshold ? best : null,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Marker for the preview op's missing-content surface — same
|
|
121
|
+
* convention as `theme-asset-unbound:<slot>` / `theme-font-unresolvable`.
|
|
122
|
+
*/
|
|
123
|
+
export function unknownCssVarMarker(name: string): string {
|
|
124
|
+
return `unknown-css-var:${name}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One-line, AI-actionable warning for authoring-tool results. Returns
|
|
129
|
+
* null when nothing is unknown (no noise on clean writes).
|
|
130
|
+
*/
|
|
131
|
+
export function formatUnknownCssVarWarning(unknown: readonly UnknownCssVar[]): string | null {
|
|
132
|
+
if (unknown.length === 0) return null;
|
|
133
|
+
const parts = unknown.map((u) => {
|
|
134
|
+
const hint = u.suggestion !== null ? ` (did you mean \`${u.suggestion}\`?)` : "";
|
|
135
|
+
const fallback = u.hasFallback
|
|
136
|
+
? " — its hardcoded fallback is what visitors will see, detached from the theme"
|
|
137
|
+
: " — the declaration is invalid and the property won't apply";
|
|
138
|
+
return `\`${u.name}\`${hint}${fallback}`;
|
|
139
|
+
});
|
|
140
|
+
return (
|
|
141
|
+
`⚠️ Unknown CSS vars (the active theme does not emit them): ${parts.join("; ")}. ` +
|
|
142
|
+
"Use the exact names from the `## Theme` inventory, or define the custom property in this CSS if it's intentional."
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.12.3 (issue #106) — unit guards for the module type/slug helpers.
|
|
5
|
+
* The load-bearing invariant: a module's stable `type` is the slug base
|
|
6
|
+
* WITHOUT the uniqueness suffix, so `type` is always a prefix of `slug`
|
|
7
|
+
* and the two can never drift (which is what the allowedModuleTypes match
|
|
8
|
+
* relies on).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { deriveModuleType, slugifyModuleName, slugifyModuleSection } from "./content.js";
|
|
13
|
+
|
|
14
|
+
describe("deriveModuleType", () => {
|
|
15
|
+
it("lowercases + hyphenates and drops no suffix", () => {
|
|
16
|
+
expect(deriveModuleType("Primary Button")).toBe("primary-button");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("collapses runs of non-alphanumerics to a single hyphen, trimmed", () => {
|
|
20
|
+
expect(deriveModuleType("CTA!! banner $$$")).toBe("cta-banner");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to 'module' for empty / all-punctuation names", () => {
|
|
24
|
+
expect(deriveModuleType("!!!")).toBe("module");
|
|
25
|
+
expect(deriveModuleType("")).toBe("module");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("caps the base at 40 chars", () => {
|
|
29
|
+
expect(deriveModuleType("a".repeat(80)).length).toBe(40);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("produces NO -<timestamp> suffix (contrast with slugifyModuleName)", () => {
|
|
33
|
+
const t = deriveModuleType("Button");
|
|
34
|
+
expect(t).toBe("button");
|
|
35
|
+
expect(t).not.toMatch(/-[a-z0-9]+$/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Copilot review (#106 PR): the 40-char cap used to run AFTER the edge-hyphen
|
|
39
|
+
// trim, so a name whose collapsed hyphen sits at the boundary truncated into a
|
|
40
|
+
// trailing hyphen — which would double-hyphenate `slug = type + "-" + suffix`.
|
|
41
|
+
it("never returns a trailing hyphen when the cap lands on one", () => {
|
|
42
|
+
const name = `${"a".repeat(39)} tail`; // collapses to 39 a's + "-tail"
|
|
43
|
+
const t = deriveModuleType(name);
|
|
44
|
+
expect(t).not.toMatch(/-$/);
|
|
45
|
+
expect(t).toBe("a".repeat(39));
|
|
46
|
+
// the composed slug stays single-hyphenated
|
|
47
|
+
expect(slugifyModuleName(name, "s")).toBe(`${"a".repeat(39)}-s`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("never returns a leading hyphen", () => {
|
|
51
|
+
expect(deriveModuleType(" !!leading")).toBe("leading");
|
|
52
|
+
expect(deriveModuleType("!!leading")).not.toMatch(/^-/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("slugifyModuleName", () => {
|
|
57
|
+
it("composes as deriveModuleType + '-' + suffix (type is a slug prefix)", () => {
|
|
58
|
+
expect(slugifyModuleName("Primary Button", "abc123")).toBe("primary-button-abc123");
|
|
59
|
+
const name = "Hero Banner";
|
|
60
|
+
expect(slugifyModuleName(name, "x").startsWith(`${deriveModuleType(name)}-`)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("uses a base36 timestamp suffix when none is given", () => {
|
|
64
|
+
expect(slugifyModuleName("Button")).toMatch(/^button-[a-z0-9]+$/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("slugifyModuleSection", () => {
|
|
69
|
+
it("inserts the section index between the type base and the suffix", () => {
|
|
70
|
+
expect(slugifyModuleSection("Hero", 2, "zzz")).toBe("hero-2-zzz");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps the type base as a prefix so same-named sections don't collide", () => {
|
|
74
|
+
const a = slugifyModuleSection("Card", 0, "s");
|
|
75
|
+
const b = slugifyModuleSection("Card", 1, "s");
|
|
76
|
+
expect(a).not.toBe(b);
|
|
77
|
+
expect(a.startsWith("card-")).toBe(true);
|
|
78
|
+
expect(b.startsWith("card-")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #375 — theme-shell composition for growth-time design drafts:
|
|
5
|
+
* head order (fonts → theme vars → base), theme-asset placeholder
|
|
6
|
+
* resolution incl. the loud unbound path, and the no-theme degradation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { composeDesignDraftShell } from "./design-draft-shell.js";
|
|
11
|
+
import type { ComposeTheme } from "./preview-compose.js";
|
|
12
|
+
import type { ThemeDocument } from "./themes.js";
|
|
13
|
+
|
|
14
|
+
const TOKENS = {
|
|
15
|
+
color: {
|
|
16
|
+
background: { $type: "color", $value: "#ffffff" },
|
|
17
|
+
primary: { $type: "color", $value: "#4f46e5" },
|
|
18
|
+
},
|
|
19
|
+
} as ThemeDocument;
|
|
20
|
+
|
|
21
|
+
const THEME: ComposeTheme = {
|
|
22
|
+
tokens: TOKENS,
|
|
23
|
+
assets: {
|
|
24
|
+
logo: { mediaId: "11111111-1111-4111-8111-111111111111", url: "/_caelo/media/logo/orig" },
|
|
25
|
+
logoDark: null,
|
|
26
|
+
favicon: null,
|
|
27
|
+
socialShare: null,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const FRAGMENT = `<section class="hero"><h1>Real headline</h1><style>.hero{color:var(--color-primary)}</style></section>`;
|
|
32
|
+
|
|
33
|
+
describe("composeDesignDraftShell (issue #375)", () => {
|
|
34
|
+
it("wraps the fragment in fonts → theme vars → base head order", () => {
|
|
35
|
+
const out = composeDesignDraftShell({
|
|
36
|
+
fragmentHtml: FRAGMENT,
|
|
37
|
+
theme: THEME,
|
|
38
|
+
fonts: { css: "@font-face{font-family:Inter}", preloads: ["/_caelo/fonts/inter/aa.woff2"] },
|
|
39
|
+
title: "Draft — bold",
|
|
40
|
+
});
|
|
41
|
+
expect(out.html).toStartWith("<!doctype html>");
|
|
42
|
+
expect(out.html).toContain('<style data-source="fonts">@font-face{font-family:Inter}');
|
|
43
|
+
expect(out.html).toContain('rel="preload" as="font"');
|
|
44
|
+
expect(out.html).toContain('<style data-source="theme">');
|
|
45
|
+
expect(out.html).toContain("--color-primary:#4f46e5");
|
|
46
|
+
expect(out.html).toContain('<style data-source="base">');
|
|
47
|
+
expect(out.html).toContain(FRAGMENT);
|
|
48
|
+
expect(out.missingSlots).toEqual([]);
|
|
49
|
+
const fontsAt = out.html.indexOf('data-source="fonts"');
|
|
50
|
+
const themeAt = out.html.indexOf('data-source="theme"');
|
|
51
|
+
const baseAt = out.html.indexOf('data-source="base"');
|
|
52
|
+
expect(fontsAt).toBeLessThan(themeAt);
|
|
53
|
+
expect(themeAt).toBeLessThan(baseAt);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("substitutes bound theme-asset placeholders and flags unbound ones loudly", () => {
|
|
57
|
+
const out = composeDesignDraftShell({
|
|
58
|
+
fragmentHtml: `<img src="{{theme_logo_url}}"><img src="{{theme_favicon_url}}">`,
|
|
59
|
+
theme: THEME,
|
|
60
|
+
title: "assets",
|
|
61
|
+
});
|
|
62
|
+
expect(out.html).toContain('src="/_caelo/media/logo/orig"');
|
|
63
|
+
// Unbound slot: placeholder survives raw IN PLACE (§2 no-fallbacks;
|
|
64
|
+
// the attribute value must not be corrupted by an inline marker) …
|
|
65
|
+
expect(out.html).toContain('src="{{theme_favicon_url}}"');
|
|
66
|
+
// … and the marker rides as a real comment node at the body's end.
|
|
67
|
+
expect(out.html).toContain("<!-- caelo:missing reason=theme-asset-unbound:favicon --></body>");
|
|
68
|
+
expect(out.missingSlots).toEqual(["theme-asset-unbound:favicon"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders without theme vars when no theme exists (visibly broken on purpose)", () => {
|
|
72
|
+
const out = composeDesignDraftShell({ fragmentHtml: FRAGMENT, title: "no theme" });
|
|
73
|
+
expect(out.html).not.toContain('data-source="theme"');
|
|
74
|
+
expect(out.html).not.toContain('data-source="fonts"');
|
|
75
|
+
expect(out.html).toContain('data-source="base"');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("escapes the document title", () => {
|
|
79
|
+
const out = composeDesignDraftShell({
|
|
80
|
+
fragmentHtml: FRAGMENT,
|
|
81
|
+
title: `<script>"x"</script>`,
|
|
82
|
+
});
|
|
83
|
+
expect(out.html).toContain("<title><script>"x"</script></title>");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #375 — theme shell for growth-time design-draft previews.
|
|
5
|
+
*
|
|
6
|
+
* Page/module-scope drafts are stored as FRAGMENTS bound to the site's
|
|
7
|
+
* `var(--…)` tokens; this composer wraps one in the site's real
|
|
8
|
+
* compiled theme at view time: web fonts (#150), theme CSS variables,
|
|
9
|
+
* and the invisible technical baseline (#151) — in the exact head
|
|
10
|
+
* order `composePagePreview` uses, so a draft previews in the same
|
|
11
|
+
* cascade it will materialise into. Pure / sync / no IO: the caller
|
|
12
|
+
* (the `genesis.render_draft` op) loads the theme row and resolves
|
|
13
|
+
* fonts, mirroring `pages.render_preview`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
17
|
+
import { type ComposeFonts, type ComposeTheme, fontsHeadFragment } from "./preview-compose.js";
|
|
18
|
+
import { caeloMissingComment } from "./template-engine.js";
|
|
19
|
+
import { renderThemeCss } from "./theme-render.js";
|
|
20
|
+
|
|
21
|
+
/** Same four placeholders the template engine resolves (v0.11.1, #76). */
|
|
22
|
+
const THEME_ASSET_KEY_TO_SLOT = {
|
|
23
|
+
theme_logo_url: "logo",
|
|
24
|
+
theme_logo_dark_url: "logoDark",
|
|
25
|
+
theme_favicon_url: "favicon",
|
|
26
|
+
theme_social_share_url: "socialShare",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export interface DesignDraftShellInput {
|
|
30
|
+
/** The stored fragment (already script-stripped at the boundary). */
|
|
31
|
+
readonly fragmentHtml: string;
|
|
32
|
+
/** Active theme; undefined renders the shell without theme vars
|
|
33
|
+
* (loud: the draft's `var(--…)` references fall to browser initial
|
|
34
|
+
* values — visibly broken, per CLAUDE.md §2 no-fallbacks). */
|
|
35
|
+
readonly theme?: ComposeTheme;
|
|
36
|
+
/** Resolved web fonts; undefined = system stacks only. */
|
|
37
|
+
readonly fonts?: ComposeFonts;
|
|
38
|
+
/** Document title shown in the iframe (accessibility). */
|
|
39
|
+
readonly title: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DesignDraftShellOutput {
|
|
43
|
+
readonly html: string;
|
|
44
|
+
/** `theme-asset-unbound:<slot>` markers, mirroring the template
|
|
45
|
+
* engine's failure-marker contract. */
|
|
46
|
+
readonly missingSlots: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve `{{theme_*_url}}` placeholders in a draft fragment. Bound
|
|
51
|
+
* slots substitute their URL; a placeholder on an unbound slot stays
|
|
52
|
+
* loud-raw IN PLACE (placeholders usually sit inside attribute values,
|
|
53
|
+
* where an appended comment would corrupt the URL string — PR-378
|
|
54
|
+
* review) and lands in `missingSlots`; the caller appends the real
|
|
55
|
+
* `caelo:missing` comment nodes at the end of the body so the marker
|
|
56
|
+
* tooling still sees them. Other `{{…}}` sequences are left untouched:
|
|
57
|
+
* a draft is not a fielded module.
|
|
58
|
+
*/
|
|
59
|
+
function substituteThemeAssetUrls(
|
|
60
|
+
html: string,
|
|
61
|
+
theme: ComposeTheme | undefined,
|
|
62
|
+
missing: string[],
|
|
63
|
+
): string {
|
|
64
|
+
return html.replace(/\{\{\s*(theme_[a-z_]+)\s*\}\}/g, (match, key: string) => {
|
|
65
|
+
const slot = THEME_ASSET_KEY_TO_SLOT[key as keyof typeof THEME_ASSET_KEY_TO_SLOT];
|
|
66
|
+
if (slot === undefined) return match;
|
|
67
|
+
const url = theme?.assets[slot]?.url;
|
|
68
|
+
if (url === undefined) {
|
|
69
|
+
missing.push(`theme-asset-unbound:${slot}`);
|
|
70
|
+
return match;
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function composeDesignDraftShell(input: DesignDraftShellInput): DesignDraftShellOutput {
|
|
77
|
+
const missing: string[] = [];
|
|
78
|
+
const substituted = substituteThemeAssetUrls(input.fragmentHtml, input.theme, missing);
|
|
79
|
+
// Real comment NODES, after the fragment — never inside an attribute.
|
|
80
|
+
const markers = [...new Set(missing)].map((m) => caeloMissingComment(m)).join("");
|
|
81
|
+
const body = substituted + markers;
|
|
82
|
+
|
|
83
|
+
// Head order mirrors composePagePreview: fonts first (URL discovery),
|
|
84
|
+
// then theme vars (so fragment CSS can `var(--color-primary)`), then
|
|
85
|
+
// the technical baseline.
|
|
86
|
+
const head: string[] = [
|
|
87
|
+
`<meta charset="utf-8">`,
|
|
88
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
89
|
+
`<title>${escapeHtml(input.title)}</title>`,
|
|
90
|
+
];
|
|
91
|
+
const fonts = fontsHeadFragment(input.fonts);
|
|
92
|
+
if (fonts !== null) head.push(fonts);
|
|
93
|
+
const themeCss = input.theme !== undefined ? renderThemeCss(input.theme.tokens) : null;
|
|
94
|
+
if (themeCss !== null) head.push(`<style data-source="theme">${themeCss}</style>`);
|
|
95
|
+
head.push(`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
html: `<!doctype html>\n<html><head>${head.join("")}</head><body>${body}</body></html>`,
|
|
99
|
+
missingSlots: missing,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function escapeHtml(s: string): string {
|
|
104
|
+
return s
|
|
105
|
+
.replaceAll("&", "&")
|
|
106
|
+
.replaceAll("<", "<")
|
|
107
|
+
.replaceAll(">", ">")
|
|
108
|
+
.replaceAll('"', """);
|
|
109
|
+
}
|