@chat-de-hp/site 0.2.3 → 0.2.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/annotation-middleware.d.ts +4 -0
- package/dist/annotation-middleware.d.ts.map +1 -0
- package/dist/annotation-middleware.js +20 -0
- package/dist/astro.d.ts.map +1 -1
- package/dist/astro.js +2 -0
- package/dist/check.d.ts +21 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +576 -0
- package/dist/cli.js +51 -11
- package/dist/contracts/forms.d.ts +6 -6
- package/dist/emdash-annotation.d.ts +7 -0
- package/dist/emdash-annotation.d.ts.map +1 -0
- package/dist/emdash-annotation.js +37 -0
- package/dist/internal/annotation-context.d.ts +3 -0
- package/dist/internal/annotation-context.d.ts.map +1 -0
- package/dist/internal/annotation-context.js +14 -0
- package/dist/internal/annotation-integration.d.ts +3 -0
- package/dist/internal/annotation-integration.d.ts.map +1 -0
- package/dist/internal/annotation-integration.js +43 -0
- package/dist/runtime/annotation-capture.d.ts +2 -0
- package/dist/runtime/annotation-capture.d.ts.map +1 -0
- package/dist/runtime/annotation-capture.js +204 -0
- package/dist/runtime/capture-contract.d.ts +58 -0
- package/dist/runtime/capture-contract.d.ts.map +1 -0
- package/dist/runtime/capture-contract.js +105 -0
- package/dist/runtime/contracts.d.ts +2 -0
- package/dist/runtime/contracts.d.ts.map +1 -1
- package/dist/runtime/contracts.js +2 -1
- package/dist/runtime/element-extractor.d.ts +21 -0
- package/dist/runtime/element-extractor.d.ts.map +1 -0
- package/dist/runtime/element-extractor.js +258 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/package.json +30 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"annotation-middleware.d.ts","sourceRoot":"","sources":["../src/annotation-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAK/C,eAAO,MAAM,SAAS,EAAE,iBAiBvB,CAAC;AAEF,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runWithAnnotationMode } from "./internal/annotation-context.js";
|
|
2
|
+
import { CHAT_DE_HP_ANNOTATION_MODE_HEADER } from "./runtime/contracts.js";
|
|
3
|
+
export const onRequest = (context, next) => {
|
|
4
|
+
if (context.request.headers.get(CHAT_DE_HP_ANNOTATION_MODE_HEADER) !== "1") {
|
|
5
|
+
return next();
|
|
6
|
+
}
|
|
7
|
+
context.cache.set(false);
|
|
8
|
+
return runWithAnnotationMode(async () => {
|
|
9
|
+
const response = await next();
|
|
10
|
+
const headers = new Headers(response.headers);
|
|
11
|
+
headers.set("cache-control", "private, no-store");
|
|
12
|
+
headers.set("x-robots-tag", "noindex, nofollow");
|
|
13
|
+
return new Response(response.body, {
|
|
14
|
+
headers,
|
|
15
|
+
status: response.status,
|
|
16
|
+
statusText: response.statusText,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
export default onRequest;
|
package/dist/astro.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"astro.d.ts","sourceRoot":"","sources":["../src/astro.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,OAAO,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"astro.d.ts","sourceRoot":"","sources":["../src/astro.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,OAAO,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAKtD,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;AAC7D,QAAA,MAAM,sBAAsB,sNAkBlB,CAAC;AACX,KAAK,kBAAkB,GAAG,OAAO,CAC/B,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,EACvC,MAAM,kBAAkB,CACzB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,IAAI,CACrC,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,EAC5C,cAAc,CACf,GAAG;IACF,YAAY,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC5C,CAAC;AAOF,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,kBAAkB,EACxB,OAAO,GAAE,oBAAyB,GACjC,kBAAkB,CAkCpB"}
|
package/dist/astro.js
CHANGED
|
@@ -3,6 +3,7 @@ import react from "@astrojs/react";
|
|
|
3
3
|
import { d1, r2 } from "@emdash-cms/cloudflare";
|
|
4
4
|
import { defineConfig } from "astro/config";
|
|
5
5
|
import emdash from "emdash/astro";
|
|
6
|
+
import { createAnnotationOnlyIntegration } from "./internal/annotation-integration.js";
|
|
6
7
|
import { createApprovedAstroPlugins } from "./internal/astro-plugins.js";
|
|
7
8
|
import { resolveSiteDefinition } from "./internal/registry.js";
|
|
8
9
|
const SITE_ASTRO_OPTION_KEYS = [
|
|
@@ -52,6 +53,7 @@ export function createChatDeHpAstroConfig(site, options = {}) {
|
|
|
52
53
|
plugins: plugins,
|
|
53
54
|
storage: r2({ binding: "MEDIA" }),
|
|
54
55
|
}),
|
|
56
|
+
createAnnotationOnlyIntegration(),
|
|
55
57
|
],
|
|
56
58
|
output: "server",
|
|
57
59
|
});
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ConformanceRule = "annotations" | "fallbacks" | "seed";
|
|
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
|
+
*/
|
|
18
|
+
export declare function checkSiteConformance(options: {
|
|
19
|
+
root: string;
|
|
20
|
+
}): Promise<ConformanceViolation[]>;
|
|
21
|
+
//# sourceMappingURL=check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,WAAW,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,oBAAoB,GAAG;IACjC,6CAA6C;IAC7C,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;AAuBF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAuBlC"}
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
const SOURCE_DIRECTORY = "src";
|
|
4
|
+
const SEED_PATHS = ["seed/seed.json", "seed.json"];
|
|
5
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
6
|
+
".chat-de-hp",
|
|
7
|
+
".git",
|
|
8
|
+
"dist",
|
|
9
|
+
"node_modules",
|
|
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
|
+
*/
|
|
18
|
+
export async function checkSiteConformance(options) {
|
|
19
|
+
const seed = await loadSeed(options.root);
|
|
20
|
+
if (seed.fields.size === 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const violations = [];
|
|
24
|
+
for (const file of await astroFiles(join(options.root, SOURCE_DIRECTORY))) {
|
|
25
|
+
violations.push(...checkAstroSource({
|
|
26
|
+
relativePath: toPosix(relative(options.root, file)),
|
|
27
|
+
seed,
|
|
28
|
+
source: await readFile(file, "utf-8"),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
return violations.sort((left, right) => left.file.localeCompare(right.file) ||
|
|
32
|
+
left.line - right.line ||
|
|
33
|
+
left.field.localeCompare(right.field));
|
|
34
|
+
}
|
|
35
|
+
function checkAstroSource(input) {
|
|
36
|
+
const { frontmatter, frontmatterLineOffset, template, templateLineOffset } = splitAstro(input.source);
|
|
37
|
+
const locals = collectLocals(frontmatter, input.seed);
|
|
38
|
+
const violations = [];
|
|
39
|
+
const renderedFields = new Set();
|
|
40
|
+
violations.push(...findFallbacks({
|
|
41
|
+
code: frontmatter,
|
|
42
|
+
lineOffset: frontmatterLineOffset,
|
|
43
|
+
opaque: locals.opaque,
|
|
44
|
+
relativePath: input.relativePath,
|
|
45
|
+
seed: input.seed,
|
|
46
|
+
}), ...findFallbacks({
|
|
47
|
+
code: template,
|
|
48
|
+
lineOffset: templateLineOffset,
|
|
49
|
+
opaque: locals.opaque,
|
|
50
|
+
relativePath: input.relativePath,
|
|
51
|
+
seed: input.seed,
|
|
52
|
+
}));
|
|
53
|
+
for (const render of findRenders({ locals, seed: input.seed, template })) {
|
|
54
|
+
renderedFields.add(`${render.collection}.${render.field}`);
|
|
55
|
+
if (render.annotated) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
violations.push({
|
|
59
|
+
field: render.field,
|
|
60
|
+
file: input.relativePath,
|
|
61
|
+
line: templateLineOffset + render.line,
|
|
62
|
+
message: `${render.collection}.${render.field} rendered without {...<entry>.edit.${render.field}}`,
|
|
63
|
+
rule: "annotations",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const rendered of renderedFields) {
|
|
67
|
+
if (input.seed.seeded.has(rendered)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const [, field = ""] = rendered.split(".");
|
|
71
|
+
violations.push({
|
|
72
|
+
field,
|
|
73
|
+
file: input.relativePath,
|
|
74
|
+
line: 1,
|
|
75
|
+
message: `${rendered} rendered but absent from seed`,
|
|
76
|
+
rule: "seed",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return violations;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Splits an `.astro` file into its frontmatter and template halves, keeping the
|
|
83
|
+
* template's 1-based line offset so reported lines match the real file.
|
|
84
|
+
*/
|
|
85
|
+
function splitAstro(source) {
|
|
86
|
+
const match = /^(---\r?\n)([\s\S]*?)\r?\n---[^\n]*\r?\n?/u.exec(source);
|
|
87
|
+
if (!match) {
|
|
88
|
+
return {
|
|
89
|
+
frontmatter: "",
|
|
90
|
+
frontmatterLineOffset: 0,
|
|
91
|
+
template: source,
|
|
92
|
+
templateLineOffset: 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const consumed = match[0];
|
|
96
|
+
return {
|
|
97
|
+
frontmatter: match[2] ?? "",
|
|
98
|
+
// The opening `---` line sits above the frontmatter body.
|
|
99
|
+
frontmatterLineOffset: countLines(match[1] ?? ""),
|
|
100
|
+
template: source.slice(consumed.length),
|
|
101
|
+
templateLineOffset: countLines(consumed),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function countLines(text) {
|
|
105
|
+
let lines = 0;
|
|
106
|
+
for (const character of text) {
|
|
107
|
+
if (character === "\n") {
|
|
108
|
+
lines += 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
const LEADING_MEMBER_CHAIN = /^\s*[A-Za-z_$][\w$]*((?:\s*\??\.\s*[A-Za-z_$][\w$]*)+)/u;
|
|
114
|
+
const MEMBER_CHAIN_ONLY = /^\s*[A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*\s*$/u;
|
|
115
|
+
/**
|
|
116
|
+
* Classifies frontmatter locals. `const eyebrow = info.data.hero_eyebrow`
|
|
117
|
+
* aliases the field it carries. `const seo = getSeoMeta(post, {...})` is
|
|
118
|
+
* opaque: its properties are derived values, so `seo.title` colliding with a
|
|
119
|
+
* field slug is not a CMS read. A local that only *selects by* a field
|
|
120
|
+
* (`items.find((i) => i.is_featured)`) is neither — it carries an entry, not
|
|
121
|
+
* that field's value.
|
|
122
|
+
*/
|
|
123
|
+
function collectLocals(frontmatter, seed) {
|
|
124
|
+
const aliases = new Map();
|
|
125
|
+
const opaque = new Set();
|
|
126
|
+
const declaration = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/gu;
|
|
127
|
+
let match = declaration.exec(frontmatter);
|
|
128
|
+
while (match !== null) {
|
|
129
|
+
const name = match[1] ?? "";
|
|
130
|
+
const initializer = match[2] ?? "";
|
|
131
|
+
if (!(aliases.has(name) || opaque.has(name))) {
|
|
132
|
+
const field = leadingChainField(initializer, seed);
|
|
133
|
+
if (field) {
|
|
134
|
+
aliases.set(name, field);
|
|
135
|
+
}
|
|
136
|
+
else if (!(
|
|
137
|
+
// `const data = info.data` re-roots an entry, not a derived value.
|
|
138
|
+
(MEMBER_CHAIN_ONLY.test(initializer) ||
|
|
139
|
+
// Awaited initializers are entry/settings fetches; their fields count.
|
|
140
|
+
/\bawait\b/u.test(initializer) ||
|
|
141
|
+
firstFieldRead(initializer, seed)))) {
|
|
142
|
+
opaque.add(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
match = declaration.exec(frontmatter);
|
|
146
|
+
}
|
|
147
|
+
return { aliases, opaque };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The CMS field read by `expression`'s leading member chain —
|
|
151
|
+
* `info.data.hero_title.split("…")` reads `hero_title`, while
|
|
152
|
+
* `items.find((i) => i.hero_title)` reads none: a field inside call arguments
|
|
153
|
+
* selects, it does not flow into the local.
|
|
154
|
+
*/
|
|
155
|
+
function leadingChainField(expression, seed) {
|
|
156
|
+
const chain = LEADING_MEMBER_CHAIN.exec(expression)?.[1] ?? "";
|
|
157
|
+
return firstFieldRead(chain, seed);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The root identifier of the member chain containing the property at `index`,
|
|
161
|
+
* or null when the chain hangs off something other than a plain identifier
|
|
162
|
+
* (`getEntry(x).title`, `rows[0].name`).
|
|
163
|
+
*/
|
|
164
|
+
function chainRootAt(code, index) {
|
|
165
|
+
let cursor = index - 1;
|
|
166
|
+
for (;;) {
|
|
167
|
+
while (cursor >= 0 && /\s/u.test(code[cursor] ?? "")) {
|
|
168
|
+
cursor -= 1;
|
|
169
|
+
}
|
|
170
|
+
if (code[cursor] !== ".") {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
cursor -= 1;
|
|
174
|
+
if (code[cursor] === "?") {
|
|
175
|
+
cursor -= 1;
|
|
176
|
+
}
|
|
177
|
+
while (cursor >= 0 && /\s/u.test(code[cursor] ?? "")) {
|
|
178
|
+
cursor -= 1;
|
|
179
|
+
}
|
|
180
|
+
const end = cursor;
|
|
181
|
+
while (cursor >= 0 && /[\w$]/u.test(code[cursor] ?? "")) {
|
|
182
|
+
cursor -= 1;
|
|
183
|
+
}
|
|
184
|
+
if (cursor === end) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const name = code.slice(cursor + 1, end + 1);
|
|
188
|
+
let before = cursor;
|
|
189
|
+
while (before >= 0 && /\s/u.test(code[before] ?? "")) {
|
|
190
|
+
before -= 1;
|
|
191
|
+
}
|
|
192
|
+
if (code[before] !== ".") {
|
|
193
|
+
return name;
|
|
194
|
+
}
|
|
195
|
+
cursor = before;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** The first CMS field slug read by a property access in `expression`. */
|
|
199
|
+
function firstFieldRead(expression, seed) {
|
|
200
|
+
for (const access of propertyAccesses(expression)) {
|
|
201
|
+
if (seed.fields.has(access.property)) {
|
|
202
|
+
return access.property;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
/** Every `<expr>.<name>` / `<expr>?.<name>` property read in `code`. */
|
|
208
|
+
function propertyAccesses(code) {
|
|
209
|
+
const accesses = [];
|
|
210
|
+
const pattern = /\??\.\s*([A-Za-z_$][\w$]*)/gu;
|
|
211
|
+
let match = pattern.exec(code);
|
|
212
|
+
while (match !== null) {
|
|
213
|
+
accesses.push({
|
|
214
|
+
index: match.index + match[0].length - (match[1]?.length ?? 0),
|
|
215
|
+
property: match[1] ?? "",
|
|
216
|
+
});
|
|
217
|
+
match = pattern.exec(code);
|
|
218
|
+
}
|
|
219
|
+
return accesses;
|
|
220
|
+
}
|
|
221
|
+
function findFallbacks(input) {
|
|
222
|
+
const violations = [];
|
|
223
|
+
// `<something>.<cms field> ?? <default>` — the default belongs in seed data.
|
|
224
|
+
const pattern = /\??\.\s*([A-Za-z_$][\w$]*)\s*\?\?/gu;
|
|
225
|
+
let match = pattern.exec(input.code);
|
|
226
|
+
while (match !== null) {
|
|
227
|
+
const field = match[1] ?? "";
|
|
228
|
+
const collections = input.seed.fields.get(field);
|
|
229
|
+
const root = chainRootAt(input.code, input.code.indexOf(field, match.index));
|
|
230
|
+
if (collections?.[0] && !(root && input.opaque.has(root))) {
|
|
231
|
+
violations.push({
|
|
232
|
+
field,
|
|
233
|
+
file: input.relativePath,
|
|
234
|
+
line: input.lineOffset + lineAt(input.code, match.index),
|
|
235
|
+
message: `${collections[0].collection}.${field} has a hardcoded ?? fallback; defaults belong in seed data`,
|
|
236
|
+
rule: "fallbacks",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
match = pattern.exec(input.code);
|
|
240
|
+
}
|
|
241
|
+
return violations;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Finds each CMS-backed value rendered by the template and whether an
|
|
245
|
+
* annotation covers it — either on the element it renders in or on an ancestor
|
|
246
|
+
* wrapper, which is how a transformed value stays one target.
|
|
247
|
+
*
|
|
248
|
+
* Guard reads (`{price && …}`) only test the value, so like plain HTML
|
|
249
|
+
* attributes they are not renders; the render a guard protects is checked on
|
|
250
|
+
* its own. A genuinely unannotated render is always reported, even when the
|
|
251
|
+
* same field is annotated elsewhere in the file: each render site has to carry
|
|
252
|
+
* its own annotation for the routing rule to be honest about that element.
|
|
253
|
+
*/
|
|
254
|
+
function findRenders(input) {
|
|
255
|
+
const annotations = findAnnotations(input.template);
|
|
256
|
+
const renders = [];
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
for (const expression of templateExpressions(input.template)) {
|
|
259
|
+
for (const read of fieldsRenderedBy(expression.code, input)) {
|
|
260
|
+
const collections = input.seed.fields.get(read.field);
|
|
261
|
+
const collection = collections?.[0]?.collection;
|
|
262
|
+
if (!collection || isGuardRead(expression.code, read.index)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Offsets are absolute so an annotation on an ancestor covers the read.
|
|
266
|
+
const index = expression.index + read.index;
|
|
267
|
+
const line = lineAt(input.template, index);
|
|
268
|
+
const key = `${collection}.${read.field}:${line}`;
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
seen.add(key);
|
|
273
|
+
renders.push({
|
|
274
|
+
annotated: isAnnotated({ annotations, field: read.field, index }),
|
|
275
|
+
collection,
|
|
276
|
+
field: read.field,
|
|
277
|
+
line,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return renders;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Whether the read at `index` only tests its value. True when what follows is a
|
|
285
|
+
* boolean operator or a ternary condition rather than the end of the
|
|
286
|
+
* expression, as in `{price && (…)}` or `{price ? … : …}`.
|
|
287
|
+
*
|
|
288
|
+
* Trailing property access is part of the read being tested, so
|
|
289
|
+
* `{image?.id && (…)}` is a guard on `image` just as `{image && (…)}` is.
|
|
290
|
+
*/
|
|
291
|
+
function isGuardRead(code, index) {
|
|
292
|
+
const rest = code
|
|
293
|
+
.slice(index)
|
|
294
|
+
.replace(/^[\w$]+/u, "")
|
|
295
|
+
.replace(/^(\??\.\s*[\w$]+)*\s*/u, "");
|
|
296
|
+
return /^(&&|\|\||\?[^.?])/u.test(rest);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* CMS fields a rendered expression resolves to, directly or via an alias, each
|
|
300
|
+
* with the offset it is read at.
|
|
301
|
+
*/
|
|
302
|
+
function fieldsRenderedBy(code, input) {
|
|
303
|
+
const reads = [];
|
|
304
|
+
for (const access of propertyAccesses(code)) {
|
|
305
|
+
if (!input.seed.fields.has(access.property)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const root = chainRootAt(code, access.index);
|
|
309
|
+
if (root && input.locals.opaque.has(root)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
reads.push({ field: access.property, index: access.index });
|
|
313
|
+
}
|
|
314
|
+
for (const identifier of bareIdentifiers(code)) {
|
|
315
|
+
const aliased = input.locals.aliases.get(identifier.name);
|
|
316
|
+
if (aliased) {
|
|
317
|
+
reads.push({ field: aliased, index: identifier.index });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return reads;
|
|
321
|
+
}
|
|
322
|
+
/** Identifiers used on their own — not as `.property` and not as a call. */
|
|
323
|
+
function bareIdentifiers(code) {
|
|
324
|
+
const identifiers = [];
|
|
325
|
+
const pattern = /(^|[^.\w$?])([A-Za-z_$][\w$]*)\s*(?![\w$(.])/gu;
|
|
326
|
+
let match = pattern.exec(code);
|
|
327
|
+
while (match !== null) {
|
|
328
|
+
identifiers.push({
|
|
329
|
+
index: match.index + (match[1]?.length ?? 0),
|
|
330
|
+
name: match[2] ?? "",
|
|
331
|
+
});
|
|
332
|
+
match = pattern.exec(code);
|
|
333
|
+
}
|
|
334
|
+
return identifiers;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Locates `{...<entry>.edit.<field>}` spreads and the span of the element each
|
|
338
|
+
* one annotates, so a wrapper annotation covers everything it contains.
|
|
339
|
+
*/
|
|
340
|
+
function findAnnotations(template) {
|
|
341
|
+
const annotations = [];
|
|
342
|
+
const pattern = /\{\s*\.\.\.\s*[\w$?.]*\bedit\??\.\s*([A-Za-z_$][\w$]*)\s*\}/gu;
|
|
343
|
+
let match = pattern.exec(template);
|
|
344
|
+
while (match !== null) {
|
|
345
|
+
const start = template.lastIndexOf("<", match.index);
|
|
346
|
+
if (start !== -1) {
|
|
347
|
+
annotations.push({
|
|
348
|
+
end: elementEnd(template, start),
|
|
349
|
+
field: match[1] ?? "",
|
|
350
|
+
start,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
match = pattern.exec(template);
|
|
354
|
+
}
|
|
355
|
+
return annotations;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* End offset of the element opening at `start`: the matching close tag when the
|
|
359
|
+
* element has one, otherwise the end of its self-closing/void tag.
|
|
360
|
+
*/
|
|
361
|
+
function elementEnd(template, start) {
|
|
362
|
+
const tagMatch = /^<\s*([A-Za-z][\w:.-]*)/u.exec(template.slice(start));
|
|
363
|
+
const tag = tagMatch?.[1];
|
|
364
|
+
const openTagEnd = template.indexOf(">", start);
|
|
365
|
+
if (!tag || openTagEnd === -1) {
|
|
366
|
+
return template.length;
|
|
367
|
+
}
|
|
368
|
+
if (template[openTagEnd - 1] === "/") {
|
|
369
|
+
return openTagEnd + 1;
|
|
370
|
+
}
|
|
371
|
+
const boundary = new RegExp(`<\\s*(/)?${escapeRegExp(tag)}(?=[\\s/>])`, "gu");
|
|
372
|
+
boundary.lastIndex = openTagEnd + 1;
|
|
373
|
+
let depth = 1;
|
|
374
|
+
let match = boundary.exec(template);
|
|
375
|
+
while (match !== null) {
|
|
376
|
+
depth += match[1] ? -1 : 1;
|
|
377
|
+
if (depth === 0) {
|
|
378
|
+
return match.index + match[0].length;
|
|
379
|
+
}
|
|
380
|
+
match = boundary.exec(template);
|
|
381
|
+
}
|
|
382
|
+
return template.length;
|
|
383
|
+
}
|
|
384
|
+
function escapeRegExp(value) {
|
|
385
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\-]/gu, String.raw `\$&`);
|
|
386
|
+
}
|
|
387
|
+
function isAnnotated(input) {
|
|
388
|
+
return input.annotations.some((annotation) => annotation.field === input.field &&
|
|
389
|
+
input.index >= annotation.start &&
|
|
390
|
+
input.index < annotation.end);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* `{...}` expressions in template position, including those nested inside
|
|
394
|
+
* markup an outer expression returns, and those passed as props to a component.
|
|
395
|
+
* Annotation spreads are skipped: only what the page actually renders counts.
|
|
396
|
+
*
|
|
397
|
+
* Nested expressions are visited separately rather than as part of their
|
|
398
|
+
* parent's text, so an identifier is only read where it is genuinely
|
|
399
|
+
* evaluated — an `aria-label` attribute is markup, not a render of `label`.
|
|
400
|
+
*
|
|
401
|
+
* A component prop (`<Image image={data.hero_image} />`) renders its value, so
|
|
402
|
+
* it counts. A plain HTML attribute (`href={mapUrl}`) does not: it carries
|
|
403
|
+
* metadata rather than visible content the customer would screenshot.
|
|
404
|
+
*/
|
|
405
|
+
function templateExpressions(template, offset = 0) {
|
|
406
|
+
const expressions = [];
|
|
407
|
+
let index = 0;
|
|
408
|
+
let componentTagDepth = 0;
|
|
409
|
+
let insideTag = false;
|
|
410
|
+
while (index < template.length) {
|
|
411
|
+
const character = template[index];
|
|
412
|
+
if (character === "<" && /[A-Za-z/]/u.test(template[index + 1] ?? "")) {
|
|
413
|
+
insideTag = true;
|
|
414
|
+
// Components are capitalized; lowercase names are HTML elements.
|
|
415
|
+
componentTagDepth = /^<\s*[A-Z]/u.test(template.slice(index, index + 3))
|
|
416
|
+
? 1
|
|
417
|
+
: 0;
|
|
418
|
+
index += 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (character === ">" && insideTag) {
|
|
422
|
+
insideTag = false;
|
|
423
|
+
componentTagDepth = 0;
|
|
424
|
+
index += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (character !== "{") {
|
|
428
|
+
index += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (insideTag) {
|
|
432
|
+
if (componentTagDepth > 0) {
|
|
433
|
+
const attributeEnd = matchingBrace(template, index);
|
|
434
|
+
const attributeBody = template.slice(index + 1, attributeEnd);
|
|
435
|
+
if (!isSpread(attributeBody)) {
|
|
436
|
+
expressions.push({
|
|
437
|
+
code: attributeBody,
|
|
438
|
+
index: offset + index + 1,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
index = attributeEnd + 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
index += 1;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const end = matchingBrace(template, index);
|
|
448
|
+
const body = template.slice(index + 1, end);
|
|
449
|
+
if (!isSpread(body)) {
|
|
450
|
+
const bodyStart = offset + index + 1;
|
|
451
|
+
const markupAt = body.search(/<\s*[A-Za-z]/u);
|
|
452
|
+
if (markupAt === -1) {
|
|
453
|
+
expressions.push({ code: body, index: bodyStart });
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// Everything before the markup is this expression's own code; the
|
|
457
|
+
// markup is a nested template scanned on its own terms.
|
|
458
|
+
expressions.push({
|
|
459
|
+
code: body.slice(0, markupAt),
|
|
460
|
+
index: bodyStart,
|
|
461
|
+
});
|
|
462
|
+
expressions.push(...templateExpressions(body.slice(markupAt), bodyStart + markupAt));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
index = end + 1;
|
|
466
|
+
}
|
|
467
|
+
return expressions;
|
|
468
|
+
}
|
|
469
|
+
function isSpread(code) {
|
|
470
|
+
return code.trimStart().startsWith("...");
|
|
471
|
+
}
|
|
472
|
+
function matchingBrace(template, start) {
|
|
473
|
+
let depth = 0;
|
|
474
|
+
for (let index = start; index < template.length; index += 1) {
|
|
475
|
+
const character = template[index];
|
|
476
|
+
if (character === "{") {
|
|
477
|
+
depth += 1;
|
|
478
|
+
}
|
|
479
|
+
else if (character === "}") {
|
|
480
|
+
depth -= 1;
|
|
481
|
+
if (depth === 0) {
|
|
482
|
+
return index;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return template.length;
|
|
487
|
+
}
|
|
488
|
+
function lineAt(code, index) {
|
|
489
|
+
return countLines(code.slice(0, index)) + 1;
|
|
490
|
+
}
|
|
491
|
+
async function loadSeed(root) {
|
|
492
|
+
const raw = await readSeedFile(root);
|
|
493
|
+
const parsed = JSON.parse(raw);
|
|
494
|
+
const fields = new Map();
|
|
495
|
+
const seeded = new Set();
|
|
496
|
+
if (!isRecord(parsed)) {
|
|
497
|
+
return { fields, seeded };
|
|
498
|
+
}
|
|
499
|
+
const collections = Array.isArray(parsed.collections)
|
|
500
|
+
? parsed.collections
|
|
501
|
+
: [];
|
|
502
|
+
for (const collection of collections) {
|
|
503
|
+
if (!(isRecord(collection) && typeof collection.slug === "string")) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const collectionSlug = collection.slug;
|
|
507
|
+
const collectionFields = Array.isArray(collection.fields)
|
|
508
|
+
? collection.fields
|
|
509
|
+
: [];
|
|
510
|
+
for (const field of collectionFields) {
|
|
511
|
+
if (!(isRecord(field) && typeof field.slug === "string")) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const entries = fields.get(field.slug) ?? [];
|
|
515
|
+
entries.push({ collection: collectionSlug, slug: field.slug });
|
|
516
|
+
fields.set(field.slug, entries);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const content = isRecord(parsed.content) ? parsed.content : {};
|
|
520
|
+
for (const [collectionSlug, entries] of Object.entries(content)) {
|
|
521
|
+
if (!Array.isArray(entries)) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
for (const entry of entries) {
|
|
525
|
+
if (!(isRecord(entry) && isRecord(entry.data))) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
for (const [slug, value] of Object.entries(entry.data)) {
|
|
529
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
530
|
+
seeded.add(`${collectionSlug}.${slug}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { fields, seeded };
|
|
536
|
+
}
|
|
537
|
+
async function readSeedFile(root) {
|
|
538
|
+
for (const candidate of SEED_PATHS) {
|
|
539
|
+
try {
|
|
540
|
+
return await readFile(join(root, candidate), "utf-8");
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// Try the next supported seed location.
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
throw new Error(`Could not find seed data in ${root}. Expected ${SEED_PATHS.join(" or ")}.`);
|
|
547
|
+
}
|
|
548
|
+
async function astroFiles(directory) {
|
|
549
|
+
let entries;
|
|
550
|
+
try {
|
|
551
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
const files = [];
|
|
557
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
558
|
+
const path = join(directory, entry.name);
|
|
559
|
+
if (entry.isDirectory()) {
|
|
560
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
561
|
+
files.push(...(await astroFiles(path)));
|
|
562
|
+
}
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (entry.name.endsWith(".astro")) {
|
|
566
|
+
files.push(path);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return files;
|
|
570
|
+
}
|
|
571
|
+
function isRecord(value) {
|
|
572
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
573
|
+
}
|
|
574
|
+
function toPosix(path) {
|
|
575
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
576
|
+
}
|