@aeptus/aexeo-emdash 0.8.19 → 0.9.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/INSTALL.md +286 -4
  3. package/README.md +20 -5
  4. package/dist/adapter.d.ts +62 -4
  5. package/dist/adapter.d.ts.map +1 -1
  6. package/dist/admin/Findings.d.ts.map +1 -1
  7. package/dist/admin.d.ts.map +1 -1
  8. package/dist/admin.js +73 -6
  9. package/dist/assessment-display.d.ts +10 -0
  10. package/dist/assessment-display.d.ts.map +1 -0
  11. package/dist/cms-coverage.d.ts +10 -0
  12. package/dist/cms-coverage.d.ts.map +1 -0
  13. package/dist/configured.d.ts +16 -1
  14. package/dist/configured.d.ts.map +1 -1
  15. package/dist/configured.js +1341 -172
  16. package/dist/content-evaluator.d.ts +34 -0
  17. package/dist/content-evaluator.d.ts.map +1 -0
  18. package/dist/content.d.ts +98 -0
  19. package/dist/content.d.ts.map +1 -0
  20. package/dist/content.js +445 -0
  21. package/dist/data-route.d.ts +12 -1
  22. package/dist/data-route.d.ts.map +1 -1
  23. package/dist/evaluator-sandbox.d.ts +1 -1
  24. package/dist/evaluator-sandbox.d.ts.map +1 -1
  25. package/dist/evaluator.d.ts +1 -1
  26. package/dist/evaluator.d.ts.map +1 -1
  27. package/dist/findings-view.d.ts +11 -5
  28. package/dist/findings-view.d.ts.map +1 -1
  29. package/dist/index.d.ts +17 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +163 -4
  32. package/dist/inventory.d.ts +59 -0
  33. package/dist/inventory.d.ts.map +1 -0
  34. package/dist/plugin.d.ts +59 -3
  35. package/dist/plugin.d.ts.map +1 -1
  36. package/dist/presence-check.d.ts +1 -0
  37. package/dist/presence-check.d.ts.map +1 -1
  38. package/dist/sandbox-entry.d.ts +2 -1
  39. package/dist/sandbox-entry.d.ts.map +1 -1
  40. package/dist/sandbox-entry.js +1077 -190
  41. package/dist/sandbox.d.ts +2 -2
  42. package/dist/sidecar-url.d.ts.map +1 -1
  43. package/dist/sidecar.d.ts +9 -1
  44. package/dist/sidecar.d.ts.map +1 -1
  45. package/dist/types.d.ts +26 -4
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.d.ts.map +1 -1
  49. package/dist/wasm-init.d.ts +8 -0
  50. package/dist/wasm-init.d.ts.map +1 -1
  51. package/package.json +28 -8
  52. package/wasm/aexeo_emdash_bridge.d.ts +22 -0
  53. package/wasm/aexeo_emdash_bridge.js +1 -1
  54. package/wasm/aexeo_emdash_bridge_bg.d.ts +15 -0
  55. package/wasm/aexeo_emdash_bridge_bg.js +130 -0
  56. package/wasm/aexeo_emdash_bridge_bg.wasm +0 -0
  57. package/wasm/aexeo_emdash_bridge_bg.wasm.d.ts +4 -0
@@ -1,11 +1,36 @@
1
+ // src/cms-coverage.ts
2
+ var NATIVE_RULE_ID = /^(?:A11Y|AGT|CNT|CRW|DEP|EDT|FACTS|GEO|IMG|LLM|LNK|MAP|QLT|ROB|SCH|SEO|SOC|SRF)[0-9]{3}$/;
3
+ var OBSERVABLE_BODY_RULES = /* @__PURE__ */ new Set(["CNT006", "LNK003"]);
4
+ function isCmsObservableFinding(finding, bodyComplete = true) {
5
+ return finding.aexeo_origin === "content_preset" || isLegacyCmsObservableFinding(finding, bodyComplete);
6
+ }
7
+ function isLegacyCmsObservableFinding(finding, bodyComplete = true) {
8
+ if (!NATIVE_RULE_ID.test(finding.rule_id)) return true;
9
+ return bodyComplete && finding.scope === "page" && OBSERVABLE_BODY_RULES.has(finding.rule_id);
10
+ }
11
+ function hasCompleteBody(document) {
12
+ return document.body !== void 0 && (document.body_capabilities === void 0 || ["text", "headings", "paragraphs", "links"].every((capability) => document.body_capabilities?.includes(capability)));
13
+ }
14
+ function cmsBodyCoverage(document) {
15
+ if (document.body === void 0 || document.body_capabilities?.length === 0) return "unavailable";
16
+ return hasCompleteBody(document) ? "complete" : "partial";
17
+ }
18
+ function cmsBodyCoverageNotice(coverage) {
19
+ if (coverage === "complete") return void 0;
20
+ return coverage === void 0 ? "Body coverage is unknown for this evaluation; save or refresh to reassess it." : `Body coverage is ${coverage}; content checks depending on missing capabilities cannot be evaluated reliably.`;
21
+ }
22
+
1
23
  // src/adapter.ts
