@caelo-cms/shared 0.10.51 → 0.10.53
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 +9 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +6 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/font-axis-policy.d.ts +174 -0
- package/dist/font-axis-policy.d.ts.map +1 -0
- package/dist/font-axis-policy.js +282 -0
- package/dist/font-axis-policy.js.map +1 -0
- package/dist/font-usage.d.ts +23 -0
- package/dist/font-usage.d.ts.map +1 -0
- package/dist/font-usage.js +381 -0
- package/dist/font-usage.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/markup-structure.d.ts +79 -0
- package/dist/markup-structure.d.ts.map +1 -0
- package/dist/markup-structure.js +274 -0
- package/dist/markup-structure.js.map +1 -0
- package/dist/media.d.ts +25 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +35 -0
- package/dist/media.js.map +1 -1
- package/dist/preview-compose.d.ts +4 -3
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +2 -1
- package/dist/preview-compose.js.map +1 -1
- package/dist/publish-live-parity.d.ts +18 -0
- package/dist/publish-live-parity.d.ts.map +1 -0
- package/dist/publish-live-parity.js +21 -0
- package/dist/publish-live-parity.js.map +1 -0
- package/dist/static-cache-policy.d.ts +21 -7
- package/dist/static-cache-policy.d.ts.map +1 -1
- package/dist/static-cache-policy.js +24 -7
- package/dist/static-cache-policy.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +6 -1
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +8 -0
- package/src/font-axis-policy.test.ts +182 -0
- package/src/font-axis-policy.ts +372 -0
- package/src/font-usage.test.ts +100 -0
- package/src/font-usage.ts +395 -0
- package/src/index.ts +4 -0
- package/src/markup-structure.test.ts +184 -0
- package/src/markup-structure.ts +304 -0
- package/src/media.ts +43 -0
- package/src/preview-compose.ts +6 -4
- package/src/publish-live-parity.ts +25 -0
- package/src/static-cache-policy.test.ts +33 -3
- package/src/static-cache-policy.ts +28 -8
- package/src/themes.ts +6 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which font weights a site can render — the input to the default `wght`
|
|
5
|
+
* clamp of pinned faces (see `font-axis-policy.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Clamping `wght` is only safe when the result is a superset of every
|
|
8
|
+
* computed `font-weight` any element can end up with. A weight outside
|
|
9
|
+
* the delivered range does not fail visibly: the browser silently
|
|
10
|
+
* renders the nearest end of the range (800 → 700). So this module is
|
|
11
|
+
* deliberately conservative, and when it cannot be CERTAIN it reports
|
|
12
|
+
* `weights: null`, which keeps the full axis. Full is the safe outcome,
|
|
13
|
+
* not a degraded one — it is exactly what shipped before clamping.
|
|
14
|
+
*
|
|
15
|
+
* How the set is built:
|
|
16
|
+
*
|
|
17
|
+
* - It is site-wide, not per family. `font-weight` inherits separately
|
|
18
|
+
* from `font-family`, so a weight declared under another family still
|
|
19
|
+
* reaches a pinned face through inheritance. Treating every declared
|
|
20
|
+
* weight as applicable to every family over-approximates; it never
|
|
21
|
+
* under-approximates.
|
|
22
|
+
* - 400 (the initial value) and 700 (UA `bold` on h1-h6, th, ...) are
|
|
23
|
+
* always in. UA `b, strong { font-weight: bolder }` applies wherever
|
|
24
|
+
* rich text puts a `<strong>`, so the set is always closed under
|
|
25
|
+
* `bolder` (400 → 700 → 900); under `lighter` too when any source uses
|
|
26
|
+
* it. CSS Fonts 4 maps both deterministically from the parent weight,
|
|
27
|
+
* and every parent weight is itself in the set, so the closure is
|
|
28
|
+
* complete rather than a guess.
|
|
29
|
+
* - Every `font-weight` declaration (CSS, `style=""`, JSON-escaped
|
|
30
|
+
* content, script text), every `font:` shorthand and every camel-case
|
|
31
|
+
* `fontWeight` assignment is parsed. `var(--x)` is resolved against
|
|
32
|
+
* every declaration of `--x` in any source plus its fallback; a
|
|
33
|
+
* custom property that is also touched any other way (`setProperty`,
|
|
34
|
+
* `@property`) is not knowable and makes the result uncertain.
|
|
35
|
+
* - Anything else that could set a weight — `calc()`, a mustache
|
|
36
|
+
* placeholder, a system-font `font:` keyword, a `font-weight` mention
|
|
37
|
+
* that is not a plain declaration — makes the result uncertain.
|
|
38
|
+
* - `font-variation-settings` addresses axes directly; the axes it names
|
|
39
|
+
* are reported so the default keeps them full (unparsable → `"all"`).
|
|
40
|
+
*
|
|
41
|
+
* The caller passes every text a page can be built from (modules incl.
|
|
42
|
+
* their JS and field defaults, templates, layouts, content values, plugin
|
|
43
|
+
* source) plus the theme, so the editor preview and the deployed site
|
|
44
|
+
* derive the same set from the same data.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
48
|
+
import type { WeightUsage } from "./font-axis-policy.js";
|
|
49
|
+
import { renderThemeCss } from "./theme-render.js";
|
|
50
|
+
import type { ThemeDocument } from "./themes.js";
|
|
51
|
+
|
|
52
|
+
/** One text the site is built from; `label` names it in uncertainty reasons. */
|
|
53
|
+
export interface FontUsageSource {
|
|
54
|
+
readonly label: string;
|
|
55
|
+
readonly text: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of {@link scanSiteFontUsage}. */
|
|
59
|
+
export interface SiteFontUsage extends WeightUsage {
|
|
60
|
+
/** Why `weights` is null (first few causes, `label: excerpt`); empty when certain. */
|
|
61
|
+
readonly uncertain: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MAX_REASONS = 5;
|
|
65
|
+
const MAX_VAR_DEPTH = 8;
|
|
66
|
+
const MAX_EXPANSIONS = 256;
|
|
67
|
+
|
|
68
|
+
/** Characters that end a declaration value in CSS, attributes, JSON or JS strings. */
|
|
69
|
+
const VALUE_END = /[;{}"'`<>\n\\!]/;
|
|
70
|
+
|
|
71
|
+
/** CSS Fonts 4 §2.2 relative weights. */
|
|
72
|
+
function bolder(w: number): number {
|
|
73
|
+
return w < 350 ? 400 : w < 550 ? 700 : w < 900 ? 900 : w;
|
|
74
|
+
}
|
|
75
|
+
function lighter(w: number): number {
|
|
76
|
+
return w < 100 ? w : w < 550 ? 100 : w < 750 ? 400 : 700;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const GLOBAL_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
80
|
+
const SYSTEM_FONTS = new Set([
|
|
81
|
+
"caption",
|
|
82
|
+
"icon",
|
|
83
|
+
"menu",
|
|
84
|
+
"message-box",
|
|
85
|
+
"small-caption",
|
|
86
|
+
"status-bar",
|
|
87
|
+
]);
|
|
88
|
+
const SIZE_KEYWORDS = new Set([
|
|
89
|
+
"xx-small",
|
|
90
|
+
"x-small",
|
|
91
|
+
"small",
|
|
92
|
+
"medium",
|
|
93
|
+
"large",
|
|
94
|
+
"x-large",
|
|
95
|
+
"xx-large",
|
|
96
|
+
"xxx-large",
|
|
97
|
+
"larger",
|
|
98
|
+
"smaller",
|
|
99
|
+
]);
|
|
100
|
+
const SIZE_TOKEN = /^(?:\d*\.?\d+(?:[a-z]+|%)|0)(?:\/.*)?$|^(?:calc|clamp|min|max)\(/;
|
|
101
|
+
|
|
102
|
+
interface Scan {
|
|
103
|
+
readonly weights: Set<number>;
|
|
104
|
+
useLighter: boolean;
|
|
105
|
+
readonly reasons: string[];
|
|
106
|
+
axes: Set<string> | "all";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function excerpt(text: string, at: number): string {
|
|
110
|
+
return text
|
|
111
|
+
.slice(Math.max(0, at), at + 60)
|
|
112
|
+
.replace(/\s+/g, " ")
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function uncertain(scan: Scan, label: string, why: string): void {
|
|
117
|
+
if (scan.reasons.length < MAX_REASONS) scan.reasons.push(`${label}: ${why}`);
|
|
118
|
+
else if (scan.reasons.length === MAX_REASONS) scan.reasons.push("…");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function valueAfterColon(text: string, from: number): string | null {
|
|
122
|
+
const m = /^\s*:/.exec(text.slice(from, from + 64));
|
|
123
|
+
if (m === null) return null;
|
|
124
|
+
const start = from + m[0].length;
|
|
125
|
+
const rest = text.slice(start, start + 400);
|
|
126
|
+
const end = rest.search(VALUE_END);
|
|
127
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Index of the `)` closing the `(` at `open`, or -1. */
|
|
131
|
+
function closingParen(value: string, open: number): number {
|
|
132
|
+
let depth = 0;
|
|
133
|
+
for (let i = open; i < value.length; i++) {
|
|
134
|
+
if (value[i] === "(") depth++;
|
|
135
|
+
else if (value[i] === ")" && --depth === 0) return i;
|
|
136
|
+
}
|
|
137
|
+
return -1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Custom-property knowledge collected from every source: all declared
|
|
142
|
+
* values per name, and names that are also written in a way we cannot
|
|
143
|
+
* follow (script, `@property`).
|
|
144
|
+
*/
|
|
145
|
+
interface CustomProps {
|
|
146
|
+
readonly values: Map<string, string[]>;
|
|
147
|
+
readonly opaque: Set<string>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectCustomProps(sources: readonly FontUsageSource[]): CustomProps {
|
|
151
|
+
const values = new Map<string, string[]>();
|
|
152
|
+
const opaque = new Set<string>();
|
|
153
|
+
const name = /(?<![\w-])(--[A-Za-z0-9_-]+)/g;
|
|
154
|
+
for (const { text } of sources) {
|
|
155
|
+
for (const m of text.matchAll(name)) {
|
|
156
|
+
const prop = m[1] as string;
|
|
157
|
+
const at = (m.index ?? 0) + prop.length;
|
|
158
|
+
const value = valueAfterColon(text, at);
|
|
159
|
+
if (value !== null) {
|
|
160
|
+
const list = values.get(prop) ?? [];
|
|
161
|
+
list.push(value);
|
|
162
|
+
values.set(prop, list);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const before = text.slice(Math.max(0, (m.index ?? 0) - 8), m.index ?? 0);
|
|
166
|
+
if (!/var\(\s*$/.test(before)) opaque.add(prop);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { values, opaque };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every value `value` can take once its `var()` references are
|
|
174
|
+
* substituted. Returns a reason string when that is not knowable.
|
|
175
|
+
*/
|
|
176
|
+
function expandVars(value: string, props: CustomProps, depth = 0): string[] | string {
|
|
177
|
+
const at = value.indexOf("var(");
|
|
178
|
+
if (at === -1) return [value];
|
|
179
|
+
if (depth >= MAX_VAR_DEPTH) return "var() nesting too deep";
|
|
180
|
+
const close = closingParen(value, at + 3);
|
|
181
|
+
if (close === -1) return "unbalanced var()";
|
|
182
|
+
const inner = value.slice(at + 4, close);
|
|
183
|
+
const comma = inner.indexOf(",");
|
|
184
|
+
const prop = (comma === -1 ? inner : inner.slice(0, comma)).trim();
|
|
185
|
+
if (props.opaque.has(prop)) return `${prop} is set outside CSS declarations`;
|
|
186
|
+
const candidates = [...(props.values.get(prop) ?? [])];
|
|
187
|
+
if (comma !== -1) candidates.push(inner.slice(comma + 1).trim());
|
|
188
|
+
// No declaration and no fallback: invalid at computed-value time, so the
|
|
189
|
+
// property behaves as `unset` and inherits — it adds no weight.
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const candidate of candidates) {
|
|
192
|
+
const substituted = `${value.slice(0, at)}${candidate}${value.slice(close + 1)}`;
|
|
193
|
+
const expanded = expandVars(substituted, props, depth + 1);
|
|
194
|
+
if (typeof expanded === "string") return expanded;
|
|
195
|
+
out.push(...expanded);
|
|
196
|
+
if (out.length > MAX_EXPANSIONS) return "too many var() combinations";
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Parse a resolved `font-weight` value; returns a reason when it is not plain. */
|
|
202
|
+
function addWeightTokens(scan: Scan, value: string): string | null {
|
|
203
|
+
const tokens = value
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.split(/[\s,]+/)
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
// Empty usually means the value continues past what we can read (a
|
|
208
|
+
// `{{placeholder}}`, a template literal) — not knowable.
|
|
209
|
+
if (tokens.length === 0) return "no readable value";
|
|
210
|
+
for (const token of tokens) {
|
|
211
|
+
if (/^\d+(\.\d+)?$/.test(token)) {
|
|
212
|
+
const n = Number(token);
|
|
213
|
+
if (n < 1 || n > 1000) return `weight ${token} out of range`;
|
|
214
|
+
scan.weights.add(n);
|
|
215
|
+
} else if (token === "normal") scan.weights.add(400);
|
|
216
|
+
else if (token === "bold") scan.weights.add(700);
|
|
217
|
+
else if (token === "lighter") scan.useLighter = true;
|
|
218
|
+
else if (token === "bolder" || GLOBAL_KEYWORDS.has(token)) {
|
|
219
|
+
// bolder: covered by the closure; globals: initial = 400, rest inherit.
|
|
220
|
+
} else return `unrecognised weight '${token}'`;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Tokens of a `font:` shorthand before its size (where the weight lives). */
|
|
226
|
+
function addShorthand(scan: Scan, value: string): string | null {
|
|
227
|
+
const tokens: string[] = [];
|
|
228
|
+
let depth = 0;
|
|
229
|
+
let current = "";
|
|
230
|
+
for (const ch of value.toLowerCase()) {
|
|
231
|
+
if (ch === "(") depth++;
|
|
232
|
+
if (ch === ")") depth--;
|
|
233
|
+
if (/\s/.test(ch) && depth === 0) {
|
|
234
|
+
if (current) tokens.push(current);
|
|
235
|
+
current = "";
|
|
236
|
+
} else current += ch;
|
|
237
|
+
}
|
|
238
|
+
if (current) tokens.push(current);
|
|
239
|
+
if (tokens.length === 1 && GLOBAL_KEYWORDS.has(tokens[0] as string)) return null;
|
|
240
|
+
if (tokens.length === 1 && SYSTEM_FONTS.has(tokens[0] as string)) {
|
|
241
|
+
return `system font '${tokens[0]}' (weight chosen by the OS)`;
|
|
242
|
+
}
|
|
243
|
+
for (const token of tokens) {
|
|
244
|
+
if (SIZE_TOKEN.test(token) || SIZE_KEYWORDS.has(token)) return null;
|
|
245
|
+
if (/^\d+(\.\d+)?$/.test(token)) {
|
|
246
|
+
const reason = addWeightTokens(scan, token);
|
|
247
|
+
if (reason !== null) return reason;
|
|
248
|
+
} else if (token === "bold" || token === "lighter" || token === "bolder") {
|
|
249
|
+
addWeightTokens(scan, token);
|
|
250
|
+
}
|
|
251
|
+
// style/variant/stretch keywords and `normal` (= 400, already in) need nothing.
|
|
252
|
+
}
|
|
253
|
+
// A shorthand without a size is invalid CSS and is dropped by the browser.
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseVariationSettings(scan: Scan, raw: string): void {
|
|
258
|
+
const value = raw.replace(/["'\\\s]+$/, "").trim();
|
|
259
|
+
if (value === "" || GLOBAL_KEYWORDS.has(value) || value === "normal") return;
|
|
260
|
+
const pair = /^\s*(\\?["'])([A-Za-z0-9 ]{4})\1\s+-?\d*\.?\d+\s*$/;
|
|
261
|
+
const tags: string[] = [];
|
|
262
|
+
for (const part of value.split(",")) {
|
|
263
|
+
const m = pair.exec(part);
|
|
264
|
+
if (m === null) {
|
|
265
|
+
scan.axes = "all";
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
tags.push(m[2] as string);
|
|
269
|
+
}
|
|
270
|
+
if (scan.axes !== "all") for (const t of tags) scan.axes.add(t);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function scanSource(scan: Scan, source: FontUsageSource, props: CustomProps): void {
|
|
274
|
+
const { label, text } = source;
|
|
275
|
+
|
|
276
|
+
for (const m of text.matchAll(/(?<![\w-])font-weight(?![\w-])/gi)) {
|
|
277
|
+
const at = m.index ?? 0;
|
|
278
|
+
const value = valueAfterColon(text, at + m[0].length);
|
|
279
|
+
if (value === null) {
|
|
280
|
+
uncertain(scan, label, `font-weight used outside a declaration (${excerpt(text, at)})`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const expanded = expandVars(value, props);
|
|
284
|
+
if (typeof expanded === "string") {
|
|
285
|
+
uncertain(scan, label, `${expanded} (${excerpt(text, at)})`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const v of expanded) {
|
|
289
|
+
const reason = addWeightTokens(scan, v);
|
|
290
|
+
if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const m of text.matchAll(/(?<![\w-])font\s*:/gi)) {
|
|
295
|
+
const at = m.index ?? 0;
|
|
296
|
+
// Start at the colon itself: valueAfterColon consumes it.
|
|
297
|
+
const value = valueAfterColon(text, at + m[0].length - 1);
|
|
298
|
+
if (value === null) continue;
|
|
299
|
+
const expanded = expandVars(value, props);
|
|
300
|
+
if (typeof expanded === "string") {
|
|
301
|
+
uncertain(scan, label, `${expanded} (${excerpt(text, at)})`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const v of expanded) {
|
|
305
|
+
const reason = addShorthand(scan, v);
|
|
306
|
+
if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const m of text.matchAll(/\bfontWeight\b/g)) {
|
|
311
|
+
const at = m.index ?? 0;
|
|
312
|
+
const literal = /^\s*[:=]\s*(?:(["'`])([^"'`\n]{0,20})\1|(\d+))/.exec(text.slice(at + 10));
|
|
313
|
+
const value = literal === null ? null : (literal[2] ?? literal[3] ?? null);
|
|
314
|
+
const reason =
|
|
315
|
+
value === null ? "fontWeight set from script" : addWeightTokens(scan, value.toString());
|
|
316
|
+
if (reason !== null) uncertain(scan, label, `${reason} (${excerpt(text, at)})`);
|
|
317
|
+
}
|
|
318
|
+
if (/\.font\s*=[^=]/.test(text)) {
|
|
319
|
+
uncertain(scan, label, "font shorthand assigned from script");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const m of text.matchAll(/(?<![\w-])font-variation-settings(?![\w-])/gi)) {
|
|
323
|
+
const at = (m.index ?? 0) + m[0].length;
|
|
324
|
+
const colon = /^\s*:/.exec(text.slice(at, at + 64));
|
|
325
|
+
if (colon === null) {
|
|
326
|
+
scan.axes = "all";
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const rest = text.slice(at + colon[0].length, at + colon[0].length + 400);
|
|
330
|
+
const end = rest.search(/[;{}<>\n!]/);
|
|
331
|
+
parseVariationSettings(scan, end === -1 ? rest : rest.slice(0, end));
|
|
332
|
+
}
|
|
333
|
+
if (/\bfontVariationSettings\b/.test(text)) scan.axes = "all";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Derive {@link SiteFontUsage} from the theme and every site text.
|
|
338
|
+
*
|
|
339
|
+
* @param input.sources every module/template/layout/content/plugin text;
|
|
340
|
+
* the theme's own CSS and the base CSS are added here
|
|
341
|
+
*/
|
|
342
|
+
export function scanSiteFontUsage(input: {
|
|
343
|
+
readonly tokens: ThemeDocument;
|
|
344
|
+
readonly sources: readonly FontUsageSource[];
|
|
345
|
+
}): SiteFontUsage {
|
|
346
|
+
const all: FontUsageSource[] = [
|
|
347
|
+
{ label: "theme", text: renderThemeCss(input.tokens) },
|
|
348
|
+
{ label: "base-css", text: BASE_TECHNICAL_CSS },
|
|
349
|
+
...input.sources,
|
|
350
|
+
];
|
|
351
|
+
const scan: Scan = {
|
|
352
|
+
weights: new Set([400, 700]),
|
|
353
|
+
useLighter: false,
|
|
354
|
+
reasons: [],
|
|
355
|
+
axes: new Set(),
|
|
356
|
+
};
|
|
357
|
+
// Theme typography weights apply to their roles whether or not any CSS
|
|
358
|
+
// references `--font-weight-<role>`; the theme emits them as such vars.
|
|
359
|
+
const themeCss = all[0]?.text ?? "";
|
|
360
|
+
// The name match has no suffix and starts only at a name boundary, so it
|
|
361
|
+
// never backtracks; the value is read with the same bounded reader as
|
|
362
|
+
// every other declaration (a `[name]+:` pattern here is polynomial on
|
|
363
|
+
// runs like `--font-weight---font-weight-...`).
|
|
364
|
+
for (const m of themeCss.matchAll(/(?<![\w-])--font-weight-[A-Za-z0-9_-]+/g)) {
|
|
365
|
+
const value = valueAfterColon(themeCss, (m.index ?? 0) + m[0].length);
|
|
366
|
+
if (value === null) continue;
|
|
367
|
+
const reason = addWeightTokens(scan, value);
|
|
368
|
+
if (reason !== null) uncertain(scan, "theme", reason);
|
|
369
|
+
}
|
|
370
|
+
const props = collectCustomProps(all);
|
|
371
|
+
for (const source of all) scanSource(scan, source, props);
|
|
372
|
+
|
|
373
|
+
const axes = scan.axes === "all" ? ("all" as const) : [...scan.axes].sort();
|
|
374
|
+
if (scan.reasons.length > 0) {
|
|
375
|
+
return { weights: null, uncertain: scan.reasons, variationSettingsAxes: axes };
|
|
376
|
+
}
|
|
377
|
+
// Fixed point of the relative-weight closure.
|
|
378
|
+
const closed = new Set(scan.weights);
|
|
379
|
+
for (let grew = true; grew; ) {
|
|
380
|
+
grew = false;
|
|
381
|
+
for (const w of [...closed]) {
|
|
382
|
+
for (const next of scan.useLighter ? [bolder(w), lighter(w)] : [bolder(w)]) {
|
|
383
|
+
if (!closed.has(next)) {
|
|
384
|
+
closed.add(next);
|
|
385
|
+
grew = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
weights: [...closed].sort((a, z) => a - z),
|
|
392
|
+
uncertain: [],
|
|
393
|
+
variationSettingsAxes: axes,
|
|
394
|
+
};
|
|
395
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export * from "./database-url.js";
|
|
|
17
17
|
export * from "./design-draft-shell.js";
|
|
18
18
|
export * from "./document-language.js";
|
|
19
19
|
export * from "./font-assets.js";
|
|
20
|
+
export * from "./font-axis-policy.js";
|
|
21
|
+
export * from "./font-usage.js";
|
|
20
22
|
export * from "./fonts.js";
|
|
21
23
|
export * from "./genesis.js";
|
|
22
24
|
export * from "./genesis-inventory.js";
|
|
@@ -34,6 +36,7 @@ export {
|
|
|
34
36
|
type ServiceName,
|
|
35
37
|
type StructuredLogEntry,
|
|
36
38
|
} from "./logger.js";
|
|
39
|
+
export * from "./markup-structure.js";
|
|
37
40
|
export * from "./media.js";
|
|
38
41
|
export * from "./nested-module-render.js";
|
|
39
42
|
export * from "./page-log.js";
|
|
@@ -43,6 +46,7 @@ export * from "./preview-compose.js";
|
|
|
43
46
|
export * from "./preview-scanner.js";
|
|
44
47
|
export * from "./proposal-status.js";
|
|
45
48
|
export * from "./provisioning-command.js";
|
|
49
|
+
export * from "./publish-live-parity.js";
|
|
46
50
|
export * from "./redirect-emit-caddy.js";
|
|
47
51
|
export * from "./redirect-emit-cloudfront.js";
|
|
48
52
|
export * from "./redirect-emit-front-door.js";
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
freeRegions,
|
|
6
|
+
isPattern,
|
|
7
|
+
MAX_NODES,
|
|
8
|
+
moduleRoots,
|
|
9
|
+
NEAR_MATCH_THRESHOLD,
|
|
10
|
+
outermostPatterns,
|
|
11
|
+
parseMarkup,
|
|
12
|
+
shapeSimilarity,
|
|
13
|
+
shapeTree,
|
|
14
|
+
stripMustache,
|
|
15
|
+
templateShape,
|
|
16
|
+
} from "./markup-structure.js";
|
|
17
|
+
|
|
18
|
+
const chain = (steps: string[]) =>
|
|
19
|
+
`<div class="chain">${steps
|
|
20
|
+
.map((s, i) => `<div class="step"><span class="n">${i + 1}</span><p>${s}</p></div>`)
|
|
21
|
+
.join('<span class="arrow"></span>')}</div>`;
|
|
22
|
+
|
|
23
|
+
function shapeOf(html: string) {
|
|
24
|
+
const root = parseMarkup(html)[0];
|
|
25
|
+
if (!root) throw new Error("no element");
|
|
26
|
+
const shape = shapeTree(root).get(root);
|
|
27
|
+
if (!shape) throw new Error("no shape");
|
|
28
|
+
return shape;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("signatures", () => {
|
|
32
|
+
it("ignore text and repetition: a 3-step and a 5-step chain are the same pattern", () => {
|
|
33
|
+
const a = shapeOf(chain(["Crawl", "Diff", "Alert"]));
|
|
34
|
+
const b = shapeOf(chain(["One", "Two", "Three", "Four", "Five"]));
|
|
35
|
+
expect(a.signature).toBe(b.signature);
|
|
36
|
+
expect(isPattern(a)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("class order and duplicate class tokens do not matter", () => {
|
|
40
|
+
expect(shapeOf('<div class="b a a"><p></p></div>').signature).toBe(
|
|
41
|
+
shapeOf('<div class="a b"><p></p></div>').signature,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("a different class makes a different signature", () => {
|
|
46
|
+
expect(shapeOf(chain(["x"])).signature).not.toBe(
|
|
47
|
+
shapeOf(chain(["x"]).replace('class="chain"', 'class="flow"')).signature,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("near matches score above the threshold, unrelated markup below", () => {
|
|
52
|
+
const a = shapeOf(chain(["x", "y"]));
|
|
53
|
+
const b = shapeOf(chain(["x", "y"]).replace("</p>", "</p><small>note</small>"));
|
|
54
|
+
expect(a.signature).not.toBe(b.signature);
|
|
55
|
+
expect(shapeSimilarity(a, b)).toBeGreaterThanOrEqual(NEAR_MATCH_THRESHOLD);
|
|
56
|
+
const c = shapeOf('<table class="pm"><tr><th>a</th><td>b</td></tr></table>');
|
|
57
|
+
expect(shapeSimilarity(a, c)).toBeLessThan(0.2);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isPattern", () => {
|
|
62
|
+
it("class-less prose never qualifies, however deep", () => {
|
|
63
|
+
expect(isPattern(shapeOf("<ul><li><a>x</a><strong>y</strong></li></ul>"))).toBe(false);
|
|
64
|
+
expect(isPattern(shapeOf("<table><thead><tr><th>a</th></tr></thead></table>"))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("small classed markup is not a component", () => {
|
|
68
|
+
expect(isPattern(shapeOf('<p class="lead"><a>x</a></p>'))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("outermostPatterns", () => {
|
|
73
|
+
it("reports the outer pattern, not the patterns inside it", () => {
|
|
74
|
+
const html = `<section class="wrap"><h2>t</h2>${chain(["a", "b"])}</section>`;
|
|
75
|
+
const found = outermostPatterns(parseMarkup(html));
|
|
76
|
+
expect(found.map((f) => f.node.tag + f.node.classes.join("."))).toEqual(["sectionwrap"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("finds several patterns side by side in prose", () => {
|
|
80
|
+
const html = `<p>intro</p>${chain(["a"])}<p>mid</p>${chain(["b"]).replace("chain", "pair")}`;
|
|
81
|
+
expect(outermostPatterns(parseMarkup(html)).map((f) => f.node.classes[0])).toEqual([
|
|
82
|
+
"chain",
|
|
83
|
+
"pair",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("templates and free regions", () => {
|
|
89
|
+
const template =
|
|
90
|
+
'<section class="prose"><h2>{{title}}</h2><div class="body">{{body}}</div>' +
|
|
91
|
+
'<ul class="tags">{{#tags}}<li class="tag">{{.}}</li>{{/tags}}</ul></section>';
|
|
92
|
+
|
|
93
|
+
it("stripMustache matches the two-pass regex on edge cases and stays linear on long brace runs", () => {
|
|
94
|
+
const regex = (t: string): string =>
|
|
95
|
+
t.replace(/\{\{\{[^}]*\}\}\}/g, "").replace(/\{\{[^}]*\}\}/g, "");
|
|
96
|
+
for (const t of [
|
|
97
|
+
"{{x{{{y}}}",
|
|
98
|
+
"{{a}}}",
|
|
99
|
+
"{{{a}}",
|
|
100
|
+
"{{{{a}}}}",
|
|
101
|
+
"a{b}c{{",
|
|
102
|
+
"{{}}{{{}}}",
|
|
103
|
+
"}}{{a}b}}",
|
|
104
|
+
"<p>{{#s}}{{{h}}}{{/s}}</p>",
|
|
105
|
+
]) {
|
|
106
|
+
expect(stripMustache(t)).toBe(regex(t));
|
|
107
|
+
}
|
|
108
|
+
const started = performance.now();
|
|
109
|
+
stripMustache("{{{{{{".repeat(50_000));
|
|
110
|
+
stripMustache(`${"{{".repeat(50_000)}}`);
|
|
111
|
+
expect(performance.now() - started).toBeLessThan(500);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stripMustache keeps the markup a template renders", () => {
|
|
115
|
+
expect(stripMustache("<p>{{a}}{{{b}}}</p>{{#s}}<i></i>{{/s}}{{>n}}")).toBe("<p></p><i></i>");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("everything a template renders, list items included, is not free", () => {
|
|
119
|
+
const rendered = parseMarkup(
|
|
120
|
+
'<section class="prose" data-caelo-module-id="m1"><h2>Hi</h2><div class="body"><p>text</p></div>' +
|
|
121
|
+
'<ul class="tags"><li class="tag">a</li><li class="tag">b</li></ul></section>',
|
|
122
|
+
);
|
|
123
|
+
const root = moduleRoots(rendered)[0];
|
|
124
|
+
if (!root) throw new Error("no module root");
|
|
125
|
+
const free = freeRegions(root, parseMarkup(stripMustache(template)));
|
|
126
|
+
expect(free?.map((n) => n.tag)).toEqual(["p"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("markup pasted into a richtext value is a free region", () => {
|
|
130
|
+
const rendered = parseMarkup(
|
|
131
|
+
`<section class="prose" data-caelo-module-id="m1"><h2>Hi</h2><div class="body">${chain(["a", "b"])}</div></section>`,
|
|
132
|
+
);
|
|
133
|
+
const root = moduleRoots(rendered)[0];
|
|
134
|
+
if (!root) throw new Error("no module root");
|
|
135
|
+
const free = freeRegions(root, parseMarkup(stripMustache(template))) ?? [];
|
|
136
|
+
expect(outermostPatterns(free).map((f) => f.node.classes[0])).toEqual(["chain"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a nested module is opaque to its parent and found on its own", () => {
|
|
140
|
+
const rendered = parseMarkup(
|
|
141
|
+
`<section class="prose" data-caelo-module-id="m1"><div class="body">${chain(["a"]).replace(
|
|
142
|
+
'<div class="chain"',
|
|
143
|
+
'<div class="chain" data-caelo-module-id="m2"',
|
|
144
|
+
)}</div></section>`,
|
|
145
|
+
);
|
|
146
|
+
expect(moduleRoots(rendered).map((r) => r.moduleId)).toEqual(["m1", "m2"]);
|
|
147
|
+
const root = moduleRoots(rendered)[0];
|
|
148
|
+
if (!root) throw new Error("no module root");
|
|
149
|
+
expect(freeRegions(root, parseMarkup(stripMustache(template)))).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns null when the rendered root is not the template's root", () => {
|
|
153
|
+
const rendered = parseMarkup('<nav data-caelo-module-id="m3"><a>x</a></nav>');
|
|
154
|
+
const root = moduleRoots(rendered)[0];
|
|
155
|
+
if (!root) throw new Error("no module root");
|
|
156
|
+
expect(freeRegions(root, parseMarkup(stripMustache(template)))).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("templateShape fingerprints the template's root element", () => {
|
|
160
|
+
const t = templateShape(
|
|
161
|
+
'<div class="chain">{{#steps}}<div class="step"><span class="n">{{n}}</span><p>{{t}}</p></div><span class="arrow"></span>{{/steps}}</div>',
|
|
162
|
+
);
|
|
163
|
+
expect(t?.signature).toBe(shapeOf(chain(["a", "b", "c"])).signature);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("bounds", () => {
|
|
168
|
+
it("stops collecting elements at MAX_NODES", () => {
|
|
169
|
+
const html = "<div>".repeat(10) + "<i></i>".repeat(MAX_NODES + 100) + "</div>".repeat(10);
|
|
170
|
+
const roots = parseMarkup(html);
|
|
171
|
+
let count = 0;
|
|
172
|
+
const walk = (n: (typeof roots)[number]) => {
|
|
173
|
+
count++;
|
|
174
|
+
for (const c of n.children) walk(c);
|
|
175
|
+
};
|
|
176
|
+
for (const r of roots) walk(r);
|
|
177
|
+
expect(count).toBe(MAX_NODES);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not descend into svg icons", () => {
|
|
181
|
+
const root = parseMarkup('<span class="i"><svg><g><path/><path/></g></svg></span>')[0];
|
|
182
|
+
expect(root?.children[0]?.children).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|