@chat-de-hp/site 0.3.3 → 0.3.5
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/check-core.d.ts +27 -0
- package/dist/check-core.d.ts.map +1 -0
- package/dist/check-core.js +638 -0
- package/dist/check.d.ts +2 -17
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +18 -529
- package/dist/control-plane.d.ts +28 -28
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +31 -34
- package/dist/internal/forms-plugin-runtime.d.ts +5 -0
- package/dist/internal/forms-plugin-runtime.d.ts.map +1 -0
- package/dist/internal/forms-plugin-runtime.js +42 -0
- package/package.json +5 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ConformanceRule = "annotations" | "fallbacks" | "seed" | "seed-schema";
|
|
2
|
+
export type ConformanceViolation = {
|
|
3
|
+
/** CMS field slug, or collection slug for seed-schema violations. */
|
|
4
|
+
field: string;
|
|
5
|
+
/** Site-root-relative path, always POSIX-separated. */
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
message: string;
|
|
9
|
+
rule: ConformanceRule;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Reports conformance violations for a bounded set of site sources.
|
|
13
|
+
*
|
|
14
|
+
* The invariant behind the screenshot-editing fast path: a rendered CMS-backed
|
|
15
|
+
* value is annotated, or the build fails. See
|
|
16
|
+
* `plans/019-screenshot-site-editing.md` §7.
|
|
17
|
+
*/
|
|
18
|
+
export type ConformanceSource = {
|
|
19
|
+
file: string;
|
|
20
|
+
source: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function checkSiteConformanceSources(options: {
|
|
23
|
+
files: AsyncIterable<ConformanceSource> | Iterable<ConformanceSource>;
|
|
24
|
+
maxViolations?: number;
|
|
25
|
+
seed: ConformanceSource;
|
|
26
|
+
}): Promise<ConformanceViolation[]>;
|
|
27
|
+
//# sourceMappingURL=check-core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check-core.d.ts","sourceRoot":"","sources":["../src/check-core.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,WAAW,GACX,MAAM,GACN,aAAa,CAAC;AAElB,MAAM,MAAM,oBAAoB,GAAG;IACjC,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,eAAe,CAAC;CACvB,CAAC;AA2BF;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAsB,2BAA2B,CAAC,OAAO,EAAE;IACzD,KAAK,EAAE,aAAa,CAAC,iBAAiB,CAAC,GAAG,QAAQ,CAAC,iBAAiB,CAAC,CAAC;IACtE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,iBAAiB,CAAC;CACzB,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAwBlC"}
|
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
export async function checkSiteConformanceSources(options) {
|
|
2
|
+
const seed = parseSeed(options.seed);
|
|
3
|
+
const violations = checkSeedSchema(seed);
|
|
4
|
+
if (seed.fields.size === 0) {
|
|
5
|
+
return sortViolations(violations);
|
|
6
|
+
}
|
|
7
|
+
for await (const file of options.files) {
|
|
8
|
+
violations.push(...checkAstroSource({
|
|
9
|
+
relativePath: file.file,
|
|
10
|
+
seed,
|
|
11
|
+
source: file.source,
|
|
12
|
+
}));
|
|
13
|
+
if (options.maxViolations !== undefined &&
|
|
14
|
+
violations.length >= options.maxViolations) {
|
|
15
|
+
return sortViolations(violations).slice(0, options.maxViolations);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return sortViolations(violations);
|
|
19
|
+
}
|
|
20
|
+
function checkSeedSchema(seed) {
|
|
21
|
+
const violations = seed.collections
|
|
22
|
+
.filter((collection) => collection.supportsDrafts && !collection.supportsRevisions)
|
|
23
|
+
.map((collection) => ({
|
|
24
|
+
field: collection.slug,
|
|
25
|
+
file: seed.file,
|
|
26
|
+
line: 1,
|
|
27
|
+
message: `Collection "${collection.slug}" enables "drafts" without "revisions". Add "revisions", or remove "drafts" and do not use the publish flow.`,
|
|
28
|
+
rule: "seed-schema",
|
|
29
|
+
}));
|
|
30
|
+
const collections = new Set(seed.collections.map((collection) => collection.slug));
|
|
31
|
+
const unknownCollections = new Set();
|
|
32
|
+
for (const provided of seed.provided) {
|
|
33
|
+
const separator = provided.indexOf(".");
|
|
34
|
+
const collection = provided.slice(0, separator);
|
|
35
|
+
const field = provided.slice(separator + 1);
|
|
36
|
+
if (!collections.has(collection)) {
|
|
37
|
+
if (!unknownCollections.has(collection)) {
|
|
38
|
+
unknownCollections.add(collection);
|
|
39
|
+
violations.push({
|
|
40
|
+
field: collection,
|
|
41
|
+
file: seed.file,
|
|
42
|
+
line: 1,
|
|
43
|
+
message: `Content collection "${collection}" has seed data but no matching collection is declared.`,
|
|
44
|
+
rule: "seed-schema",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!seed.declared.has(provided)) {
|
|
50
|
+
violations.push({
|
|
51
|
+
field,
|
|
52
|
+
file: seed.file,
|
|
53
|
+
line: 1,
|
|
54
|
+
message: `Content field "${collection}.${field}" is provided but not declared in collection "${collection}".`,
|
|
55
|
+
rule: "seed-schema",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return violations;
|
|
60
|
+
}
|
|
61
|
+
function sortViolations(violations) {
|
|
62
|
+
return violations.sort((left, right) => left.file.localeCompare(right.file) ||
|
|
63
|
+
left.line - right.line ||
|
|
64
|
+
left.field.localeCompare(right.field));
|
|
65
|
+
}
|
|
66
|
+
function checkAstroSource(input) {
|
|
67
|
+
const { frontmatter, frontmatterLineOffset, template, templateLineOffset } = splitAstro(input.source);
|
|
68
|
+
const locals = collectLocals(frontmatter, input.seed);
|
|
69
|
+
const violations = [];
|
|
70
|
+
const renderedFields = new Set();
|
|
71
|
+
violations.push(...findFallbacks({
|
|
72
|
+
code: frontmatter,
|
|
73
|
+
lineOffset: frontmatterLineOffset,
|
|
74
|
+
opaque: locals.opaque,
|
|
75
|
+
relativePath: input.relativePath,
|
|
76
|
+
seed: input.seed,
|
|
77
|
+
}), ...findFallbacks({
|
|
78
|
+
code: template,
|
|
79
|
+
lineOffset: templateLineOffset,
|
|
80
|
+
opaque: locals.opaque,
|
|
81
|
+
relativePath: input.relativePath,
|
|
82
|
+
seed: input.seed,
|
|
83
|
+
}));
|
|
84
|
+
for (const render of findRenders({ locals, seed: input.seed, template })) {
|
|
85
|
+
renderedFields.add(`${render.collection}.${render.field}`);
|
|
86
|
+
if (render.annotated) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
violations.push({
|
|
90
|
+
field: render.field,
|
|
91
|
+
file: input.relativePath,
|
|
92
|
+
line: templateLineOffset + render.line,
|
|
93
|
+
message: `${render.collection}.${render.field} rendered without {...<entry>.edit.${render.field}}`,
|
|
94
|
+
rule: "annotations",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const rendered of renderedFields) {
|
|
98
|
+
if (input.seed.seeded.has(rendered)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const [, field = ""] = rendered.split(".");
|
|
102
|
+
violations.push({
|
|
103
|
+
field,
|
|
104
|
+
file: input.relativePath,
|
|
105
|
+
line: 1,
|
|
106
|
+
message: `${rendered} rendered but absent from seed`,
|
|
107
|
+
rule: "seed",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return violations;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Splits an `.astro` file into its frontmatter and template halves, keeping the
|
|
114
|
+
* template's 1-based line offset so reported lines match the real file.
|
|
115
|
+
*/
|
|
116
|
+
function splitAstro(source) {
|
|
117
|
+
const match = /^(---\r?\n)([\s\S]*?)\r?\n---[^\n]*\r?\n?/u.exec(source);
|
|
118
|
+
if (!match) {
|
|
119
|
+
return {
|
|
120
|
+
frontmatter: "",
|
|
121
|
+
frontmatterLineOffset: 0,
|
|
122
|
+
template: source,
|
|
123
|
+
templateLineOffset: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const consumed = match[0];
|
|
127
|
+
return {
|
|
128
|
+
frontmatter: match[2] ?? "",
|
|
129
|
+
// The opening `---` line sits above the frontmatter body.
|
|
130
|
+
frontmatterLineOffset: countLines(match[1] ?? ""),
|
|
131
|
+
template: source.slice(consumed.length),
|
|
132
|
+
templateLineOffset: countLines(consumed),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function countLines(text) {
|
|
136
|
+
let lines = 0;
|
|
137
|
+
for (const character of text) {
|
|
138
|
+
if (character === "\n") {
|
|
139
|
+
lines += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
const LEADING_MEMBER_CHAIN = /^\s*[A-Za-z_$][\w$]*((?:\s*\??\.\s*[A-Za-z_$][\w$]*)+)/u;
|
|
145
|
+
const MEMBER_CHAIN_ONLY = /^\s*[A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*\s*$/u;
|
|
146
|
+
/**
|
|
147
|
+
* Classifies frontmatter locals. `const eyebrow = info.data.hero_eyebrow`
|
|
148
|
+
* aliases the field it carries. `const seo = getSeoMeta(post, {...})` is
|
|
149
|
+
* opaque: its properties are derived values, so `seo.title` colliding with a
|
|
150
|
+
* field slug is not a CMS read. A local that only *selects by* a field
|
|
151
|
+
* (`items.find((i) => i.is_featured)`) is neither — it carries an entry, not
|
|
152
|
+
* that field's value.
|
|
153
|
+
*/
|
|
154
|
+
function collectLocals(frontmatter, seed) {
|
|
155
|
+
const aliases = new Map();
|
|
156
|
+
const opaque = new Set();
|
|
157
|
+
const declaration = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/gu;
|
|
158
|
+
let match = declaration.exec(frontmatter);
|
|
159
|
+
while (match !== null) {
|
|
160
|
+
const name = match[1] ?? "";
|
|
161
|
+
const initializer = match[2] ?? "";
|
|
162
|
+
if (!(aliases.has(name) || opaque.has(name))) {
|
|
163
|
+
const field = leadingChainField(initializer, seed);
|
|
164
|
+
if (field) {
|
|
165
|
+
aliases.set(name, field);
|
|
166
|
+
}
|
|
167
|
+
else if (!(
|
|
168
|
+
// `const data = info.data` re-roots an entry, not a derived value.
|
|
169
|
+
(MEMBER_CHAIN_ONLY.test(initializer) ||
|
|
170
|
+
// Awaited initializers are entry/settings fetches; their fields count.
|
|
171
|
+
/\bawait\b/u.test(initializer) ||
|
|
172
|
+
firstFieldRead(initializer, seed)))) {
|
|
173
|
+
opaque.add(name);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
match = declaration.exec(frontmatter);
|
|
177
|
+
}
|
|
178
|
+
return { aliases, opaque };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The CMS field read by `expression`'s leading member chain —
|
|
182
|
+
* `info.data.hero_title.split("…")` reads `hero_title`, while
|
|
183
|
+
* `items.find((i) => i.hero_title)` reads none: a field inside call arguments
|
|
184
|
+
* selects, it does not flow into the local.
|
|
185
|
+
*/
|
|
186
|
+
function leadingChainField(expression, seed) {
|
|
187
|
+
const chain = LEADING_MEMBER_CHAIN.exec(expression)?.[1] ?? "";
|
|
188
|
+
return firstFieldRead(chain, seed);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The root identifier of the member chain containing the property at `index`,
|
|
192
|
+
* or null when the chain hangs off something other than a plain identifier
|
|
193
|
+
* (`getEntry(x).title`, `rows[0].name`).
|
|
194
|
+
*/
|
|
195
|
+
function chainRootAt(code, index) {
|
|
196
|
+
let cursor = index - 1;
|
|
197
|
+
for (;;) {
|
|
198
|
+
while (cursor >= 0 && /\s/u.test(code[cursor] ?? "")) {
|
|
199
|
+
cursor -= 1;
|
|
200
|
+
}
|
|
201
|
+
if (code[cursor] !== ".") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
cursor -= 1;
|
|
205
|
+
if (code[cursor] === "?") {
|
|
206
|
+
cursor -= 1;
|
|
207
|
+
}
|
|
208
|
+
while (cursor >= 0 && /\s/u.test(code[cursor] ?? "")) {
|
|
209
|
+
cursor -= 1;
|
|
210
|
+
}
|
|
211
|
+
const end = cursor;
|
|
212
|
+
while (cursor >= 0 && /[\w$]/u.test(code[cursor] ?? "")) {
|
|
213
|
+
cursor -= 1;
|
|
214
|
+
}
|
|
215
|
+
if (cursor === end) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const name = code.slice(cursor + 1, end + 1);
|
|
219
|
+
let before = cursor;
|
|
220
|
+
while (before >= 0 && /\s/u.test(code[before] ?? "")) {
|
|
221
|
+
before -= 1;
|
|
222
|
+
}
|
|
223
|
+
if (code[before] !== ".") {
|
|
224
|
+
return name;
|
|
225
|
+
}
|
|
226
|
+
cursor = before;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The first CMS field slug read by a property access in `expression`. */
|
|
230
|
+
function firstFieldRead(expression, seed) {
|
|
231
|
+
for (const access of propertyAccesses(expression)) {
|
|
232
|
+
if (seed.fields.has(access.property)) {
|
|
233
|
+
return access.property;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/** Every `<expr>.<name>` / `<expr>?.<name>` property read in `code`. */
|
|
239
|
+
function propertyAccesses(code) {
|
|
240
|
+
const accesses = [];
|
|
241
|
+
const pattern = /\??\.\s*([A-Za-z_$][\w$]*)/gu;
|
|
242
|
+
let match = pattern.exec(code);
|
|
243
|
+
while (match !== null) {
|
|
244
|
+
accesses.push({
|
|
245
|
+
index: match.index + match[0].length - (match[1]?.length ?? 0),
|
|
246
|
+
property: match[1] ?? "",
|
|
247
|
+
});
|
|
248
|
+
match = pattern.exec(code);
|
|
249
|
+
}
|
|
250
|
+
return accesses;
|
|
251
|
+
}
|
|
252
|
+
function findFallbacks(input) {
|
|
253
|
+
const violations = [];
|
|
254
|
+
// `<something>.<cms field> ?? <default>` — the default belongs in seed data.
|
|
255
|
+
const pattern = /\??\.\s*([A-Za-z_$][\w$]*)\s*\?\?/gu;
|
|
256
|
+
let match = pattern.exec(input.code);
|
|
257
|
+
while (match !== null) {
|
|
258
|
+
const field = match[1] ?? "";
|
|
259
|
+
const collections = input.seed.fields.get(field);
|
|
260
|
+
const root = chainRootAt(input.code, input.code.indexOf(field, match.index));
|
|
261
|
+
if (collections?.[0] && !(root && input.opaque.has(root))) {
|
|
262
|
+
violations.push({
|
|
263
|
+
field,
|
|
264
|
+
file: input.relativePath,
|
|
265
|
+
line: input.lineOffset + lineAt(input.code, match.index),
|
|
266
|
+
message: `${collections[0].collection}.${field} has a hardcoded ?? fallback; defaults belong in seed data`,
|
|
267
|
+
rule: "fallbacks",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
match = pattern.exec(input.code);
|
|
271
|
+
}
|
|
272
|
+
return violations;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Finds each CMS-backed value rendered by the template and whether an
|
|
276
|
+
* annotation covers it — either on the element it renders in or on an ancestor
|
|
277
|
+
* wrapper, which is how a transformed value stays one target.
|
|
278
|
+
*
|
|
279
|
+
* Guard reads (`{price && …}`) only test the value, so like plain HTML
|
|
280
|
+
* attributes they are not renders; the render a guard protects is checked on
|
|
281
|
+
* its own. A genuinely unannotated render is always reported, even when the
|
|
282
|
+
* same field is annotated elsewhere in the file: each render site has to carry
|
|
283
|
+
* its own annotation for the routing rule to be honest about that element.
|
|
284
|
+
*/
|
|
285
|
+
function findRenders(input) {
|
|
286
|
+
const annotations = findAnnotations(input.template);
|
|
287
|
+
const renders = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
for (const expression of templateExpressions(input.template)) {
|
|
290
|
+
for (const read of fieldsRenderedBy(expression.code, {
|
|
291
|
+
...input,
|
|
292
|
+
callbackLocals: expression.callbackLocals,
|
|
293
|
+
})) {
|
|
294
|
+
const collections = input.seed.fields.get(read.field);
|
|
295
|
+
const collection = collections?.[0]?.collection;
|
|
296
|
+
if (!collection || isGuardRead(expression.code, read.index)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Offsets are absolute so an annotation on an ancestor covers the read.
|
|
300
|
+
const index = expression.index + read.index;
|
|
301
|
+
const line = lineAt(input.template, index);
|
|
302
|
+
const key = `${collection}.${read.field}:${line}`;
|
|
303
|
+
if (seen.has(key)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
seen.add(key);
|
|
307
|
+
renders.push({
|
|
308
|
+
annotated: isAnnotated({ annotations, field: read.field, index }),
|
|
309
|
+
collection,
|
|
310
|
+
field: read.field,
|
|
311
|
+
line,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return renders;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether the read at `index` only tests its value. True when what follows is a
|
|
319
|
+
* boolean operator or a ternary condition rather than the end of the
|
|
320
|
+
* expression, as in `{price && (…)}` or `{price ? … : …}`.
|
|
321
|
+
*
|
|
322
|
+
* Trailing property access is part of the read being tested, so
|
|
323
|
+
* `{image?.id && (…)}` is a guard on `image` just as `{image && (…)}` is.
|
|
324
|
+
*/
|
|
325
|
+
function isGuardRead(code, index) {
|
|
326
|
+
const rest = code
|
|
327
|
+
.slice(index)
|
|
328
|
+
.replace(/^[\w$]+/u, "")
|
|
329
|
+
.replace(/^(\??\.\s*[\w$]+)*\s*/u, "");
|
|
330
|
+
return /^(&&|\|\||\?[^.?])/u.test(rest);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* CMS fields a rendered expression resolves to, directly or via an alias, each
|
|
334
|
+
* with the offset it is read at.
|
|
335
|
+
*/
|
|
336
|
+
function fieldsRenderedBy(code, input) {
|
|
337
|
+
const reads = [];
|
|
338
|
+
for (const access of propertyAccesses(code)) {
|
|
339
|
+
if (!input.seed.fields.has(access.property)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const root = chainRootAt(code, access.index);
|
|
343
|
+
if (root &&
|
|
344
|
+
(input.locals.opaque.has(root) ||
|
|
345
|
+
(input.callbackLocals.has(root) &&
|
|
346
|
+
!isEntryDataFieldAccess(code, access.index)))) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
reads.push({ field: access.property, index: access.index });
|
|
350
|
+
}
|
|
351
|
+
for (const identifier of bareIdentifiers(code)) {
|
|
352
|
+
const aliased = input.locals.aliases.get(identifier.name);
|
|
353
|
+
if (aliased) {
|
|
354
|
+
reads.push({ field: aliased, index: identifier.index });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return reads;
|
|
358
|
+
}
|
|
359
|
+
/** Whether the field at `index` is read through an EmDash entry's `.data`. */
|
|
360
|
+
function isEntryDataFieldAccess(code, index) {
|
|
361
|
+
return /\??\.\s*data\s*\??\.\s*$/u.test(code.slice(0, index));
|
|
362
|
+
}
|
|
363
|
+
/** Identifiers used on their own — not as `.property` and not as a call. */
|
|
364
|
+
function bareIdentifiers(code) {
|
|
365
|
+
const identifiers = [];
|
|
366
|
+
const pattern = /(^|[^.\w$?])([A-Za-z_$][\w$]*)\s*(?![\w$(.])/gu;
|
|
367
|
+
let match = pattern.exec(code);
|
|
368
|
+
while (match !== null) {
|
|
369
|
+
identifiers.push({
|
|
370
|
+
index: match.index + (match[1]?.length ?? 0),
|
|
371
|
+
name: match[2] ?? "",
|
|
372
|
+
});
|
|
373
|
+
match = pattern.exec(code);
|
|
374
|
+
}
|
|
375
|
+
return identifiers;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Locates `{...<entry>.edit.<field>}` spreads and the span of the element each
|
|
379
|
+
* one annotates, so a wrapper annotation covers everything it contains.
|
|
380
|
+
*/
|
|
381
|
+
function findAnnotations(template) {
|
|
382
|
+
const annotations = [];
|
|
383
|
+
const pattern = /\{\s*\.\.\.\s*[\w$?.]*\bedit\??\.\s*([A-Za-z_$][\w$]*)\s*\}/gu;
|
|
384
|
+
let match = pattern.exec(template);
|
|
385
|
+
while (match !== null) {
|
|
386
|
+
const start = template.lastIndexOf("<", match.index);
|
|
387
|
+
if (start !== -1) {
|
|
388
|
+
annotations.push({
|
|
389
|
+
end: elementEnd(template, start),
|
|
390
|
+
field: match[1] ?? "",
|
|
391
|
+
start,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
match = pattern.exec(template);
|
|
395
|
+
}
|
|
396
|
+
return annotations;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* End offset of the element opening at `start`: the matching close tag when the
|
|
400
|
+
* element has one, otherwise the end of its self-closing/void tag.
|
|
401
|
+
*/
|
|
402
|
+
function elementEnd(template, start) {
|
|
403
|
+
const tagMatch = /^<\s*([A-Za-z][\w:.-]*)/u.exec(template.slice(start));
|
|
404
|
+
const tag = tagMatch?.[1];
|
|
405
|
+
const openTagEnd = template.indexOf(">", start);
|
|
406
|
+
if (!tag || openTagEnd === -1) {
|
|
407
|
+
return template.length;
|
|
408
|
+
}
|
|
409
|
+
if (template[openTagEnd - 1] === "/") {
|
|
410
|
+
return openTagEnd + 1;
|
|
411
|
+
}
|
|
412
|
+
const boundary = new RegExp(`<\\s*(/)?${escapeRegExp(tag)}(?=[\\s/>])`, "gu");
|
|
413
|
+
boundary.lastIndex = openTagEnd + 1;
|
|
414
|
+
let depth = 1;
|
|
415
|
+
let match = boundary.exec(template);
|
|
416
|
+
while (match !== null) {
|
|
417
|
+
depth += match[1] ? -1 : 1;
|
|
418
|
+
if (depth === 0) {
|
|
419
|
+
return match.index + match[0].length;
|
|
420
|
+
}
|
|
421
|
+
match = boundary.exec(template);
|
|
422
|
+
}
|
|
423
|
+
return template.length;
|
|
424
|
+
}
|
|
425
|
+
function escapeRegExp(value) {
|
|
426
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\-]/gu, String.raw `\$&`);
|
|
427
|
+
}
|
|
428
|
+
function isAnnotated(input) {
|
|
429
|
+
return input.annotations.some((annotation) => annotation.field === input.field &&
|
|
430
|
+
input.index >= annotation.start &&
|
|
431
|
+
input.index < annotation.end);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* `{...}` expressions in template position, including those nested inside
|
|
435
|
+
* markup an outer expression returns, and those passed as props to a component.
|
|
436
|
+
* Annotation spreads are skipped: only what the page actually renders counts.
|
|
437
|
+
*
|
|
438
|
+
* Nested expressions are visited separately rather than as part of their
|
|
439
|
+
* parent's text, so an identifier is only read where it is genuinely
|
|
440
|
+
* evaluated — an `aria-label` attribute is markup, not a render of `label`.
|
|
441
|
+
*
|
|
442
|
+
* A component prop (`<Image image={data.hero_image} />`) renders its value, so
|
|
443
|
+
* it counts. A plain HTML attribute (`href={mapUrl}`) does not: it carries
|
|
444
|
+
* metadata rather than visible content the customer would screenshot.
|
|
445
|
+
*/
|
|
446
|
+
function templateExpressions(template, offset = 0, callbackLocals = new Set()) {
|
|
447
|
+
const expressions = [];
|
|
448
|
+
let index = 0;
|
|
449
|
+
let componentTagDepth = 0;
|
|
450
|
+
let insideTag = false;
|
|
451
|
+
while (index < template.length) {
|
|
452
|
+
const character = template[index];
|
|
453
|
+
if (character === "<" && /[A-Za-z/]/u.test(template[index + 1] ?? "")) {
|
|
454
|
+
insideTag = true;
|
|
455
|
+
// Components are capitalized; lowercase names are HTML elements.
|
|
456
|
+
componentTagDepth = /^<\s*[A-Z]/u.test(template.slice(index, index + 3))
|
|
457
|
+
? 1
|
|
458
|
+
: 0;
|
|
459
|
+
index += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (character === ">" && insideTag) {
|
|
463
|
+
insideTag = false;
|
|
464
|
+
componentTagDepth = 0;
|
|
465
|
+
index += 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (character !== "{") {
|
|
469
|
+
index += 1;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (insideTag) {
|
|
473
|
+
if (componentTagDepth > 0) {
|
|
474
|
+
const attributeEnd = matchingBrace(template, index);
|
|
475
|
+
const attributeBody = template.slice(index + 1, attributeEnd);
|
|
476
|
+
if (!isSpread(attributeBody)) {
|
|
477
|
+
expressions.push({
|
|
478
|
+
callbackLocals: callbackLocalsFor(attributeBody, callbackLocals),
|
|
479
|
+
code: attributeBody,
|
|
480
|
+
index: offset + index + 1,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
index = attributeEnd + 1;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
index += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const end = matchingBrace(template, index);
|
|
490
|
+
const body = template.slice(index + 1, end);
|
|
491
|
+
if (!isSpread(body)) {
|
|
492
|
+
const bodyStart = offset + index + 1;
|
|
493
|
+
const markupAt = body.search(/<\s*[A-Za-z]/u);
|
|
494
|
+
if (markupAt === -1) {
|
|
495
|
+
expressions.push({
|
|
496
|
+
callbackLocals: callbackLocalsFor(body, callbackLocals),
|
|
497
|
+
code: body,
|
|
498
|
+
index: bodyStart,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
// Everything before the markup is this expression's own code; the
|
|
503
|
+
// markup is a nested template scanned on its own terms.
|
|
504
|
+
const prefix = body.slice(0, markupAt);
|
|
505
|
+
const nestedCallbackLocals = callbackLocalsFor(prefix, callbackLocals);
|
|
506
|
+
expressions.push({
|
|
507
|
+
callbackLocals: nestedCallbackLocals,
|
|
508
|
+
code: prefix,
|
|
509
|
+
index: bodyStart,
|
|
510
|
+
});
|
|
511
|
+
expressions.push(...templateExpressions(body.slice(markupAt), bodyStart + markupAt, nestedCallbackLocals));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
index = end + 1;
|
|
515
|
+
}
|
|
516
|
+
return expressions;
|
|
517
|
+
}
|
|
518
|
+
function callbackLocalsFor(code, inherited) {
|
|
519
|
+
const introduced = arrowFunctionParameters(code);
|
|
520
|
+
if (introduced.size === 0) {
|
|
521
|
+
return inherited;
|
|
522
|
+
}
|
|
523
|
+
return new Set([...inherited, ...introduced]);
|
|
524
|
+
}
|
|
525
|
+
/** Simple identifier parameters introduced by arrows that return nested markup. */
|
|
526
|
+
function arrowFunctionParameters(code) {
|
|
527
|
+
const parameters = new Set();
|
|
528
|
+
const pattern = /(?:^|[,(]\s*)(?:async\s+)?(?:\(\s*([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*\)|([A-Za-z_$][\w$]*))\s*=>/gu;
|
|
529
|
+
let match = pattern.exec(code);
|
|
530
|
+
while (match !== null) {
|
|
531
|
+
const list = match[1] ?? match[2] ?? "";
|
|
532
|
+
for (const name of list.split(",")) {
|
|
533
|
+
const trimmed = name.trim();
|
|
534
|
+
if (trimmed) {
|
|
535
|
+
parameters.add(trimmed);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
match = pattern.exec(code);
|
|
539
|
+
}
|
|
540
|
+
return parameters;
|
|
541
|
+
}
|
|
542
|
+
function isSpread(code) {
|
|
543
|
+
return code.trimStart().startsWith("...");
|
|
544
|
+
}
|
|
545
|
+
function matchingBrace(template, start) {
|
|
546
|
+
let depth = 0;
|
|
547
|
+
for (let index = start; index < template.length; index += 1) {
|
|
548
|
+
const character = template[index];
|
|
549
|
+
if (character === "{") {
|
|
550
|
+
depth += 1;
|
|
551
|
+
}
|
|
552
|
+
else if (character === "}") {
|
|
553
|
+
depth -= 1;
|
|
554
|
+
if (depth === 0) {
|
|
555
|
+
return index;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return template.length;
|
|
560
|
+
}
|
|
561
|
+
function lineAt(code, index) {
|
|
562
|
+
return countLines(code.slice(0, index)) + 1;
|
|
563
|
+
}
|
|
564
|
+
function parseSeed(source) {
|
|
565
|
+
const parsed = JSON.parse(source.source);
|
|
566
|
+
const seedCollections = [];
|
|
567
|
+
const declared = new Set();
|
|
568
|
+
const fields = new Map();
|
|
569
|
+
const provided = new Set();
|
|
570
|
+
const seeded = new Set();
|
|
571
|
+
if (!isRecord(parsed)) {
|
|
572
|
+
return {
|
|
573
|
+
collections: seedCollections,
|
|
574
|
+
declared,
|
|
575
|
+
fields,
|
|
576
|
+
file: source.file,
|
|
577
|
+
provided,
|
|
578
|
+
seeded,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const collections = Array.isArray(parsed.collections)
|
|
582
|
+
? parsed.collections
|
|
583
|
+
: [];
|
|
584
|
+
for (const collection of collections) {
|
|
585
|
+
if (!(isRecord(collection) && typeof collection.slug === "string")) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const collectionSlug = collection.slug;
|
|
589
|
+
const supports = Array.isArray(collection.supports)
|
|
590
|
+
? collection.supports
|
|
591
|
+
: [];
|
|
592
|
+
seedCollections.push({
|
|
593
|
+
slug: collectionSlug,
|
|
594
|
+
supportsDrafts: supports.includes("drafts"),
|
|
595
|
+
supportsRevisions: supports.includes("revisions"),
|
|
596
|
+
});
|
|
597
|
+
const collectionFields = Array.isArray(collection.fields)
|
|
598
|
+
? collection.fields
|
|
599
|
+
: [];
|
|
600
|
+
for (const field of collectionFields) {
|
|
601
|
+
if (!(isRecord(field) && typeof field.slug === "string")) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const entries = fields.get(field.slug) ?? [];
|
|
605
|
+
entries.push({ collection: collectionSlug, slug: field.slug });
|
|
606
|
+
fields.set(field.slug, entries);
|
|
607
|
+
declared.add(`${collectionSlug}.${field.slug}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const content = isRecord(parsed.content) ? parsed.content : {};
|
|
611
|
+
for (const [collectionSlug, entries] of Object.entries(content)) {
|
|
612
|
+
if (!Array.isArray(entries)) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
if (!(isRecord(entry) && isRecord(entry.data))) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
for (const [slug, value] of Object.entries(entry.data)) {
|
|
620
|
+
provided.add(`${collectionSlug}.${slug}`);
|
|
621
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
622
|
+
seeded.add(`${collectionSlug}.${slug}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
collections: seedCollections,
|
|
629
|
+
declared,
|
|
630
|
+
fields,
|
|
631
|
+
file: source.file,
|
|
632
|
+
provided,
|
|
633
|
+
seeded,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function isRecord(value) {
|
|
637
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
638
|
+
}
|
package/dist/check.d.ts
CHANGED
|
@@ -1,20 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export type ConformanceViolation
|
|
3
|
-
/** CMS field slug the violation is about. */
|
|
4
|
-
field: string;
|
|
5
|
-
/** Site-root-relative path, always POSIX-separated. */
|
|
6
|
-
file: string;
|
|
7
|
-
line: number;
|
|
8
|
-
message: string;
|
|
9
|
-
rule: ConformanceRule;
|
|
10
|
-
};
|
|
11
|
-
/**
|
|
12
|
-
* Reports conformance violations for one site root.
|
|
13
|
-
*
|
|
14
|
-
* The invariant behind the screenshot-editing fast path: a rendered CMS-backed
|
|
15
|
-
* value is annotated, or the build fails. See
|
|
16
|
-
* `plans/019-screenshot-site-editing.md` §7.
|
|
17
|
-
*/
|
|
1
|
+
import { type ConformanceViolation } from "./check-core.js";
|
|
2
|
+
export type { ConformanceRule, ConformanceSource, ConformanceViolation, } from "./check-core.js";
|
|
18
3
|
export declare function checkSiteConformance(options: {
|
|
19
4
|
root: string;
|
|
20
5
|
}): Promise<ConformanceViolation[]>;
|
package/dist/check.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAIA,
|
|
1
|
+
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,oBAAoB,EAE1B,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AAWzB,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAKlC"}
|