2
- async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
24
+ async function adaptContentItemWithContext(content, contentApi, defaultLocale, blockMappings, contentFields, descriptionFields) {
3
25
  const publicUrl = content.status === "published" ? await contentApi.getPublicUrl(content.type, content.id) : null;
4
26
  const route = publicUrl === null ? deriveRoute(content) : routeFromPublicUrl(publicUrl);
5
27
  const adapted = adaptContentItem(content, {
6
28
  route,
7
29
  publicUrl,
8
- ...defaultLocale === void 0 ? {} : { defaultLocale }
30
+ ...defaultLocale === void 0 ? {} : { defaultLocale },
31
+ ...blockMappings === void 0 ? {} : { blockMappings },
32
+ ...contentFields === void 0 ? {} : { contentFields },
33
+ ...descriptionFields === void 0 ? {} : { descriptionFields }
9
34
  });
10
35
  const translationSet = await contentApi.getTranslations(
11
36
  content.type,
@@ -30,14 +55,32 @@ async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
30
55
  return adapted;
31
56
  }
32
57
  function adaptContentItem(content, options = {}) {
58
+ return adaptContentItemInternal(content, options, true);
59
+ }
60
+ function adaptContentItemInternal(content, options, includeSourcePaths) {
33
61
  const route = options.route ?? deriveRoute(content);
34
62
  const title = stringOrEmpty(
35
63
  content.seo?.title ?? content.data["title"] ?? content.slug ?? content.id
36
64
  );
37
- const document = { route, title };
38
- const description = content.seo?.description ?? content.data["description"];
39
- if (typeof description === "string" && description.length > 0) {
40
- document.description = description;
65
+ const document = { route, title, description_source_observed: false };
66
+ const fields = options.descriptionFields;
67
+ if (fields === void 0) {
68
+ const description = content.seo?.description ?? content.data["description"];
69
+ if (typeof description === "string" && description.length > 0) document.description = description;
70
+ } else if (fields.length > 0 && fields.every((field) => typeof field === "string" && field.length > 0)) {
71
+ let observed = true;
72
+ for (const field of fields) {
73
+ if (!Object.hasOwn(content.data, field) || content.data[field] === void 0 || content.data[field] !== null && typeof content.data[field] !== "string") {
74
+ observed = false;
75
+ break;
76
+ }
77
+ const value = content.data[field];
78
+ if (typeof value === "string" && value.length > 0) {
79
+ document.description = value;
80
+ break;
81
+ }
82
+ }
83
+ if (observed) document.description_source_observed = true;
41
84
  }
42
85
  const canonical = content.seo?.canonical;
43
86
  if (typeof canonical === "string" && canonical.length > 0) {
@@ -47,8 +90,13 @@ function adaptContentItem(content, options = {}) {
47
90
  if (typeof locale === "string" && locale.length > 0) {
48
91
  document.lang = locale;
49
92
  }
50
- const body = extractPortableText(content.data);
51
- if (body !== null) document.body = body;
93
+ const body = options.contentFields === void 0 ? extractPortableText(content.data, options.blockMappings) : extractSelectedContent(content.data, options.contentFields, options.blockMappings);
94
+ if (body !== null) {
95
+ document.body = body.blocks;
96
+ if (body.capabilities !== void 0) document.body_capabilities = body.capabilities;
97
+ }
98
+ const bodySourcePaths = body === null || !includeSourcePaths ? void 0 : portableTextSourcePaths(content.data, body.blocks);
99
+ const bodySourceSpanPaths = body === null || !includeSourcePaths ? void 0 : portableTextSourceSpanPaths(content.data, body.blocks, bodySourcePaths);
52
100
  return {
53
101
  document,
54
102
  meta: {
@@ -57,7 +105,10 @@ function adaptContentItem(content, options = {}) {
57
105
  slug: content.slug,
58
106
  status: content.status,
59
107
  title,
60
- publicUrl: options.publicUrl ?? null
108
+ publicUrl: options.publicUrl ?? null,
109
+ ...bodySourcePaths === void 0 ? {} : { bodySourcePaths },
110
+ ...bodySourceSpanPaths === void 0 ? {} : { bodySourceSpanPaths },
111
+ ...content.updatedAt === void 0 ? {} : { updatedAt: content.updatedAt }
61
112
  }
62
113
  };
63
114
  }
@@ -76,38 +127,390 @@ function routeFromPublicUrl(publicUrl) {
76
127
  function stringOrEmpty(value) {
77
128
  return typeof value === "string" ? value : "";
78
129
  }
79
- function extractPortableText(data) {
130
+ function portableTextSourcePaths(data, blocks) {
131
+ if (blocks.length === 0) return void 0;
132
+ const targets = new WeakSet(blocks);
133
+ const paths = /* @__PURE__ */ new WeakMap();
134
+ const seen = /* @__PURE__ */ new WeakSet();
135
+ const stack = [{ value: data, path: ["data"] }];
136
+ let count = 0;
137
+ let inspected = 0;
138
+ while (stack.length > 0) {
139
+ const current = stack.pop();
140
+ if (typeof current.value !== "object" || current.value === null) continue;
141
+ if (++count > 5e4 || current.path.length > 64 || seen.has(current.value)) return void 0;
142
+ seen.add(current.value);
143
+ if (targets.has(current.value)) {
144
+ paths.set(current.value, current.path);
145
+ continue;
146
+ }
147
+ if (Array.isArray(current.value)) {
148
+ for (let i = 0; i < current.value.length; i++) {
149
+ if (++inspected > 5e4) return void 0;
150
+ const value = current.value[i];
151
+ if (typeof value === "object" && value !== null) stack.push({ value, path: [...current.path, i] });
152
+ }
153
+ } else {
154
+ for (const key in current.value) {
155
+ if (!Object.hasOwn(current.value, key)) continue;
156
+ if (++inspected > 5e4) return void 0;
157
+ const value = current.value[key];
158
+ if (typeof value === "object" && value !== null) stack.push({ value, path: [...current.path, key] });
159
+ }
160
+ }
161
+ }
162
+ const result = blocks.map((block) => paths.get(block) ?? null);
163
+ return result.some((path) => path !== null) ? result : void 0;
164
+ }
165
+ var BODY_CAPABILITIES = ["text", "headings", "paragraphs", "links"];
166
+ function intersectCapabilities(current, other) {
167
+ return other === void 0 ? [...current] : current.filter((capability) => other.includes(capability));
168
+ }
169
+ function bodyWithCapabilities(blocks, capabilities) {
170
+ return capabilities.length === BODY_CAPABILITIES.length ? { blocks } : { blocks, capabilities };
171
+ }
172
+ function portableTextSourceSpanPaths(data, blocks, blockPaths) {
173
+ const targets = /* @__PURE__ */ new WeakSet();
174
+ let targetCount = 0;
175
+ for (let i = 0; i < blocks.length; i++) {
176
+ if (blockPaths?.[i]) continue;
177
+ for (const child of blocks[i].children) {
178
+ if (++targetCount > 5e4) return void 0;
179
+ targets.add(child);
180
+ }
181
+ }
182
+ if (targetCount === 0) return void 0;
183
+ const paths = /* @__PURE__ */ new WeakMap();
184
+ const seen = /* @__PURE__ */ new WeakSet();
185
+ const stack = [
186
+ { value: data, path: ["data"], portableChild: false }
187
+ ];
188
+ let count = 0;
189
+ let inspected = 0;
190
+ while (stack.length > 0) {
191
+ const current = stack.pop();
192
+ if (typeof current.value !== "object" || current.value === null) continue;
193
+ if (++count > 5e4 || current.path.length > 64 || seen.has(current.value)) return void 0;
194
+ seen.add(current.value);
195
+ if (current.portableChild && targets.has(current.value) && isPortableTextSpan(current.value)) {
196
+ paths.set(current.value, [...current.path, "text"]);
197
+ }
198
+ if (Array.isArray(current.value)) {
199
+ for (let i = 0; i < current.value.length; i++) {
200
+ if (++inspected > 5e4) return void 0;
201
+ const value = current.value[i];
202
+ if (typeof value === "object" && value !== null) stack.push({ value, path: [...current.path, i], portableChild: current.portableChild });
203
+ }
204
+ } else {
205
+ for (const key in current.value) {
206
+ if (!Object.hasOwn(current.value, key)) continue;
207
+ if (++inspected > 5e4) return void 0;
208
+ const value = current.value[key];
209
+ if (typeof value === "object" && value !== null) stack.push({ value, path: [...current.path, key], portableChild: key === "children" && isPortableTextBlockCandidate(current.value) });
210
+ }
211
+ }
212
+ }
213
+ let mapped = false;
214
+ const result = blocks.map((block, index) => {
215
+ if (blockPaths?.[index]) return null;
216
+ return block.children.map((child) => {
217
+ const path = paths.get(child) ?? null;
218
+ if (path !== null) mapped = true;
219
+ return path;
220
+ });
221
+ });
222
+ return mapped ? result : void 0;
223
+ }
224
+ function extractSelectedContent(data, fields, mappings) {
225
+ if (fields.length === 0 || new Set(fields).size !== fields.length || fields.some((field) => typeof field !== "string" || field.length === 0)) return null;
226
+ const all = ["text", "headings", "paragraphs", "links"];
227
+ let capabilities = all;
228
+ const blocks = [];
229
+ let observed = false;
230
+ for (const field of fields) {
231
+ const value = data[field];
232
+ if (value === void 0) {
233
+ capabilities = [];
234
+ continue;
235
+ }
236
+ observed = true;
237
+ if (value === null) continue;
238
+ const part = extractFirstPortableText({ content: value }, mappings);
239
+ if (part === null) {
240
+ capabilities = [];
241
+ continue;
242
+ }
243
+ blocks.push(...part.blocks);
244
+ capabilities = capabilities.filter((capability) => part.capabilities === void 0 || part.capabilities.includes(capability));
245
+ }
246
+ if (!observed) return null;
247
+ return capabilities.length === all.length ? { blocks } : { blocks, capabilities: [...capabilities] };
248
+ }
249
+ function extractPortableText(data, mappings) {
250
+ const result = extractFirstPortableText(data, mappings);
251
+ if (result === null) return null;
252
+ const candidates = Object.entries(data).filter(
253
+ ([field, value]) => isPortableTextArray(value) || isMixedPortableTextArray(value) || isEmdashBlocksArray(value, field) || (field === "body" || field === "content") && typeof value === "string" && value.trim().length > 0
254
+ );
255
+ const unrecognizedBody = ["body", "content", "blocks"].some((field) => {
256
+ const value = data[field];
257
+ if (value == null || Array.isArray(value) && value.length === 0 || typeof value === "string" && value.trim().length === 0) return false;
258
+ return !candidates.some(([candidate]) => candidate === field);
259
+ });
260
+ return candidates.length > 1 || unrecognizedBody ? { blocks: result.blocks, capabilities: [] } : result;
261
+ }
262
+ function extractFirstPortableText(data, mappings) {
263
+ let foundEmptyBody = false;
80
264
  for (const slug of ["body", "content"]) {
81
265
  const value = data[slug];
82
- if (isPortableTextArray(value)) return value;
83
- if (isEmdashBlocksArray(value, slug)) return blocksToPortableText(value);
266
+ if (Array.isArray(value) && value.length === 0 || typeof value === "string" && value.trim().length === 0) foundEmptyBody = true;
267
+ if (isPortableTextArray(value)) return { blocks: value };
268
+ if (isMixedPortableTextArray(value)) return mapMixedPortableText(value, mappings);
269
+ if (isEmdashBlocksArray(value, slug)) return mapMixedPortableText(value, mappings);
84
270
  if (typeof value === "string" && value.trim().length > 0) {
85
- return [portableTextBlock(value.trim(), `${slug}-text`)];
271
+ return { blocks: [portableTextBlock(value.trim(), `${slug}-text`)], capabilities: ["text"] };
86
272
  }
87
273
  }
88
274
  const blocks = data["blocks"];
89
- if (isPortableTextArray(blocks)) return blocks;
90
- if (isEmdashBlocksArray(blocks, "blocks")) return blocksToPortableText(blocks);
275
+ if (Array.isArray(blocks) && blocks.length === 0) foundEmptyBody = true;
276
+ if (isPortableTextArray(blocks)) return { blocks };
277
+ if (isMixedPortableTextArray(blocks)) return mapMixedPortableText(blocks, mappings);
278
+ if (isEmdashBlocksArray(blocks, "blocks")) return mapMixedPortableText(blocks, mappings);
91
279
  for (const [field, value] of Object.entries(data)) {
92
280
  if (field === "body" || field === "content" || field === "blocks") continue;
93
- if (isPortableTextArray(value)) return value;
94
- if (isEmdashBlocksArray(value, field)) return blocksToPortableText(value);
281
+ if (isPortableTextArray(value)) return { blocks: value };
282
+ if (isMixedPortableTextArray(value)) return mapMixedPortableText(value, mappings);
283
+ if (isEmdashBlocksArray(value, field)) return mapMixedPortableText(value, mappings);
95
284
  }
96
- return null;
285
+ return foundEmptyBody ? { blocks: [] } : null;
97
286
  }
98
287
  function isPortableTextArray(value) {
99
288
  return Array.isArray(value) && value.length > 0 && value.every(
100
- (item) => isRecord(item) && item["_type"] === "block" && Array.isArray(item["children"]) && item["children"].every(
101
- (child) => isRecord(child) && typeof child["text"] === "string"
102
- )
289
+ (item) => isPortableTextBlockCandidate(item) && isWireSafeBlock(item)
103
290
  );
104
291
  }
292
+ function isPortableTextSpan(value) {
293
+ return isRecord(value) && value["_type"] === "span" && typeof value["text"] === "string";
294
+ }
295
+ function isPortableTextBlockCandidate(value) {
296
+ return isRecord(value) && value["_type"] === "block" && Array.isArray(value["children"]);
297
+ }
298
+ function isWireSafeSpan(value) {
299
+ return isPortableTextSpan(value) && isRecord(value) && (value["_key"] == null || typeof value["_key"] === "string") && (value["marks"] === void 0 || Array.isArray(value["marks"]) && value["marks"].every((mark) => typeof mark === "string"));
300
+ }
301
+ function isWireSafeMarkDef(value) {
302
+ return isRecord(value) && typeof value["_key"] === "string" && typeof value["_type"] === "string" && (value["_type"] !== "link" || typeof value["href"] === "string");
303
+ }
304
+ function isWireSafeBlock(block) {
305
+ const level = block["level"];
306
+ return (block["_key"] == null || typeof block["_key"] === "string") && (block["style"] === void 0 || typeof block["style"] === "string" && ["normal", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote"].includes(block["style"])) && (block["listItem"] == null || block["listItem"] === "bullet" || block["listItem"] === "number") && (level == null || typeof level === "number" && Number.isInteger(level) && level >= 0 && level <= 4294967295) && (block["markDefs"] === void 0 || Array.isArray(block["markDefs"]) && block["markDefs"].every(isWireSafeMarkDef)) && block["children"].every(isWireSafeSpan);
307
+ }
308
+ function normalizePortableBlock(block) {
309
+ const normalized = { ...block };
310
+ let incomplete = false;
311
+ if (normalized["_key"] != null && typeof normalized["_key"] !== "string") {
312
+ delete normalized["_key"];
313
+ incomplete = true;
314
+ }
315
+ if (normalized["style"] !== void 0 && (typeof normalized["style"] !== "string" || !["normal", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote"].includes(normalized["style"]))) {
316
+ delete normalized["style"];
317
+ incomplete = true;
318
+ }
319
+ if (normalized["listItem"] != null && normalized["listItem"] !== "bullet" && normalized["listItem"] !== "number") {
320
+ delete normalized["listItem"];
321
+ incomplete = true;
322
+ }
323
+ const level = normalized["level"];
324
+ if (level != null && (typeof level !== "number" || !Number.isInteger(level) || level < 0 || level > 4294967295)) {
325
+ delete normalized["level"];
326
+ incomplete = true;
327
+ }
328
+ if (normalized["markDefs"] !== void 0) {
329
+ if (!Array.isArray(normalized["markDefs"])) {
330
+ delete normalized["markDefs"];
331
+ incomplete = true;
332
+ } else {
333
+ const defs = normalized["markDefs"].filter(isWireSafeMarkDef);
334
+ if (defs.length !== normalized["markDefs"].length) {
335
+ normalized["markDefs"] = defs;
336
+ incomplete = true;
337
+ }
338
+ }
339
+ }
340
+ normalized["children"] = block["children"].map((child) => {
341
+ if (!isPortableTextSpan(child) || !isRecord(child) || isWireSafeSpan(child)) return child;
342
+ const span = { ...child };
343
+ if (span["_key"] != null && typeof span["_key"] !== "string") delete span["_key"];
344
+ if (span["marks"] !== void 0 && (!Array.isArray(span["marks"]) || !span["marks"].every((mark) => typeof mark === "string"))) delete span["marks"];
345
+ incomplete = true;
346
+ return span;
347
+ });
348
+ return { block: normalized, incomplete };
349
+ }
350
+ function isMixedPortableTextArray(value) {
351
+ return Array.isArray(value) && value.some(isPortableTextBlockCandidate);
352
+ }
353
+ function mapMixedPortableText(blocks, mappings, depth = 0) {
354
+ if (depth > 8) return { blocks: [], capabilities: [] };
355
+ const result = [];
356
+ let capabilities = [...BODY_CAPABILITIES];
357
+ for (const block of blocks) {
358
+ if (isPortableTextArray([block])) {
359
+ result.push(block);
360
+ continue;
361
+ }
362
+ if (isPortableTextBlockCandidate(block)) {
363
+ const normalized = normalizePortableBlock(block);
364
+ const projected = projectInlineBlock(normalized.block, mappings);
365
+ result.push(projected.block);
366
+ capabilities = normalized.incomplete ? [] : intersectCapabilities(capabilities, projected.capabilities);
367
+ continue;
368
+ }
369
+ if (!isRecord(block) || typeof block["_type"] !== "string" || block["_type"] === "block") {
370
+ capabilities = [];
371
+ continue;
372
+ }
373
+ const mapped = mapCustomBlocks([block], mappings, depth + 1);
374
+ result.push(...mapped.blocks);
375
+ capabilities = intersectCapabilities(capabilities, mapped.capabilities);
376
+ }
377
+ return bodyWithCapabilities(result, capabilities);
378
+ }
379
+ function projectInlineBlock(block, mappings) {
380
+ const children = [];
381
+ const defs = Array.isArray(block["markDefs"]) ? [...block["markDefs"]] : [];
382
+ const keys = new Set(defs.flatMap((def) => isRecord(def) && typeof def["_key"] === "string" ? [def["_key"]] : []));
383
+ let sequence = 0;
384
+ let capabilities = block["markDefs"] === void 0 || Array.isArray(block["markDefs"]) ? [...BODY_CAPABILITIES] : [];
385
+ for (const child of block["children"]) {
386
+ if (isRecord(child) && Array.isArray(child["marks"])) {
387
+ for (const mark of child["marks"]) if (typeof mark === "string") keys.add(mark);
388
+ }
389
+ }
390
+ for (const child of block["children"]) {
391
+ if (isPortableTextSpan(child)) {
392
+ children.push(child);
393
+ continue;
394
+ }
395
+ const type = isRecord(child) ? child["_type"] : void 0;
396
+ const mapping = typeof type === "string" && mappings && Object.hasOwn(mappings, type) ? mappings[type] : void 0;
397
+ if (!isRecord(child) || !isRecord(mapping) || !Array.isArray(mapping["inline"])) {
398
+ capabilities = [];
399
+ continue;
400
+ }
401
+ const declared = mapping["capabilities"];
402
+ if (declared !== void 0 && (!Array.isArray(declared) || declared.some((value) => typeof value !== "string" || !BODY_CAPABILITIES.includes(value)) || new Set(declared).size !== declared.length)) {
403
+ capabilities = [];
404
+ continue;
405
+ }
406
+ const newChildren = [];
407
+ const newDefs = [];
408
+ let valid = true;
409
+ for (const field of mapping["inline"]) {
410
+ if (!isRecord(field) || field["kind"] !== "text" && field["kind"] !== "link" || typeof field["field"] !== "string" || !field["field"] || field["optional"] !== void 0 && typeof field["optional"] !== "boolean" || field["kind"] === "link" && (typeof field["hrefField"] !== "string" || !field["hrefField"])) {
411
+ valid = false;
412
+ break;
413
+ }
414
+ const value = child[field["field"]];
415
+ const href = field["kind"] === "link" && typeof field["hrefField"] === "string" && field["hrefField"] ? child[field["hrefField"]] : void 0;
416
+ if ((value == null || value === "") && field["optional"] === true && (field["kind"] !== "link" || href == null || href === "")) continue;
417
+ if (typeof value !== "string" || field["kind"] === "link" && (typeof href !== "string" || !href)) {
418
+ valid = false;
419
+ break;
420
+ }
421
+ if (field["kind"] === "text") newChildren.push({ _type: "span", text: value, marks: [] });
422
+ else {
423
+ let key;
424
+ do {
425
+ key = `aexeo-inline-${sequence++}`;
426
+ } while (keys.has(key));
427
+ keys.add(key);
428
+ newDefs.push({ _key: key, _type: "link", href });
429
+ newChildren.push({ _type: "span", text: value, marks: [key] });
430
+ }
431
+ }
432
+ if (!valid) {
433
+ capabilities = [];
434
+ continue;
435
+ }
436
+ children.push(...newChildren);
437
+ defs.push(...newDefs);
438
+ if (Array.isArray(declared)) capabilities = intersectCapabilities(capabilities, declared);
439
+ }
440
+ return { block: { ...block, children, markDefs: defs }, capabilities };
441
+ }
105
442
  function isEmdashBlocksArray(value, field) {
106
443
  if (!Array.isArray(value) || value.length === 0) return false;
107
444
  return value.every(
108
- (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks")
445
+ (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks" || field === "body" || field === "content")
109
446
  );
110
447
  }
448
+ function mapCustomBlocks(blocks, mappings, depth = 0) {
449
+ if (depth > 8) return { blocks: [], capabilities: [] };
450
+ const unavailable = () => ({ blocks: blocksToPortableText(blocks), capabilities: [] });
451
+ const result = [];
452
+ let capabilities = [...BODY_CAPABILITIES];
453
+ const mapFields = (record, fields, depth2) => {
454
+ if (depth2 > 8 || fields.length === 0) return false;
455
+ for (const entry of fields) {
456
+ if (!entry || typeof entry.field !== "string" || !entry.field || !["heading", "paragraph", "richText", "link", "repeat"].includes(entry.kind)) return false;
457
+ if (entry.kind === "heading" && entry.level !== void 0 && ![1, 2, 3, 4, 5, 6].includes(entry.level)) return false;
458
+ if (entry.kind === "link" && (typeof entry.hrefField !== "string" || !entry.hrefField)) return false;
459
+ if (entry.kind === "repeat" && (!Array.isArray(entry.fields) || entry.fields.length === 0)) return false;
460
+ const value = record[entry.field];
461
+ if ((value === void 0 || value === null || value === "") && entry.optional) {
462
+ if (entry.kind === "link" && record[entry.hrefField] != null && record[entry.hrefField] !== "") return false;
463
+ continue;
464
+ }
465
+ if (entry.kind === "richText") {
466
+ if (!Array.isArray(value)) return false;
467
+ if (value.length === 0 || isPortableTextArray(value)) {
468
+ result.push(...value);
469
+ } else if (isMixedPortableTextArray(value) || isEmdashBlocksArray(value, "blocks")) {
470
+ const nested = mapMixedPortableText(value, mappings, depth2 + 1);
471
+ result.push(...nested.blocks);
472
+ capabilities = intersectCapabilities(capabilities, nested.capabilities);
473
+ } else {
474
+ return false;
475
+ }
476
+ } else if (entry.kind === "heading" || entry.kind === "paragraph") {
477
+ if (typeof value !== "string") return false;
478
+ result.push({ ...portableTextBlock(value, ""), style: entry.kind === "heading" ? `h${entry.level ?? 2}` : "normal" });
479
+ } else if (entry.kind === "link") {
480
+ const href = record[entry.hrefField];
481
+ if (typeof value !== "string" || typeof href !== "string" || !href) return false;
482
+ result.push({
483
+ _type: "block",
484
+ children: [{ _type: "span", text: value, marks: ["link"] }],
485
+ markDefs: [{ _key: "link", _type: "link", href }]
486
+ });
487
+ } else if (entry.kind === "repeat") {
488
+ if (!Array.isArray(value) || !Array.isArray(entry.fields)) return false;
489
+ for (const item of value) {
490
+ if (!isRecord(item) || !mapFields(item, entry.fields, depth2 + 1)) return false;
491
+ }
492
+ } else {
493
+ return false;
494
+ }
495
+ }
496
+ return true;
497
+ };
498
+ for (const block of blocks) {
499
+ const mapping = mappings?.[String(block["_type"])];
500
+ let fields;
501
+ if (Array.isArray(mapping)) {
502
+ fields = mapping;
503
+ } else if (isRecord(mapping) && Array.isArray(mapping["fields"]) && Array.isArray(mapping["capabilities"])) {
504
+ const declared = mapping["capabilities"];
505
+ if (declared.some((capability) => typeof capability !== "string" || !BODY_CAPABILITIES.includes(capability)) || new Set(declared).size !== declared.length) return unavailable();
506
+ fields = mapping["fields"];
507
+ capabilities = intersectCapabilities(capabilities, declared);
508
+ } else return unavailable();
509
+ if (fields.length === 0) continue;
510
+ if (!mapFields(block, fields, depth)) return unavailable();
511
+ }
512
+ return bodyWithCapabilities(result, capabilities);
513
+ }
111
514
  function blocksToPortableText(blocks) {
112
515
  const paragraphs = [];
113
516
  for (const block of blocks) {
@@ -120,20 +523,49 @@ function blocksToPortableText(blocks) {
120
523
  }
121
524
  return paragraphs;
122
525
  }
526
+ var FALLBACK_MAX_DEPTH = 64;
527
+ var FALLBACK_MAX_NODES = 5e4;
528
+ var FALLBACK_MAX_CHARS = 1048576;
123
529
  function collectText(value, key, out) {
124
- if (typeof value === "string") {
125
- if (!isStructuralField(key) && value.trim().length > 0) out.push(value);
126
- return;
127
- }
128
- if (Array.isArray(value)) {
129
- for (const item of value) collectText(item, key, out);
130
- return;
131
- }
132
- if (!isRecord(value)) return;
133
- for (const [childKey, childValue] of Object.entries(value)) {
134
- if (isStructuralField(childKey)) continue;
135
- collectText(childValue, childKey, out);
136
- }
530
+ const active = /* @__PURE__ */ new WeakSet();
531
+ let nodes = 0;
532
+ let remaining = FALLBACK_MAX_CHARS;
533
+ const visit = (current, field, depth) => {
534
+ if (depth > FALLBACK_MAX_DEPTH || nodes >= FALLBACK_MAX_NODES || remaining <= 0) return;
535
+ nodes++;
536
+ if (typeof current === "string") {
537
+ if (!isStructuralField(field)) {
538
+ const separator = out.length > 0 ? 1 : 0;
539
+ const limit = Math.max(0, remaining - separator);
540
+ let text = current.slice(0, limit);
541
+ if (limit < current.length && /[\uD800-\uDBFF]/.test(text.at(-1) ?? "") && /[\uDC00-\uDFFF]/.test(current[limit] ?? "")) text = text.slice(0, -1);
542
+ text = text.trim();
543
+ if (text.length > 0) {
544
+ out.push(text);
545
+ remaining -= text.length + separator;
546
+ }
547
+ }
548
+ return;
549
+ }
550
+ if (!Array.isArray(current) && !isRecord(current)) return;
551
+ if (active.has(current)) return;
552
+ active.add(current);
553
+ if (Array.isArray(current)) {
554
+ for (const item of current) {
555
+ if (nodes >= FALLBACK_MAX_NODES || remaining <= 0) break;
556
+ visit(item, field, depth + 1);
557
+ }
558
+ } else {
559
+ for (const childKey in current) {
560
+ if (nodes >= FALLBACK_MAX_NODES || remaining <= 0) break;
561
+ if (Object.hasOwn(current, childKey) && !isStructuralField(childKey)) {
562
+ visit(current[childKey], childKey, depth + 1);
563
+ }
564
+ }
565
+ }
566
+ active.delete(current);
567
+ };
568
+ visit(value, key, 0);
137
569
  }
138
570
  var STRUCTURAL_FIELDS = /* @__PURE__ */ new Set([
139
571
  "_key",
@@ -191,11 +623,16 @@ function isRecord(value) {
191
623
  return typeof value === "object" && value !== null && !Array.isArray(value);
192
624
  }
193
625
 
626
+ // src/version.ts
627
+ var PACKAGE_VERSION = "0.9.1";
628
+
194
629
  // src/sidecar.ts
195
- async function evaluateViaSidecar(http, config, documents) {
630
+ async function evaluateViaSidecar(http, config, documents, collections) {
196
631
  const body = {
197
- documents
632
+ documents,
633
+ responseFormat: 2
198
634
  };
635
+ if (collections !== void 0) body.collections = collections;
199
636
  if (config.configJson !== void 0) {
200
637
  body.configJson = config.configJson;
201
638
  }
@@ -228,6 +665,12 @@ async function evaluateViaSidecar(http, config, documents) {
228
665
  }
229
666
  return { ok: false, reason: "server_error", detail };
230
667
  }
668
+ const workerVersion = response.headers?.get("x-aexeo-version") ?? "unknown";
669
+ if (workerVersion !== PACKAGE_VERSION) {
670
+ config.log?.warn(
671
+ `Aexeo sidecar is version ${workerVersion} but the plugin is ${PACKAGE_VERSION}; redeploy the Worker so both run the same rules`
672
+ );
673
+ }
231
674
  let raw;
232
675
  try {
233
676
  raw = await response.text();
@@ -236,20 +679,36 @@ async function evaluateViaSidecar(http, config, documents) {
236
679
  return { ok: false, reason: "invalid_response", detail };
237
680
  }
238
681
  try {
239
- const findings = JSON.parse(raw);
240
- if (!Array.isArray(findings)) {
241
- return {
242
- ok: false,
243
- reason: "invalid_response",
244
- detail: "sidecar returned non-array body"
245
- };
682
+ const value = JSON.parse(raw);
683
+ if (Array.isArray(value)) return { ok: true, findings: value };
684
+ if (!value || typeof value !== "object" || !("findings" in value) || !Array.isArray(value.findings) || !("assessments" in value) || !value.assessments || typeof value.assessments !== "object" || Array.isArray(value.assessments)) {
685
+ return { ok: false, reason: "invalid_response", detail: "sidecar returned invalid assessment envelope" };
246
686
  }
247
- return { ok: true, findings };
687
+ for (const finding of value.findings) {
688
+ if (finding && typeof finding === "object" && "aexeo_source_location" in finding && !validCounts(finding.aexeo_source_location, ["block_index", "span_index", "utf16_offset"])) {
689
+ return { ok: false, reason: "invalid_response", detail: "sidecar returned invalid source location" };
690
+ }
691
+ }
692
+ for (const assessment of Object.values(value.assessments)) {
693
+ if (assessment && typeof assessment === "object" && "source_mapping" in assessment && (!validCounts(assessment.source_mapping, ["eligible", "mapped", "unmapped"]) || assessment.source_mapping.mapped + assessment.source_mapping.unmapped !== assessment.source_mapping.eligible)) {
694
+ return { ok: false, reason: "invalid_response", detail: "sidecar returned invalid source mapping coverage" };
695
+ }
696
+ if (!assessment || typeof assessment !== "object" || !("status" in assessment) || assessment.status !== "evaluated" || !("preset" in assessment) || typeof assessment.preset !== "string" || !("gates" in assessment) || !Array.isArray(assessment.gates) || assessment.gates.some((gate) => !gate || typeof gate !== "object" || !("gate_id" in gate) || typeof gate.gate_id !== "string" || !("status" in gate) || !["passed", "failed", "not_checked"].includes(String(gate.status)) || !("blocking" in gate) || typeof gate.blocking !== "boolean" || !("scope" in gate) || !["document", "collection", "site"].includes(String(gate.scope)) || !("required_capability" in gate) || typeof gate.required_capability !== "string" || !("reason" in gate) || gate.reason !== null && typeof gate.reason !== "string" || "measurements" in gate && (gate.measurements === null || typeof gate.measurements !== "object" || Array.isArray(gate.measurements) || Object.values(gate.measurements).some((v) => typeof v !== "number" || !Number.isFinite(v))))) {
697
+ return { ok: false, reason: "invalid_response", detail: "sidecar returned invalid gate assessment" };
698
+ }
699
+ }
700
+ return { ok: true, findings: value.findings, assessments: value.assessments };
248
701
  } catch (err) {
249
702
  const detail = err instanceof Error ? err.message : String(err);
250
703
  return { ok: false, reason: "invalid_response", detail };
251
704
  }
252
705
  }
706
+ function validCounts(value, keys) {
707
+ return value !== null && typeof value === "object" && !Array.isArray(value) && keys.every((key) => {
708
+ const count = value[key];
709
+ return typeof count === "number" && Number.isSafeInteger(count) && count >= 0;
710
+ });
711
+ }
253
712
  function trimTrailingSlash(url) {
254
713
  return url.endsWith("/") ? url.slice(0, -1) : url;
255
714
  }
@@ -266,7 +725,28 @@ function normalizeHost(evaluatorHost) {
266
725
  var CONFIG_URL_KEY = "config:evaluator_url";
267
726
  var CONFIG_TOKEN_KEY = "config:eval_token";
268
727
  var SETTINGS_TOKEN_KEY = "eval_token";
728
+ var SETTINGS_BLOCK_MAPPINGS_KEY = "block_mappings";
729
+ var SETTINGS_CONTENT_FIELDS_KEY = "content_fields_by_collection";
730
+ var SETTINGS_DESCRIPTION_FIELDS_KEY = "description_fields_by_collection";
269
731
  var PLUGIN_SETTINGS_SCHEMA = {
732
+ [SETTINGS_CONTENT_FIELDS_KEY]: {
733
+ type: "string",
734
+ label: "Content fields by collection (JSON)",
735
+ description: "Ordered rendered body fields per collection.",
736
+ multiline: true
737
+ },
738
+ [SETTINGS_DESCRIPTION_FIELDS_KEY]: {
739
+ type: "string",
740
+ label: "Description fields by collection (JSON)",
741
+ description: "Ordered renderer description fallback fields per collection.",
742
+ multiline: true
743
+ },
744
+ [SETTINGS_BLOCK_MAPPINGS_KEY]: {
745
+ type: "string",
746
+ label: "Custom block mappings (JSON)",
747
+ description: "Ordered mappings for the rendered fields of each custom block type.",
748
+ multiline: true
749
+ },
270
750
  [SETTINGS_TOKEN_KEY]: {
271
751
  type: "secret",
272
752
  label: "EVAL_TOKEN",
@@ -285,10 +765,21 @@ async function readSidecarConfig(store) {
285
765
  return { url, token };
286
766
  }
287
767
  async function writeSidecarConfig(store, config) {
288
- await store.kv.set(CONFIG_URL_KEY, config.url);
289
768
  await writeStoredToken(store, config.token);
769
+ await store.kv.set(CONFIG_URL_KEY, config.url);
770
+ }
771
+ async function isTokenStoredInPlaintext(store) {
772
+ return await readLegacyToken(store) !== null;
773
+ }
774
+ async function readLegacyToken(store) {
775
+ const legacy = await store.kv.get(CONFIG_TOKEN_KEY);
776
+ return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
290
777
  }
291
778
  async function readStoredToken(store) {
779
+ const legacy = await readLegacyToken(store);
780
+ if (legacy !== null) {
781
+ return legacy;
782
+ }
292
783
  const { settings } = store;
293
784
  if (settings !== void 0) {
294
785
  try {
@@ -298,32 +789,131 @@ async function readStoredToken(store) {
298
789
  }
299
790
  } catch (err) {
300
791
  store.log?.warn?.(
301
- `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings, falling back to KV: ${describeError(err, "")}`
792
+ `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings: ${describeError(err, "")}`
302
793
  );
303
794
  }
304
795
  }
305
- const legacy = await store.kv.get(CONFIG_TOKEN_KEY);
306
- return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
796
+ return null;
307
797
  }
798
+ var TokenStorageError = class extends Error {
799
+ };
308
800
  async function writeStoredToken(store, token) {
309
801
  const { settings } = store;
310
- if (settings !== void 0) {
311
- try {
312
- await settings.set(SETTINGS_TOKEN_KEY, token);
313
- await store.kv.delete(CONFIG_TOKEN_KEY);
314
- return;
315
- } catch (err) {
316
- store.log?.warn?.(
317
- `aexeo: could not store the EVAL_TOKEN encrypted in plugin settings; it will be written to plugin KV in plaintext instead. Set EMDASH_ENCRYPTION_KEY on the emdash host to fix this. Detail: ${describeError(err, token)}`
318
- );
319
- }
802
+ if (settings === void 0) {
803
+ throw new TokenStorageError(
804
+ "This emdash host does not provide encrypted plugin settings, so EVAL_TOKEN was not saved. Upgrade emdash."
805
+ );
806
+ }
807
+ try {
808
+ await settings.set(SETTINGS_TOKEN_KEY, token);
809
+ await store.kv.delete(CONFIG_TOKEN_KEY);
810
+ } catch (err) {
811
+ throw new TokenStorageError(
812
+ `EVAL_TOKEN was not saved because the host could not store it encrypted. Set EMDASH_ENCRYPTION_KEY on the emdash host and try again. Detail: ${describeError(err, token)}`
813
+ );
320
814
  }
321
- await store.kv.set(CONFIG_TOKEN_KEY, token);
322
815
  }
323
816
  function describeError(err, secret) {
324
817
  const raw = err instanceof Error ? err.message : String(err);
325
818
  return secret.length === 0 ? raw : raw.split(secret).join("[redacted]");
326
819
  }
820
+ function recordContentCoverage(ctx, documents) {
821
+ const counts = { documents: documents.length, complete: 0, partial: 0, unavailable: 0 };
822
+ for (const document of documents) {
823
+ counts[cmsBodyCoverage(document)]++;
824
+ }
825
+ try {
826
+ ctx.log?.info?.("aexeo.content_coverage", counts);
827
+ ctx.log?.info?.("aexeo.page_coverage", { documents: documents.length, rendered_page: "not_observed", generated_metadata: "not_observed" });
828
+ const observed = documents.filter((document) => document.description_source_observed === true).length;
829
+ const withValue = documents.filter((document) => document.description_source_observed === true && !!document.description).length;
830
+ ctx.log?.info?.("aexeo.description_coverage", { documents: documents.length, observed, with_value: withValue, unverified: documents.length - observed });
831
+ } catch {
832
+ }
833
+ }
834
+ function recordAssessmentCoverage(ctx, result, documents) {
835
+ let passed = 0, failed = 0, notChecked = 0, mappingReports = 0, mappingEligible = 0, mappingMapped = 0, mappingUnmapped = 0;
836
+ if (result.ok) for (const assessment of Object.values(result.assessments ?? {})) {
837
+ if (assessment.status !== "evaluated") continue;
838
+ if (assessment.source_mapping) {
839
+ mappingReports++;
840
+ mappingEligible += assessment.source_mapping.eligible;
841
+ mappingMapped += assessment.source_mapping.mapped;
842
+ mappingUnmapped += assessment.source_mapping.unmapped;
843
+ }
844
+ for (const gate of assessment.gates) {
845
+ if (gate.status === "passed") passed++;
846
+ else if (gate.status === "failed") failed++;
847
+ else notChecked++;
848
+ }
849
+ }
850
+ let assessedDocuments = 0, missingDocuments = 0, errorDocuments = 0;
851
+ if (result.ok) for (const document of documents) {
852
+ const assessment = result.assessments?.[document.route];
853
+ if (assessment === void 0) missingDocuments++;
854
+ else if (assessment.status === "error") errorDocuments++;
855
+ else assessedDocuments++;
856
+ }
857
+ const collidingFindings = result.ok ? result.findings.filter((finding) => finding.aexeo_origin === "content_preset" && (SITE_GRAPH_RULE_IDS.has(finding.rule_id) || !isLegacyCmsObservableFinding(finding))).length : 0;
858
+ try {
859
+ ctx.log?.info?.("aexeo.preset_coverage", { status: result.ok ? "evaluated" : "error", documents: documents.length, assessed_documents: assessedDocuments, missing_assessments: missingDocuments, assessment_errors: errorDocuments, passed, failed, not_checked: notChecked, colliding_findings: collidingFindings, mapping_reports: mappingReports, mapping_eligible: mappingEligible, mapping_mapped: mappingMapped, mapping_unmapped: mappingUnmapped });
860
+ } catch {
861
+ }
862
+ const completeByRoute = new Map(documents.map((document) => [document.route, hasCompleteBody(document)]));
863
+ let withheld = 0, incompleteBodyWithheld = 0;
864
+ if (result.ok) for (const finding of result.findings) {
865
+ if (finding.aexeo_origin === "content_preset") continue;
866
+ if (!isLegacyCmsObservableFinding(finding)) withheld++;
867
+ else if (!isLegacyCmsObservableFinding(finding, completeByRoute.get(finding.path) ?? false)) incompleteBodyWithheld++;
868
+ }
869
+ try {
870
+ ctx.log?.info?.("aexeo.cms_finding_coverage", { status: result.ok ? "evaluated" : "error", rendered_findings_withheld: withheld, incomplete_body_findings_withheld: incompleteBodyWithheld });
871
+ } catch {
872
+ }
873
+ }
874
+ async function handleAfterDelete(event, ctx) {
875
+ for (const entry of await ctx.kv.list("document:")) {
876
+ const stored = toStoredDocument(entry.value);
877
+ if (stored !== null && stored.meta.id === event.id && stored.meta.collection === event.collection) {
878
+ await ctx.kv.delete(entry.key);
879
+ await ctx.kv.delete(findingsKey(stored.document.route));
880
+ }
881
+ }
882
+ }
883
+ async function readSandboxBlockMappings(ctx) {
884
+ if (!ctx.settings) return void 0;
885
+ try {
886
+ const raw = await ctx.settings.get(SETTINGS_BLOCK_MAPPINGS_KEY);
887
+ if (raw === null || raw === "") return void 0;
888
+ if (typeof raw !== "string" || raw.length > 65536) throw new Error("invalid mapping size or type");
889
+ const value = JSON.parse(raw);
890
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("mapping must be an object");
891
+ return value;
892
+ } catch {
893
+ ctx.log?.warn?.("Aexeo custom block mappings unavailable; custom content remains unverified");
894
+ return void 0;
895
+ }
896
+ }
897
+ async function readSandboxFields(ctx, key, warning) {
898
+ if (!ctx.settings) return { fields: void 0, unavailable: false };
899
+ try {
900
+ const raw = await ctx.settings.get(key);
901
+ if (raw === null || raw === "") return { fields: void 0, unavailable: false };
902
+ if (typeof raw !== "string" || raw.length > 65536) throw new Error("invalid selection size or type");
903
+ const value = JSON.parse(raw);
904
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !Object.values(value).every((fields) => Array.isArray(fields) && fields.length > 0 && fields.every((field) => typeof field === "string" && field.length > 0))) throw new Error("invalid selection");
905
+ return { fields: value, unavailable: false };
906
+ } catch {
907
+ ctx.log?.warn?.(warning);
908
+ return { fields: void 0, unavailable: true };
909
+ }
910
+ }
911
+ function readSandboxContentFieldState(ctx) {
912
+ return readSandboxFields(ctx, SETTINGS_CONTENT_FIELDS_KEY, "Aexeo content field selection unavailable; body coverage remains unverified");
913
+ }
914
+ async function readSandboxDescriptionFields(ctx) {
915
+ return (await readSandboxFields(ctx, SETTINGS_DESCRIPTION_FIELDS_KEY, "Aexeo description field selection unavailable; description remains unverified")).fields;
916
+ }
327
917
  async function handleAfterSave(event, ctx) {
328
918
  const runtime = await readSidecarConfig(ctx);
329
919
  if (runtime === null) {
@@ -332,36 +922,131 @@ async function handleAfterSave(event, ctx) {
332
922
  );
333
923
  return;
334
924
  }
925
+ const [blockMappings, fields, descriptions] = await Promise.all([readSandboxBlockMappings(ctx), readSandboxContentFieldState(ctx), readSandboxDescriptionFields(ctx)]);
335
926
  await handleAfterSaveConfigured(
336
927
  event,
337
928
  ctx,
338
- (documents) => evaluateViaSidecar(
929
+ (documents, context) => evaluateViaSidecar(
339
930
  ctx.http,
340
- { url: runtime.url, authToken: runtime.token },
341
- documents
342
- )
931
+ { url: runtime.url, authToken: runtime.token, log: ctx.log },
932
+ documents,
933
+ context?.collections
934
+ ),
935
+ void 0,
936
+ blockMappings,
937
+ fields.fields?.[event.content.type],
938
+ descriptions?.[event.content.type],
939
+ fields.unavailable
343
940
  );
344
941
  }
345
- async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter) {
346
- const adapted = await adaptContentItemWithContext(
347
- event.content,
348
- ctx.content,
349
- ctx.site?.locale
942
+ var SITE_GRAPH_RULE_IDS = /* @__PURE__ */ new Set([
943
+ "LNK001",
944
+ // internal link target missing from the site
945
+ "LNK002",
946
+ // no inbound links
947
+ "LNK003",
948
+ // weak anchor, checked only when the target route exists
949
+ "LNK004",
950
+ // too few inbound links
951
+ "SEO009",
952
+ // hreflang alternate target missing from the site
953
+ "SEO012",
954
+ // hreflang alternate does not link back
955
+ "SEO016",
956
+ // canonical points at another crawlable route
957
+ "SEO017",
958
+ // title and description shared with another route
959
+ "CNT005"
960
+ // visible text duplicated on another route
961
+ ]);
962
+ function isSiteGraphFinding(finding) {
963
+ return finding.aexeo_origin !== "content_preset" && SITE_GRAPH_RULE_IDS.has(finding.rule_id);
964
+ }
965
+ function withCmsSourceLocation(finding, stored) {
966
+ if (finding.aexeo_cms_location !== void 0) {
967
+ finding = { ...finding };
968
+ delete finding.aexeo_cms_location;
969
+ }
970
+ const location = finding.aexeo_source_location;
971
+ if (finding.aexeo_origin !== "content_preset" || !location || !stored) return finding;
972
+ const blockPath = stored.meta.bodySourcePaths?.[location.block_index];
973
+ const spanPath = stored.meta.bodySourceSpanPaths?.[location.block_index]?.[location.span_index];
974
+ const path = blockPath ? [...blockPath, "children", location.span_index, "text"] : spanPath;
975
+ const text = stored.document.body?.[location.block_index]?.children[location.span_index]?.text;
976
+ const offset = location.utf16_offset;
977
+ if (!path || typeof text !== "string" || !Number.isSafeInteger(offset) || offset < 0 || offset > text.length || offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]) && /[\uDC00-\uDFFF]/.test(text[offset])) return finding;
978
+ return { ...finding, aexeo_cms_location: { path, utf16_offset: offset } };
979
+ }
980
+ function countCmsLocations(counts, findings) {
981
+ for (const finding of findings) {
982
+ if (finding.aexeo_origin !== "content_preset" || finding.aexeo_source_location === void 0) continue;
983
+ counts.eligible++;
984
+ if (finding.aexeo_cms_location !== void 0) counts.mapped++;
985
+ }
986
+ }
987
+ function recordCmsLocationCoverage(ctx, counts) {
988
+ try {
989
+ ctx.log?.info?.("aexeo.cms_location_coverage", { ...counts, unmapped: counts.eligible - counts.mapped });
990
+ } catch {
991
+ }
992
+ }
993
+ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter, blockMappings, contentFields, descriptionFields, contentFieldsUnavailable = false) {
994
+ const prior = (await readAllStoredDocuments(ctx.kv)).filter(
995
+ (stored2) => stored2.meta.id === event.content.id && stored2.meta.collection === event.content.type
350
996
  );
997
+ let adapted;
998
+ try {
999
+ adapted = await adaptContentItemWithContext(
1000
+ event.content,
1001
+ ctx.content,
1002
+ ctx.site?.locale,
1003
+ blockMappings,
1004
+ contentFields,
1005
+ descriptionFields
1006
+ );
1007
+ } catch {
1008
+ try {
1009
+ ctx.log?.info?.("aexeo.adaptation_coverage", { failed: 1 });
1010
+ } catch {
1011
+ }
1012
+ ctx.log?.error?.("Aexeo afterSave adaptation failed");
1013
+ const previous = prior.length === 1 ? prior[0] : void 0;
1014
+ if (previous !== void 0) await markAdaptationUnavailable(ctx, event.content, previous);
1015
+ return;
1016
+ }
351
1017
  const document = adapted.document;
1018
+ if (contentFieldsUnavailable && document.body !== void 0) document.body_capabilities = [];
1019
+ recordContentCoverage(ctx, [document]);
352
1020
  const { kv, log } = ctx;
353
1021
  const stored = { document, meta: adapted.meta };
354
- await persistDocument(ctx, stored);
355
- const result = await evaluator([document]);
1022
+ if (!await persistDocument(ctx, stored)) {
1023
+ log?.info?.(`Aexeo afterSave skipped: ${document.route} has a newer save`);
1024
+ return;
1025
+ }
1026
+ const result = await evaluateSafely(evaluator, [document], { collections: [adapted.meta.collection] });
1027
+ recordAssessmentCoverage(ctx, result, [document]);
356
1028
  if (!result.ok) {
357
1029
  log?.error?.(`afterSave evaluator failure (${result.reason})`, {
358
1030
  detail: result.detail
359
1031
  });
1032
+ if (!await isSuperseded(kv, stored)) {
1033
+ await kv.set(findingsKey(document.route), {
1034
+ route: document.route,
1035
+ findings: [],
1036
+ assessment: { status: "error", reason: result.reason, detail: result.detail }
1037
+ });
1038
+ }
360
1039
  return;
361
1040
  }
362
- const pageFindings = result.findings.filter(
363
- (finding) => finding.scope === "page"
1041
+ const storedGraphFindings = (await readFindings(kv, document.route)).filter(
1042
+ (finding) => isSiteGraphFinding(finding) && isCmsObservableFinding(finding, hasCompleteBody(document))
364
1043
  );
1044
+ const pageFindings = [
1045
+ ...result.findings.filter(
1046
+ (finding) => finding.scope === "page" && !isSiteGraphFinding(finding) && isCmsObservableFinding(finding, hasCompleteBody(document))
1047
+ ).map((finding) => withCmsSourceLocation(finding, stored)),
1048
+ ...storedGraphFindings
1049
+ ];
365
1050
  const filtered = suppressionFilter === void 0 ? pageFindings : suppressionFilter.apply(
366
1051
  {
367
1052
  route: document.route,
@@ -370,12 +1055,51 @@ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilte
370
1055
  },
371
1056
  pageFindings
372
1057
  );
1058
+ if (await isSuperseded(kv, stored)) {
1059
+ log?.info?.(`Aexeo afterSave discarded: ${document.route} changed while evaluating`);
1060
+ return;
1061
+ }
1062
+ const cmsLocationCounts = { eligible: 0, mapped: 0 };
1063
+ countCmsLocations(cmsLocationCounts, filtered);
1064
+ recordCmsLocationCoverage(ctx, cmsLocationCounts);
373
1065
  await kv.set(findingsKey(document.route), {
374
1066
  route: document.route,
375
- findings: filtered
1067
+ findings: filtered,
1068
+ body_coverage: cmsBodyCoverage(document),
1069
+ ...result.assessments?.[document.route] === void 0 ? {} : { assessment: result.assessments[document.route] }
376
1070
  });
377
1071
  }
378
1072
  var DEFAULT_COLLECTIONS = ["posts", "pages"];
1073
+ async function markAdaptationUnavailable(ctx, item, previous) {
1074
+ if (isOlderRevision(item.updatedAt, previous.meta.updatedAt) || await isSuperseded(ctx.kv, previous)) return;
1075
+ const unavailable = {
1076
+ document: { route: previous.document.route, title: previous.document.title },
1077
+ meta: {
1078
+ id: item.id,
1079
+ collection: item.type,
1080
+ slug: item.slug,
1081
+ status: item.status,
1082
+ title: previous.meta.title,
1083
+ publicUrl: null,
1084
+ adaptationFailed: true,
1085
+ ...item.updatedAt === void 0 ? {} : { updatedAt: item.updatedAt }
1086
+ }
1087
+ };
1088
+ if (!await persistDocument(ctx, unavailable) || await isSuperseded(ctx.kv, unavailable)) return;
1089
+ recordContentCoverage(ctx, [unavailable.document]);
1090
+ await ctx.kv.set(findingsKey(unavailable.document.route), {
1091
+ route: unavailable.document.route,
1092
+ findings: [],
1093
+ assessment: { status: "error", reason: "adaptation_failed", detail: "The CMS record could not be adapted." }
1094
+ });
1095
+ }
1096
+ async function evaluateSafely(evaluator, documents, context) {
1097
+ try {
1098
+ return await evaluator(documents, context);
1099
+ } catch {
1100
+ return { ok: false, reason: "evaluator_exception", detail: "The evaluator failed unexpectedly." };
1101
+ }
1102
+ }
379
1103
  async function evaluateAndPersistAll(ctx, options) {
380
1104
  const collections = options.collections ?? DEFAULT_COLLECTIONS;
381
1105
  const suppressionFilter = options.suppressionFilter;
@@ -386,11 +1110,15 @@ async function evaluateAndPersistAll(ctx, options) {
386
1110
  totalFindings: 0,
387
1111
  errors: []
388
1112
  };
1113
+ const storedAtStart = await readAllStoredDocuments(kv);
389
1114
  const documents = [];
1115
+ const documentCollections = [];
390
1116
  const adaptedByRoute = /* @__PURE__ */ new Map();
391
1117
  const documentRoutes = /* @__PURE__ */ new Set();
1118
+ const adaptationFailures = [];
392
1119
  for (const collection of collections) {
393
1120
  let cursor;
1121
+ const seenCursors = /* @__PURE__ */ new Set();
394
1122
  do {
395
1123
  try {
396
1124
  const page = await ctx.content.list(collection, {
@@ -398,19 +1126,35 @@ async function evaluateAndPersistAll(ctx, options) {
398
1126
  ...cursor === void 0 ? {} : { cursor }
399
1127
  });
400
1128
  for (const item of page.items) {
401
- const adapted = await adaptContentItemWithContext(
402
- item,
403
- ctx.content,
404
- ctx.site?.locale
405
- );
406
- documents.push(adapted.document);
407
- documentRoutes.add(adapted.document.route);
408
- adaptedByRoute.set(adapted.document.route, {
409
- document: adapted.document,
410
- meta: adapted.meta
411
- });
1129
+ try {
1130
+ const adapted = await adaptContentItemWithContext(
1131
+ item,
1132
+ ctx.content,
1133
+ ctx.site?.locale,
1134
+ options.blockMappings,
1135
+ options.contentFieldsByCollection?.[collection],
1136
+ options.descriptionFieldsByCollection?.[collection]
1137
+ );
1138
+ if (options.contentFieldsUnavailable && adapted.document.body !== void 0) adapted.document.body_capabilities = [];
1139
+ documents.push(adapted.document);
1140
+ documentCollections.push(adapted.meta.collection);
1141
+ documentRoutes.add(adapted.document.route);
1142
+ adaptedByRoute.set(adapted.document.route, {
1143
+ document: adapted.document,
1144
+ meta: adapted.meta
1145
+ });
1146
+ } catch {
1147
+ adaptationFailures.push({ item, collection });
1148
+ summary.errors.push(`${collection}: content adaptation failed`);
1149
+ }
1150
+ }
1151
+ if (page.hasMore) {
1152
+ if (!page.cursor || seenCursors.has(page.cursor)) throw new Error("incomplete pagination: missing or repeated cursor");
1153
+ seenCursors.add(page.cursor);
1154
+ cursor = page.cursor;
1155
+ } else {
1156
+ cursor = void 0;
412
1157
  }
413
- cursor = page.hasMore ? page.cursor : void 0;
414
1158
  } catch (err) {
415
1159
  const message = err instanceof Error ? err.message : String(err);
416
1160
  summary.errors.push(`${collection}: ${message}`);
@@ -418,66 +1162,183 @@ async function evaluateAndPersistAll(ctx, options) {
418
1162
  }
419
1163
  } while (cursor !== void 0);
420
1164
  }
1165
+ const inventoryComplete = summary.errors.length === 0;
421
1166
  summary.documentsScanned = documents.length;
1167
+ recordContentCoverage(ctx, documents);
1168
+ if (adaptationFailures.length > 0) {
1169
+ try {
1170
+ ctx.log?.info?.("aexeo.adaptation_coverage", { failed: adaptationFailures.length });
1171
+ } catch {
1172
+ }
1173
+ const storedByCollection = /* @__PURE__ */ new Map();
1174
+ for (const stored of storedAtStart) {
1175
+ const byId = storedByCollection.get(stored.meta.collection) ?? /* @__PURE__ */ new Map();
1176
+ const entries = byId.get(stored.meta.id) ?? [];
1177
+ entries.push(stored);
1178
+ byId.set(stored.meta.id, entries);
1179
+ storedByCollection.set(stored.meta.collection, byId);
1180
+ }
1181
+ for (const { item, collection } of adaptationFailures) {
1182
+ for (const stored of storedByCollection.get(collection)?.get(item.id) ?? []) {
1183
+ if (documentRoutes.has(stored.document.route)) continue;
1184
+ await markAdaptationUnavailable(ctx, item, stored);
1185
+ }
1186
+ }
1187
+ }
422
1188
  for (const stored of adaptedByRoute.values()) {
423
- await kv.set(documentKey(stored.document.route), stored);
1189
+ const current = await readStoredDocument(kv, stored.document.route);
1190
+ if (!isOlderRevision(stored.meta.updatedAt, current?.meta.updatedAt)) {
1191
+ await kv.set(documentKey(stored.document.route), stored);
1192
+ }
424
1193
  }
425
- const result = await options.evaluator(documents);
1194
+ const result = await evaluateSafely(options.evaluator, documents, { collections: documentCollections });
1195
+ recordAssessmentCoverage(ctx, result, documents);
426
1196
  if (!result.ok) {
427
1197
  log?.error?.(`aexeo evaluator failure (${result.reason})`, {
428
1198
  detail: result.detail
429
1199
  });
430
1200
  summary.errors.push(`${result.reason}: ${result.detail}`);
1201
+ try {
1202
+ ctx.log?.info?.("aexeo.sitewide_coverage", { status: "evaluation_error" });
1203
+ } catch {
1204
+ }
1205
+ await kv.set(findingsKey("*"), {
1206
+ route: "*",
1207
+ findings: [],
1208
+ assessment: { status: "error", reason: result.reason, detail: result.detail }
1209
+ });
1210
+ for (const [route, stored] of adaptedByRoute) {
1211
+ if (!await isSuperseded(kv, stored)) {
1212
+ await kv.set(findingsKey(route), {
1213
+ route,
1214
+ findings: [],
1215
+ assessment: { status: "error", reason: result.reason, detail: result.detail }
1216
+ });
1217
+ }
1218
+ }
431
1219
  return summary;
432
1220
  }
433
1221
  const findingsByRoute = /* @__PURE__ */ new Map();
1222
+ findingsByRoute.set("*", []);
434
1223
  for (const route of documentRoutes) {
435
1224
  findingsByRoute.set(route, []);
436
1225
  }
1226
+ const completeByRoute = new Map(documents.map((document) => [document.route, hasCompleteBody(document)]));
1227
+ const allBodiesComplete = documents.every(hasCompleteBody);
1228
+ const changed = await refreshInputChanged(kv, collections, adaptedByRoute, storedAtStart);
1229
+ const coverageStatus = !inventoryComplete ? "inventory_incomplete" : changed ? "changed_during_refresh" : !allBodiesComplete ? "body_incomplete" : "complete";
1230
+ const globalCoverageComplete = coverageStatus === "complete";
1231
+ try {
1232
+ ctx.log?.info?.("aexeo.sitewide_coverage", { status: coverageStatus });
1233
+ } catch {
1234
+ }
1235
+ const globalAssessment = globalCoverageComplete ? void 0 : {
1236
+ status: "error",
1237
+ reason: coverageStatus,
1238
+ detail: coverageStatus === "changed_during_refresh" ? "Sitewide and cross-document checks are unavailable because content changed during refresh." : "Sitewide and cross-document checks are unavailable because the CMS input is incomplete."
1239
+ };
1240
+ if (changed && inventoryComplete) {
1241
+ summary.errors.push("Sitewide checks unavailable: content changed during refresh.");
1242
+ } else if (!allBodiesComplete && inventoryComplete) {
1243
+ summary.errors.push("Sitewide checks unavailable: one or more document bodies are incomplete.");
1244
+ }
437
1245
  for (const finding of result.findings) {
1246
+ if (!globalCoverageComplete && (finding.scope !== "page" || isSiteGraphFinding(finding))) continue;
1247
+ const complete = finding.scope === "page" ? completeByRoute.get(finding.path) ?? false : allBodiesComplete;
1248
+ if (!isCmsObservableFinding(finding, complete)) continue;
438
1249
  const bucket = finding.scope === "page" ? finding.path : "*";
439
1250
  const list = findingsByRoute.get(bucket) ?? [];
440
- list.push(finding);
1251
+ list.push(withCmsSourceLocation(finding, adaptedByRoute.get(bucket)));
441
1252
  findingsByRoute.set(bucket, list);
442
1253
  }
443
1254
  let totalAfterSuppression = 0;
1255
+ const cmsLocationCounts = { eligible: 0, mapped: 0 };
444
1256
  for (const [route, findings] of findingsByRoute) {
445
1257
  const stored = adaptedByRoute.get(route);
1258
+ if (stored !== void 0 && await isSuperseded(kv, stored)) {
1259
+ continue;
1260
+ }
446
1261
  const context = {
447
1262
  route,
448
1263
  ...stored?.meta.collection === void 0 ? {} : { collection: stored.meta.collection },
449
1264
  ...stored?.meta.status === void 0 ? {} : { status: stored.meta.status }
450
1265
  };
451
1266
  const filtered = suppressionFilter === void 0 ? findings : suppressionFilter.apply(context, findings);
452
- await kv.set(findingsKey(route), { route, findings: filtered });
1267
+ countCmsLocations(cmsLocationCounts, filtered);
1268
+ await kv.set(findingsKey(route), {
1269
+ route,
1270
+ findings: filtered,
1271
+ ...stored === void 0 ? {} : { body_coverage: cmsBodyCoverage(stored.document) },
1272
+ ...route === "*" && globalAssessment !== void 0 ? { assessment: globalAssessment } : result.assessments?.[route] === void 0 ? {} : { assessment: result.assessments[route] }
1273
+ });
453
1274
  summary.routesUpdated += 1;
454
1275
  totalAfterSuppression += filtered.length;
455
1276
  }
1277
+ recordCmsLocationCoverage(ctx, cmsLocationCounts);
456
1278
  summary.totalFindings = totalAfterSuppression;
457
- if (summary.errors.length === 0) {
458
- await removeStaleDocuments(ctx, collections, documentRoutes);
1279
+ if (inventoryComplete && !changed) {
1280
+ await removeStaleDocuments(ctx, collections, documentRoutes, storedAtStart);
459
1281
  }
460
1282
  return summary;
461
1283
  }
1284
+ async function refreshInputChanged(kv, collections, evaluated, initial) {
1285
+ const current = await readAllStoredDocuments(kv);
1286
+ const currentByRoute = new Map(current.map((entry) => [entry.document.route, entry]));
1287
+ const initialByRoute = new Map(initial.map((entry) => [entry.document.route, entry]));
1288
+ for (const [route, entry] of evaluated) {
1289
+ if (!sameStoredDocument(entry, currentByRoute.get(route))) return true;
1290
+ }
1291
+ const selected = new Set(collections);
1292
+ for (const entry of current) {
1293
+ const route = entry.document.route;
1294
+ if (!evaluated.has(route) && (entry.meta.collection === "" || selected.has(entry.meta.collection)) && !sameStoredDocument(entry, initialByRoute.get(route))) return true;
1295
+ }
1296
+ return false;
1297
+ }
1298
+ function isOlderRevision(revision, other) {
1299
+ if (revision === void 0 || other === void 0) return false;
1300
+ return Date.parse(revision) < Date.parse(other);
1301
+ }
1302
+ async function readStoredDocument(kv, route) {
1303
+ return toStoredDocument(await kv.get(documentKey(route)));
1304
+ }
1305
+ function sameStoredDocument(a, b) {
1306
+ return a != null && b != null && JSON.stringify(a) === JSON.stringify(b);
1307
+ }
1308
+ async function isSuperseded(kv, evaluated) {
1309
+ return !sameStoredDocument(evaluated, await readStoredDocument(kv, evaluated.document.route));
1310
+ }
462
1311
  async function persistDocument(ctx, stored) {
463
1312
  const route = stored.document.route;
464
- const entries = await ctx.kv.list("document:");
465
- for (const entry of entries) {
1313
+ const sameEntry = (await ctx.kv.list("document:")).flatMap((entry) => {
466
1314
  const previous = toStoredDocument(entry.value);
467
- if (previous === null || previous.document.route === route || previous.meta.id !== stored.meta.id || previous.meta.collection !== stored.meta.collection) {
468
- continue;
469
- }
470
- await ctx.kv.delete(entry.key);
1315
+ return previous !== null && previous.meta.id === stored.meta.id && previous.meta.collection === stored.meta.collection ? [{ key: entry.key, previous }] : [];
1316
+ });
1317
+ if (sameEntry.some(
1318
+ ({ previous }) => isOlderRevision(stored.meta.updatedAt, previous.meta.updatedAt)
1319
+ )) {
1320
+ return false;
1321
+ }
1322
+ for (const { key, previous } of sameEntry) {
1323
+ if (previous.document.route === route) continue;
1324
+ await ctx.kv.delete(key);
471
1325
  await ctx.kv.delete(findingsKey(previous.document.route));
472
1326
  }
473
1327
  await ctx.kv.set(documentKey(route), stored);
1328
+ return true;
474
1329
  }
475
- async function removeStaleDocuments(ctx, collections, currentRoutes) {
1330
+ async function removeStaleDocuments(ctx, collections, currentRoutes, storedAtStart) {
476
1331
  const scannedCollections = new Set(collections);
1332
+ const revisionAtStart = new Map(
1333
+ storedAtStart.map((stored) => [
1334
+ stored.document.route,
1335
+ stored.meta.updatedAt
1336
+ ])
1337
+ );
477
1338
  const entries = await ctx.kv.list("document:");
478
1339
  for (const entry of entries) {
479
1340
  const stored = toStoredDocument(entry.value);
480
- if (stored === null || !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route)) {
1341
+ if (stored === null || stored.meta.collection !== "" && !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route) || !revisionAtStart.has(stored.document.route) || revisionAtStart.get(stored.document.route) !== stored.meta.updatedAt) {
481
1342
  continue;
482
1343
  }
483
1344
  await ctx.kv.delete(entry.key);
@@ -513,9 +1374,6 @@ function documentKey(route) {
513
1374
  const normalized = route === "" || route === "/" ? "/" : route;
514
1375
  return `document:${normalized}`;
515
1376
  }
516
- async function readAllDocuments(kv) {
517
- return (await readAllStoredDocuments(kv)).map((s) => s.document);
518
- }
519
1377
  async function readAllStoredDocuments(kv) {
520
1378
  const entries = await kv.list("document:");
521
1379
  const out = [];
@@ -532,6 +1390,10 @@ async function readFindings(kv, route) {
532
1390
  }
533
1391
  return stored.findings;
534
1392
  }
1393
+ async function readAssessment(kv, route) {
1394
+ const stored = await kv.get(findingsKey(route));
1395
+ return stored?.assessment;
1396
+ }
535
1397
 
536
1398
  // src/sidecar-url.ts
537
1399
  function validateSidecarUrl(raw, allowedHosts = []) {
@@ -552,6 +1414,12 @@ function validateSidecarUrl(raw, allowedHosts = []) {
552
1414
  };
553
1415
  }
554
1416
  const host = parsed.hostname.toLowerCase();
1417
+ if (host.endsWith(".")) {
1418
+ return {
1419
+ ok: false,
1420
+ error: `Sidecar URL host must not end with a dot: ${host}`
1421
+ };
1422
+ }
555
1423
  if (isBlockedHost(host)) {
556
1424
  return {
557
1425
  ok: false,
@@ -628,15 +1496,20 @@ function hostMatchesAllowlist(host, allowedHosts) {
628
1496
 
629
1497
  // src/evaluator-sandbox.ts
630
1498
  async function scoreSite(_documents) {
631
- return {
632
- overall_score: 0,
633
- citation_readiness_score: 0,
634
- truth_consistency_score: 0,
635
- answer_pack_score: 0,
636
- external_trust_alignment_score: null,
637
- route_scores: [],
638
- blockers: []
639
- };
1499
+ return null;
1500
+ }
1501
+
1502
+ // src/assessment-display.ts
1503
+ function describeGate(gate) {
1504
+ const status = gate.status === "not_checked" ? "not checked" : gate.status;
1505
+ const reason = gate.status === "not_checked" ? `: ${gate.reason ?? "required input unavailable"}` : "";
1506
+ const metrics = Object.entries(gate.measurements ?? {}).map(([key, value]) => `${key.replaceAll("_", " ")}: ${value}`).join(", ");
1507
+ return `${gate.gate_id} ${status}${reason} (${gate.blocking ? "blocking" : "advisory"})${metrics ? ` \u2014 ${metrics}` : ""}`;
1508
+ }
1509
+ function summarizeGates(gates) {
1510
+ const counts = { passed: 0, failed: 0, not_checked: 0 };
1511
+ for (const gate of gates) counts[gate.status]++;
1512
+ return `${gates.length} configured checks: ${counts.passed} passed, ${counts.failed} failed, ${counts.not_checked} not checked`;
640
1513
  }
641
1514
 
642
1515
  // src/findings-view.ts
@@ -675,6 +1548,10 @@ async function handleRefresh(options) {
675
1548
  summary = await evaluateAndPersistAll(options.ctx, {
676
1549
  collections: options.collections,
677
1550
  evaluator: options.evaluator,
1551
+ blockMappings: options.blockMappings,
1552
+ contentFieldsByCollection: options.contentFieldsByCollection,
1553
+ contentFieldsUnavailable: options.contentFieldsUnavailable,
1554
+ descriptionFieldsByCollection: options.descriptionFieldsByCollection,
678
1555
  // Always forwarded. See RefreshOptions.suppressionFilter.
679
1556
  suppressionFilter: options.suppressionFilter
680
1557
  });
@@ -710,21 +1587,38 @@ async function handleRefresh(options) {
710
1587
  }
711
1588
  };
712
1589
  }
713
- async function readAllFindings(kv) {
714
- const entries = await kv.list(
715
- "findings:"
716
- );
717
- const out = [];
1590
+ async function readFindingsSnapshot(kv) {
1591
+ const entries = await kv.list("findings:");
1592
+ const sitewideAssessment = entries.find((entry) => entry.key === "findings:*")?.value?.assessment;
1593
+ const incomplete = sitewideAssessment?.status === "error";
1594
+ const findings = [];
1595
+ const routes = /* @__PURE__ */ new Set();
1596
+ let failedAssessments = 0;
1597
+ let uncheckedGates = 0;
1598
+ let incompleteBodies = 0;
1599
+ let unknownBodies = 0;
1600
+ let missingAssessments = 0;
718
1601
  for (const entry of entries) {
719
- if (entry.value === null) {
720
- continue;
721
- }
1602
+ if (entry.value === null) continue;
722
1603
  const route = entry.key.replace(/^findings:/, "");
1604
+ if (route !== "*") {
1605
+ routes.add(route);
1606
+ if (entry.value.body_coverage === void 0) unknownBodies++;
1607
+ else if (entry.value.body_coverage !== "complete") incompleteBodies++;
1608
+ const assessment = entry.value.assessment;
1609
+ if (assessment === void 0) missingAssessments++;
1610
+ if (assessment?.status === "error") failedAssessments++;
1611
+ if (assessment?.status === "evaluated") {
1612
+ uncheckedGates += assessment.gates.filter((gate) => gate.status === "not_checked").length;
1613
+ }
1614
+ }
723
1615
  for (const finding of entry.value.findings) {
724
- out.push({ ...finding, document_route: route });
1616
+ if (!isCmsObservableFinding(finding, route === "*" || entry.value.body_coverage === "complete") || incomplete && isSiteGraphFinding(finding)) continue;
1617
+ findings.push({ ...finding, document_route: route });
1618
+ if (route === "*") routes.add(route);
725
1619
  }
726
1620
  }
727
- return out;
1621
+ return { findings, documentRoutes: [...routes].sort(), sitewideAssessment, failedAssessments, uncheckedGates, incompleteBodies, unknownBodies, missingAssessments };
728
1622
  }
729
1623
  function severityFirst(a, b) {
730
1624
  const rank = (severity) => severity === "error" ? 0 : 1;
@@ -742,7 +1636,7 @@ function uniqueRoutes(rows) {
742
1636
  return [...routes].sort();
743
1637
  }
744
1638
  async function renderFindingsPage(kv, tuning) {
745
- const findings = await readAllFindings(kv);
1639
+ const { findings, documentRoutes, sitewideAssessment, failedAssessments, uncheckedGates, incompleteBodies, unknownBodies, missingAssessments } = await readFindingsSnapshot(kv);
746
1640
  const errors = findings.filter((finding) => finding.severity === "error");
747
1641
  const warnings = findings.filter(
748
1642
  (finding) => finding.severity === "warning"
@@ -753,8 +1647,13 @@ async function renderFindingsPage(kv, tuning) {
753
1647
  { type: "header", text: "SEO findings" },
754
1648
  {
755
1649
  type: "context",
756
- text: findings.length === 0 ? "No findings yet \u2014 click Refresh to evaluate the site." : `${findings.length} findings across ${routes.length} routes \u2014 ${errors.length} errors, ${warnings.length} warnings.`
1650
+ text: findings.length === 0 ? documentRoutes.length === 0 ? "No CMS document results available \u2014 click Refresh to evaluate records." : "No findings reported for the available CMS checks. Inspect assessments for unchecked or failed evaluations." : `${findings.length} findings across ${routes.length} routes \u2014 ${errors.length} errors, ${warnings.length} warnings.`
757
1651
  },
1652
+ { type: "context", text: "Rendered page markup, semantic sections, generated metadata, duplicate pages and sitewide links are not checked here. Audit the built site for page-level and link findings." },
1653
+ ...incompleteBodies || unknownBodies ? [{ type: "context", text: `${incompleteBodies} document bodies were incomplete or unavailable; coverage is unknown for ${unknownBodies} older or failed evaluations. Inspect individual documents.` }] : [],
1654
+ ...missingAssessments ? [{ type: "context", text: `No content preset assessment was recorded for ${missingAssessments} document(s); absence of findings is not an editorial quality assessment.` }] : [],
1655
+ ...failedAssessments || uncheckedGates ? [{ type: "context", text: `${failedAssessments} document evaluations failed; ${uncheckedGates} preset checks were not checked. Inspect individual documents for details.` }] : [],
1656
+ ...sitewideAssessment?.status === "error" ? [{ type: "context", text: `Sitewide checks unavailable: ${sitewideAssessment.reason}. ${sitewideAssessment.detail}` }] : [],
758
1657
  { type: "divider" },
759
1658
  {
760
1659
  type: "actions",
@@ -779,10 +1678,10 @@ async function renderFindingsPage(kv, tuning) {
779
1678
  },
780
1679
  sorted.length === 0 ? {
781
1680
  type: "context",
782
- text: tuning.emptyFindingsText
1681
+ text: documentRoutes.length === 0 ? tuning.emptyFindingsText : "Select a document to inspect its assessment."
783
1682
  } : findingsTable(sorted)
784
1683
  ];
785
- if (routes.length > 0) {
1684
+ if (documentRoutes.length > 0) {
786
1685
  blocks.push({
787
1686
  type: "form",
788
1687
  fields: [
@@ -790,7 +1689,7 @@ async function renderFindingsPage(kv, tuning) {
790
1689
  type: "select",
791
1690
  action_id: "route_picker",
792
1691
  label: "Document to inspect",
793
- options: routes.map((route) => ({ label: route, value: route }))
1692
+ options: documentRoutes.map((route) => ({ label: route, value: route }))
794
1693
  }
795
1694
  ],
796
1695
  submit: { label: "View document SEO", action_id: "view_document" }
@@ -815,55 +1714,13 @@ function findingsTable(rows) {
815
1714
  }))
816
1715
  };
817
1716
  }
818
- async function renderScoreWidget(kv, tuning) {
819
- const documents = await readAllDocuments(kv);
820
- if (documents.length === 0) {
821
- return {
822
- blocks: [
823
- { type: "header", text: "SEO score" },
824
- {
825
- type: "context",
826
- text: tuning.emptyScoreText
827
- }
828
- ]
829
- };
830
- }
831
- const score = await tuning.score(documents, kv);
832
- const blocks = [
833
- {
834
- type: "stats",
835
- items: [
836
- { label: "Overall", value: `${score.overall_score}` },
837
- { label: "Citation", value: `${score.citation_readiness_score}` },
838
- {
839
- label: tuning.truthLabel(score),
840
- value: `${score.truth_consistency_score}`
841
- },
842
- { label: "Answers", value: `${score.answer_pack_score}` }
843
- ]
844
- }
845
- ];
846
- if (score.overall_score < 60) {
847
- blocks.unshift({
848
- type: "banner",
849
- title: `Site score is ${score.overall_score} \u2014 below the 60 quality threshold`,
850
- variant: "alert"
851
- });
852
- }
853
- if (score.blockers.length > 0) {
854
- blocks.push({
855
- type: "context",
856
- text: topBlockersLine(score)
857
- });
858
- }
859
- return { blocks };
860
- }
861
- function topBlockersLine(score) {
862
- const top = score.blockers.slice(0, 3).map((blocker) => blocker.message);
863
- if (top.length === 0) {
864
- return "No blockers identified.";
865
- }
866
- return `Top blockers: ${top.join(" \u2022 ")}`;
1717
+ async function renderScoreWidget(_kv, _tuning, _log) {
1718
+ return {
1719
+ blocks: [
1720
+ { type: "header", text: "SEO score unavailable" },
1721
+ { type: "context", text: "CMS records do not include the rendered site. Run an Aexeo site audit on the built site to assess SEO and page-level signals." }
1722
+ ]
1723
+ };
867
1724
  }
868
1725
  async function renderDocumentPanel(kv, route) {
869
1726
  if (route === void 0) {
@@ -877,7 +1734,11 @@ async function renderDocumentPanel(kv, route) {
877
1734
  ]
878
1735
  };
879
1736
  }
880
- const findings = await readFindings(kv, route);
1737
+ const incomplete = (await readAssessment(kv, "*"))?.status === "error";
1738
+ const record = await kv.get(findingsKey(route));
1739
+ const findings = (record?.findings ?? []).filter((finding) => isCmsObservableFinding(finding, record?.body_coverage === "complete") && (!incomplete || !isSiteGraphFinding(finding)));
1740
+ const assessment = record?.assessment;
1741
+ const bodyNotice = record && assessment?.status !== "error" ? cmsBodyCoverageNotice(record.body_coverage) : void 0;
881
1742
  const errors = findings.filter((finding) => finding.severity === "error");
882
1743
  const warnings = findings.filter(
883
1744
  (finding) => finding.severity === "warning"
@@ -888,8 +1749,14 @@ async function renderDocumentPanel(kv, route) {
888
1749
  { type: "header", text: `Document SEO \u2014 ${route}` },
889
1750
  {
890
1751
  type: "context",
891
- text: findings.length === 0 ? `No findings for ${route} \u2014 the document is currently clean.` : `${findings.length} findings \u2014 ${errors.length} errors, ${warnings.length} warnings.`
1752
+ text: !record ? `No evaluation is available for ${route}. Save the document or refresh to evaluate it.` : assessment?.status === "error" ? `Evaluation failed for ${route}; findings are unavailable for this revision.` : findings.length === 0 ? `No findings for ${route} from the available CMS checks.` : `${findings.length} findings \u2014 ${errors.length} errors, ${warnings.length} warnings.`
892
1753
  },
1754
+ { type: "context", text: "Rendered page markup, semantic sections, generated metadata, duplicate pages and sitewide links are not checked here. Audit the built site for page-level and link findings." },
1755
+ ...bodyNotice ? [{ type: "context", text: bodyNotice }] : [],
1756
+ ...incomplete ? [{ type: "context", text: "Sitewide and cross-document checks are unavailable; refresh after resolving the CMS input error." }] : [],
1757
+ ...record && assessment === void 0 ? [{ type: "context", text: "No content preset assessment was recorded for this document; absence of findings is not an editorial quality assessment." }] : [],
1758
+ ...assessment?.status === "error" ? [{ type: "context", text: `Evaluation failed: ${assessment.reason}. Findings are unavailable for this revision.` }] : [],
1759
+ ...assessment?.status === "evaluated" ? [{ type: "context", text: `Content preset: ${assessment.preset}. ${summarizeGates(assessment.gates)}. Results cover only these configured checks.` }, ...assessment.gates.map((gate) => ({ type: "context", text: describeGate(gate) }))] : [],
893
1760
  ...findings.length === 0 ? [] : [{ type: "divider" }, documentFindingsTable(sorted)]
894
1761
  ]
895
1762
  };
@@ -901,16 +1768,13 @@ function documentFindingsTable(findings) {
901
1768
  { key: "rule", label: "Rule", format: "code" },
902
1769
  { key: "severity", label: "Severity", format: "badge" },
903
1770
  { key: "message", label: "Message" },
904
- { key: "block", label: "Block", format: "code" }
1771
+ { key: "block", label: "Location", format: "code" }
905
1772
  ],
906
1773
  rows: findings.map((finding) => ({
907
1774
  rule: finding.rule_id,
908
1775
  severity: finding.severity,
909
1776
  message: finding.message,
910
- // The bridge stamps every Portable Text block with id + data-pt-key
911
- // when rendering; surfacing the path:line locator here lets authors
912
- // locate the failing block via the editor's ⌘-F.
913
- block: `${finding.path}:${finding.line}`
1777
+ block: finding.aexeo_cms_location ? `CMS path ${JSON.stringify(finding.aexeo_cms_location.path)}, UTF-16 offset ${finding.aexeo_cms_location.utf16_offset}` : finding.aexeo_source_location ? `Projected body: block ${finding.aexeo_source_location.block_index + 1}, span ${finding.aexeo_source_location.span_index + 1}, UTF-16 offset ${finding.aexeo_source_location.utf16_offset}` : finding.aexeo_origin === "content_preset" ? "Projected location unavailable" : `Reported line ${finding.line}`
914
1778
  }))
915
1779
  };
916
1780
  }
@@ -1061,7 +1925,7 @@ async function handleBlockAction(ctx, actionId, value) {
1061
1925
  return notFound(actionId);
1062
1926
  }
1063
1927
  async function handleRefresh2(ctx) {
1064
- const sandboxEvaluator = async (documents) => {
1928
+ const sandboxEvaluator = async (documents, context) => {
1065
1929
  const runtime = await readSidecarConfig(ctx.ctx);
1066
1930
  if (runtime === null) {
1067
1931
  return {
@@ -1072,10 +1936,12 @@ async function handleRefresh2(ctx) {
1072
1936
  }
1073
1937
  return evaluateViaSidecar(
1074
1938
  ctx.ctx.http,
1075
- { url: runtime.url, authToken: runtime.token },
1076
- documents
1939
+ { url: runtime.url, authToken: runtime.token, log: ctx.ctx.log },
1940
+ documents,
1941
+ context?.collections
1077
1942
  );
1078
1943
  };
1944
+ const contentFields = await readSandboxContentFieldState(ctx.ctx);
1079
1945
  return handleRefresh({
1080
1946
  ctx: ctx.ctx,
1081
1947
  // The sandboxed descriptor takes no collection knobs, so the sweep
@@ -1083,18 +1949,30 @@ async function handleRefresh2(ctx) {
1083
1949
  collections: DEFAULT_COLLECTIONS,
1084
1950
  evaluator: sandboxEvaluator,
1085
1951
  suppressionFilter: sandboxSuppressionFilter,
1952
+ blockMappings: await readSandboxBlockMappings(ctx.ctx),
1953
+ contentFieldsByCollection: contentFields.fields,
1954
+ contentFieldsUnavailable: contentFields.unavailable,
1955
+ descriptionFieldsByCollection: await readSandboxDescriptionFields(ctx.ctx),
1086
1956
  tuning: adminViewTuning
1087
1957
  });
1088
1958
  }
1089
1959
  async function renderSetupPage(ctx, options = {}) {
1090
1960
  const existing = await readSidecarConfig(ctx.ctx);
1961
+ const tokenInPlaintext = await isTokenStoredInPlaintext(ctx.ctx);
1091
1962
  const blocks = [
1092
1963
  { type: "header", text: "Aexeo setup" },
1093
1964
  {
1094
1965
  type: "context",
1095
- text: "Paste the URL and auth token of your deployed aexeo-crawl-worker. The token is stored encrypted by the emdash host; both values are read at runtime, so no rebuild is required after a change. Rotate the token here whenever you redeploy the sidecar with a new secret."
1966
+ text: "Paste the URL and auth token of your deployed aexeo-crawl-worker. Saving stores the token encrypted by the emdash host, and fails if the host cannot encrypt it. Both values are read at runtime, so no rebuild is required after a change. Rotate the token here whenever you redeploy the sidecar with a new secret."
1096
1967
  }
1097
1968
  ];
1969
+ if (tokenInPlaintext) {
1970
+ blocks.push({
1971
+ type: "banner",
1972
+ title: "EVAL_TOKEN is currently stored unencrypted in plugin KV. Save this form to encrypt it.",
1973
+ variant: "alert"
1974
+ });
1975
+ }
1098
1976
  if (options.bannerError !== void 0) {
1099
1977
  blocks.push({
1100
1978
  type: "banner",
@@ -1135,7 +2013,7 @@ async function renderSetupPage(ctx, options = {}) {
1135
2013
  { type: "divider" },
1136
2014
  {
1137
2015
  type: "context",
1138
- text: existing === null ? "Status: not configured. Refresh on the findings page will fail until both fields are saved." : `Status: configured. Sidecar at ${existing.url}. Token stored \u2014 paste a new one above to rotate.`
2016
+ text: existing === null ? "Status: not configured. Refresh on the findings page will fail until both fields are saved." : `Status: configured. Sidecar at ${existing.url}. Token stored ${tokenInPlaintext ? "unencrypted" : "encrypted"} \u2014 paste a new one above to rotate.`
1139
2017
  }
1140
2018
  );
1141
2019
  return { blocks };
@@ -1172,14 +2050,23 @@ async function handleSetupSubmit(ctx, values) {
1172
2050
  url: validated.url,
1173
2051
  token: resolvedToken
1174
2052
  };
1175
- await writeSidecarConfig(ctx.ctx, next);
2053
+ try {
2054
+ await writeSidecarConfig(ctx.ctx, next);
2055
+ } catch (err) {
2056
+ if (!(err instanceof TokenStorageError)) throw err;
2057
+ return renderSetupPage(ctx, {
2058
+ bannerError: err.message,
2059
+ initialUrl: validated.url
2060
+ });
2061
+ }
1176
2062
  return renderSetupPage(ctx, {
1177
2063
  bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
1178
2064
  });
1179
2065
  }
1180
2066
  var sandbox_entry_default = definePlugin({
1181
2067
  hooks: {
1182
- "content:afterSave": handleAfterSave
2068
+ "content:afterSave": handleAfterSave,
2069
+ "content:afterDelete": handleAfterDelete
1183
2070
  },
1184
2071
  routes: {
1185
2072
  admin: { handler: handleAdminRoute }