@aeptus/aexeo-emdash 0.8.19 → 0.9.0

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 (52) hide show
  1. package/CHANGELOG.md +96 -2
  2. package/INSTALL.md +113 -4
  3. package/README.md +12 -5
  4. package/dist/adapter.d.ts +30 -2
  5. package/dist/adapter.d.ts.map +1 -1
  6. package/dist/admin.d.ts.map +1 -1
  7. package/dist/admin.js +2 -2
  8. package/dist/configured.d.ts +6 -0
  9. package/dist/configured.d.ts.map +1 -1
  10. package/dist/configured.js +505 -70
  11. package/dist/content-evaluator.d.ts +14 -0
  12. package/dist/content-evaluator.d.ts.map +1 -0
  13. package/dist/content.d.ts +51 -0
  14. package/dist/content.d.ts.map +1 -0
  15. package/dist/content.js +365 -0
  16. package/dist/data-route.d.ts +3 -0
  17. package/dist/data-route.d.ts.map +1 -1
  18. package/dist/evaluator-sandbox.d.ts +1 -1
  19. package/dist/evaluator-sandbox.d.ts.map +1 -1
  20. package/dist/evaluator.d.ts +1 -1
  21. package/dist/evaluator.d.ts.map +1 -1
  22. package/dist/findings-view.d.ts +7 -2
  23. package/dist/findings-view.d.ts.map +1 -1
  24. package/dist/index.d.ts +15 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +99 -4
  27. package/dist/inventory.d.ts +51 -0
  28. package/dist/inventory.d.ts.map +1 -0
  29. package/dist/plugin.d.ts +30 -3
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/presence-check.d.ts +1 -0
  32. package/dist/presence-check.d.ts.map +1 -1
  33. package/dist/sandbox-entry.d.ts +2 -1
  34. package/dist/sandbox-entry.d.ts.map +1 -1
  35. package/dist/sandbox-entry.js +366 -81
  36. package/dist/sandbox.d.ts +2 -2
  37. package/dist/sidecar-url.d.ts.map +1 -1
  38. package/dist/sidecar.d.ts +7 -1
  39. package/dist/sidecar.d.ts.map +1 -1
  40. package/dist/types.d.ts +11 -4
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.d.ts.map +1 -1
  44. package/dist/wasm-init.d.ts +4 -0
  45. package/dist/wasm-init.d.ts.map +1 -1
  46. package/package.json +18 -7
  47. package/wasm/aexeo_emdash_bridge.d.ts +11 -0
  48. package/wasm/aexeo_emdash_bridge.js +1 -1
  49. package/wasm/aexeo_emdash_bridge_bg.d.ts +11 -0
  50. package/wasm/aexeo_emdash_bridge_bg.js +68 -0
  51. package/wasm/aexeo_emdash_bridge_bg.wasm +0 -0
  52. package/wasm/aexeo_emdash_bridge_bg.wasm.d.ts +2 -0
@@ -1,11 +1,13 @@
1
1
  // src/adapter.ts
2
- async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
2
+ async function adaptContentItemWithContext(content, contentApi, defaultLocale, blockMappings, contentFields) {
3
3
  const publicUrl = content.status === "published" ? await contentApi.getPublicUrl(content.type, content.id) : null;
4
4
  const route = publicUrl === null ? deriveRoute(content) : routeFromPublicUrl(publicUrl);
5
5
  const adapted = adaptContentItem(content, {
6
6
  route,
7
7
  publicUrl,
8
- ...defaultLocale === void 0 ? {} : { defaultLocale }
8
+ ...defaultLocale === void 0 ? {} : { defaultLocale },
9
+ ...blockMappings === void 0 ? {} : { blockMappings },
10
+ ...contentFields === void 0 ? {} : { contentFields }
9
11
  });
10
12
  const translationSet = await contentApi.getTranslations(
11
13
  content.type,
@@ -47,8 +49,11 @@ function adaptContentItem(content, options = {}) {
47
49
  if (typeof locale === "string" && locale.length > 0) {
48
50
  document.lang = locale;
49
51
  }
50
- const body = extractPortableText(content.data);
51
- if (body !== null) document.body = body;
52
+ const body = options.contentFields === void 0 ? extractPortableText(content.data, options.blockMappings) : extractSelectedContent(content.data, options.contentFields, options.blockMappings);
53
+ if (body !== null) {
54
+ document.body = body.blocks;
55
+ if (body.capabilities !== void 0) document.body_capabilities = body.capabilities;
56
+ }
52
57
  return {
53
58
  document,
54
59
  meta: {
@@ -57,7 +62,8 @@ function adaptContentItem(content, options = {}) {
57
62
  slug: content.slug,
58
63
  status: content.status,
59
64
  title,
60
- publicUrl: options.publicUrl ?? null
65
+ publicUrl: options.publicUrl ?? null,
66
+ ...content.updatedAt === void 0 ? {} : { updatedAt: content.updatedAt }
61
67
  }
62
68
  };
63
69
  }
@@ -76,24 +82,53 @@ function routeFromPublicUrl(publicUrl) {
76
82
  function stringOrEmpty(value) {
77
83
  return typeof value === "string" ? value : "";
78
84
  }
79
- function extractPortableText(data) {
85
+ function extractSelectedContent(data, fields, mappings) {
86
+ if (fields.length === 0 || new Set(fields).size !== fields.length || fields.some((field) => typeof field !== "string" || field.length === 0)) return null;
87
+ const all = ["text", "headings", "paragraphs", "links"];
88
+ let capabilities = all;
89
+ const blocks = [];
90
+ let observed = false;
91
+ for (const field of fields) {
92
+ const value = data[field];
93
+ if (value === void 0 || value === null) continue;
94
+ observed = true;
95
+ const part = extractFirstPortableText({ content: value }, mappings);
96
+ if (part === null) return { blocks, capabilities: [] };
97
+ blocks.push(...part.blocks);
98
+ capabilities = capabilities.filter((capability) => part.capabilities === void 0 || part.capabilities.includes(capability));
99
+ }
100
+ if (!observed) return null;
101
+ return capabilities.length === all.length ? { blocks } : { blocks, capabilities: [...capabilities] };
102
+ }
103
+ function extractPortableText(data, mappings) {
104
+ const result = extractFirstPortableText(data, mappings);
105
+ if (result === null) return null;
106
+ const candidates = Object.entries(data).filter(
107
+ ([field, value]) => isPortableTextArray(value) || isEmdashBlocksArray(value, field) || (field === "body" || field === "content") && typeof value === "string" && value.trim().length > 0
108
+ );
109
+ return candidates.length > 1 ? { blocks: result.blocks, capabilities: [] } : result;
110
+ }
111
+ function extractFirstPortableText(data, mappings) {
112
+ let foundEmptyBody = false;
80
113
  for (const slug of ["body", "content"]) {
81
114
  const value = data[slug];
82
- if (isPortableTextArray(value)) return value;
83
- if (isEmdashBlocksArray(value, slug)) return blocksToPortableText(value);
115
+ if (Array.isArray(value) && value.length === 0 || typeof value === "string" && value.trim().length === 0) foundEmptyBody = true;
116
+ if (isPortableTextArray(value)) return { blocks: value };
117
+ if (isEmdashBlocksArray(value, slug)) return mapCustomBlocks(value, mappings);
84
118
  if (typeof value === "string" && value.trim().length > 0) {
85
- return [portableTextBlock(value.trim(), `${slug}-text`)];
119
+ return { blocks: [portableTextBlock(value.trim(), `${slug}-text`)], capabilities: ["text"] };
86
120
  }
87
121
  }
88
122
  const blocks = data["blocks"];
89
- if (isPortableTextArray(blocks)) return blocks;
90
- if (isEmdashBlocksArray(blocks, "blocks")) return blocksToPortableText(blocks);
123
+ if (Array.isArray(blocks) && blocks.length === 0) foundEmptyBody = true;
124
+ if (isPortableTextArray(blocks)) return { blocks };
125
+ if (isEmdashBlocksArray(blocks, "blocks")) return mapCustomBlocks(blocks, mappings);
91
126
  for (const [field, value] of Object.entries(data)) {
92
127
  if (field === "body" || field === "content" || field === "blocks") continue;
93
- if (isPortableTextArray(value)) return value;
94
- if (isEmdashBlocksArray(value, field)) return blocksToPortableText(value);
128
+ if (isPortableTextArray(value)) return { blocks: value };
129
+ if (isEmdashBlocksArray(value, field)) return mapCustomBlocks(value, mappings);
95
130
  }
96
- return null;
131
+ return foundEmptyBody ? { blocks: [] } : null;
97
132
  }
98
133
  function isPortableTextArray(value) {
99
134
  return Array.isArray(value) && value.length > 0 && value.every(
@@ -108,6 +143,52 @@ function isEmdashBlocksArray(value, field) {
108
143
  (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks")
109
144
  );
110
145
  }
146
+ function mapCustomBlocks(blocks, mappings) {
147
+ const unavailable = () => ({ blocks: blocksToPortableText(blocks), capabilities: [] });
148
+ const result = [];
149
+ const mapFields = (record, fields, depth) => {
150
+ if (depth > 8 || fields.length === 0) return false;
151
+ for (const entry of fields) {
152
+ if (!entry || typeof entry.field !== "string" || !entry.field || !["heading", "paragraph", "richText", "link", "repeat"].includes(entry.kind)) return false;
153
+ if (entry.kind === "heading" && entry.level !== void 0 && ![1, 2, 3, 4, 5, 6].includes(entry.level)) return false;
154
+ if (entry.kind === "link" && (typeof entry.hrefField !== "string" || !entry.hrefField)) return false;
155
+ if (entry.kind === "repeat" && (!Array.isArray(entry.fields) || entry.fields.length === 0)) return false;
156
+ const value = record[entry.field];
157
+ if ((value === void 0 || value === null || value === "") && entry.optional) {
158
+ if (entry.kind === "link" && record[entry.hrefField] != null && record[entry.hrefField] !== "") return false;
159
+ continue;
160
+ }
161
+ if (entry.kind === "richText") {
162
+ if (!Array.isArray(value) || value.length > 0 && !isPortableTextArray(value)) return false;
163
+ result.push(...value);
164
+ } else if (entry.kind === "heading" || entry.kind === "paragraph") {
165
+ if (typeof value !== "string") return false;
166
+ result.push({ ...portableTextBlock(value, ""), style: entry.kind === "heading" ? `h${entry.level ?? 2}` : "normal" });
167
+ } else if (entry.kind === "link") {
168
+ const href = record[entry.hrefField];
169
+ if (typeof value !== "string" || typeof href !== "string" || !href) return false;
170
+ result.push({
171
+ _type: "block",
172
+ children: [{ _type: "span", text: value, marks: ["link"] }],
173
+ markDefs: [{ _key: "link", _type: "link", href }]
174
+ });
175
+ } else if (entry.kind === "repeat") {
176
+ if (!Array.isArray(value) || !Array.isArray(entry.fields)) return false;
177
+ for (const item of value) {
178
+ if (!isRecord(item) || !mapFields(item, entry.fields, depth + 1)) return false;
179
+ }
180
+ } else {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ };
186
+ for (const block of blocks) {
187
+ const fields = mappings?.[String(block["_type"])];
188
+ if (!Array.isArray(fields) || !mapFields(block, fields, 0)) return unavailable();
189
+ }
190
+ return { blocks: result };
191
+ }
111
192
  function blocksToPortableText(blocks) {
112
193
  const paragraphs = [];
113
194
  for (const block of blocks) {
@@ -191,11 +272,15 @@ function isRecord(value) {
191
272
  return typeof value === "object" && value !== null && !Array.isArray(value);
192
273
  }
193
274
 
275
+ // src/version.ts
276
+ var PACKAGE_VERSION = "0.9.0";
277
+
194
278
  // src/sidecar.ts
195
- async function evaluateViaSidecar(http, config, documents) {
279
+ async function evaluateViaSidecar(http, config, documents, collections) {
196
280
  const body = {
197
281
  documents
198
282
  };
283
+ if (collections !== void 0) body.collections = collections;
199
284
  if (config.configJson !== void 0) {
200
285
  body.configJson = config.configJson;
201
286
  }
@@ -228,6 +313,12 @@ async function evaluateViaSidecar(http, config, documents) {
228
313
  }
229
314
  return { ok: false, reason: "server_error", detail };
230
315
  }
316
+ const workerVersion = response.headers?.get("x-aexeo-version") ?? "unknown";
317
+ if (workerVersion !== PACKAGE_VERSION) {
318
+ config.log?.warn(
319
+ `Aexeo sidecar is version ${workerVersion} but the plugin is ${PACKAGE_VERSION}; redeploy the Worker so both run the same rules`
320
+ );
321
+ }
231
322
  let raw;
232
323
  try {
233
324
  raw = await response.text();
@@ -266,7 +357,21 @@ function normalizeHost(evaluatorHost) {
266
357
  var CONFIG_URL_KEY = "config:evaluator_url";
267
358
  var CONFIG_TOKEN_KEY = "config:eval_token";
268
359
  var SETTINGS_TOKEN_KEY = "eval_token";
360
+ var SETTINGS_BLOCK_MAPPINGS_KEY = "block_mappings";
361
+ var SETTINGS_CONTENT_FIELDS_KEY = "content_fields_by_collection";
269
362
  var PLUGIN_SETTINGS_SCHEMA = {
363
+ [SETTINGS_CONTENT_FIELDS_KEY]: {
364
+ type: "string",
365
+ label: "Content fields by collection (JSON)",
366
+ description: "Ordered rendered body fields per collection.",
367
+ multiline: true
368
+ },
369
+ [SETTINGS_BLOCK_MAPPINGS_KEY]: {
370
+ type: "string",
371
+ label: "Custom block mappings (JSON)",
372
+ description: "Ordered mappings for the rendered fields of each custom block type.",
373
+ multiline: true
374
+ },
270
375
  [SETTINGS_TOKEN_KEY]: {
271
376
  type: "secret",
272
377
  label: "EVAL_TOKEN",
@@ -285,10 +390,21 @@ async function readSidecarConfig(store) {
285
390
  return { url, token };
286
391
  }
287
392
  async function writeSidecarConfig(store, config) {
288
- await store.kv.set(CONFIG_URL_KEY, config.url);
289
393
  await writeStoredToken(store, config.token);
394
+ await store.kv.set(CONFIG_URL_KEY, config.url);
395
+ }
396
+ async function isTokenStoredInPlaintext(store) {
397
+ return await readLegacyToken(store) !== null;
398
+ }
399
+ async function readLegacyToken(store) {
400
+ const legacy = await store.kv.get(CONFIG_TOKEN_KEY);
401
+ return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
290
402
  }
291
403
  async function readStoredToken(store) {
404
+ const legacy = await readLegacyToken(store);
405
+ if (legacy !== null) {
406
+ return legacy;
407
+ }
292
408
  const { settings } = store;
293
409
  if (settings !== void 0) {
294
410
  try {
@@ -298,32 +414,85 @@ async function readStoredToken(store) {
298
414
  }
299
415
  } catch (err) {
300
416
  store.log?.warn?.(
301
- `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings, falling back to KV: ${describeError(err, "")}`
417
+ `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings: ${describeError(err, "")}`
302
418
  );
303
419
  }
304
420
  }
305
- const legacy = await store.kv.get(CONFIG_TOKEN_KEY);
306
- return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
421
+ return null;
307
422
  }
423
+ var TokenStorageError = class extends Error {
424
+ };
308
425
  async function writeStoredToken(store, token) {
309
426
  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
- }
427
+ if (settings === void 0) {
428
+ throw new TokenStorageError(
429
+ "This emdash host does not provide encrypted plugin settings, so EVAL_TOKEN was not saved. Upgrade emdash."
430
+ );
431
+ }
432
+ try {
433
+ await settings.set(SETTINGS_TOKEN_KEY, token);
434
+ await store.kv.delete(CONFIG_TOKEN_KEY);
435
+ } catch (err) {
436
+ throw new TokenStorageError(
437
+ `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)}`
438
+ );
320
439
  }
321
- await store.kv.set(CONFIG_TOKEN_KEY, token);
322
440
  }
323
441
  function describeError(err, secret) {
324
442
  const raw = err instanceof Error ? err.message : String(err);
325
443
  return secret.length === 0 ? raw : raw.split(secret).join("[redacted]");
326
444
  }
445
+ function recordContentCoverage(ctx, documents) {
446
+ const counts = { documents: documents.length, complete: 0, partial: 0, unavailable: 0 };
447
+ for (const document of documents) {
448
+ if (document.body === void 0 || document.body_capabilities?.length === 0) counts.unavailable++;
449
+ else if (document.body_capabilities === void 0 || ["text", "headings", "paragraphs", "links"].every(
450
+ (capability) => document.body_capabilities?.includes(capability)
451
+ )) counts.complete++;
452
+ else counts.partial++;
453
+ }
454
+ try {
455
+ ctx.log?.info?.("aexeo.content_coverage", counts);
456
+ } catch {
457
+ }
458
+ }
459
+ async function handleAfterDelete(event, ctx) {
460
+ for (const entry of await ctx.kv.list("document:")) {
461
+ const stored = toStoredDocument(entry.value);
462
+ if (stored !== null && stored.meta.id === event.id && stored.meta.collection === event.collection) {
463
+ await ctx.kv.delete(entry.key);
464
+ await ctx.kv.delete(findingsKey(stored.document.route));
465
+ }
466
+ }
467
+ }
468
+ async function readSandboxBlockMappings(ctx) {
469
+ if (!ctx.settings) return void 0;
470
+ try {
471
+ const raw = await ctx.settings.get(SETTINGS_BLOCK_MAPPINGS_KEY);
472
+ if (raw === null || raw === "") return void 0;
473
+ if (typeof raw !== "string" || raw.length > 65536) throw new Error("invalid mapping size or type");
474
+ const value = JSON.parse(raw);
475
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("mapping must be an object");
476
+ return value;
477
+ } catch {
478
+ ctx.log?.warn?.("Aexeo custom block mappings unavailable; custom content remains unverified");
479
+ return void 0;
480
+ }
481
+ }
482
+ async function readSandboxContentFields(ctx) {
483
+ if (!ctx.settings) return void 0;
484
+ try {
485
+ const raw = await ctx.settings.get(SETTINGS_CONTENT_FIELDS_KEY);
486
+ if (raw === null || raw === "") return void 0;
487
+ if (typeof raw !== "string" || raw.length > 65536) throw new Error("invalid selection size or type");
488
+ const value = JSON.parse(raw);
489
+ 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");
490
+ return value;
491
+ } catch {
492
+ ctx.log?.warn?.("Aexeo content field selection unavailable; multiple fields remain unverified");
493
+ return void 0;
494
+ }
495
+ }
327
496
  async function handleAfterSave(event, ctx) {
328
497
  const runtime = await readSidecarConfig(ctx);
329
498
  if (runtime === null) {
@@ -332,36 +501,73 @@ async function handleAfterSave(event, ctx) {
332
501
  );
333
502
  return;
334
503
  }
504
+ const [blockMappings, fields] = await Promise.all([readSandboxBlockMappings(ctx), readSandboxContentFields(ctx)]);
335
505
  await handleAfterSaveConfigured(
336
506
  event,
337
507
  ctx,
338
- (documents) => evaluateViaSidecar(
508
+ (documents, context) => evaluateViaSidecar(
339
509
  ctx.http,
340
- { url: runtime.url, authToken: runtime.token },
341
- documents
342
- )
510
+ { url: runtime.url, authToken: runtime.token, log: ctx.log },
511
+ documents,
512
+ context?.collections
513
+ ),
514
+ void 0,
515
+ blockMappings,
516
+ fields?.[event.content.type]
343
517
  );
344
518
  }
345
- async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter) {
519
+ var SITE_GRAPH_RULE_IDS = /* @__PURE__ */ new Set([
520
+ "LNK001",
521
+ // internal link target missing from the site
522
+ "LNK002",
523
+ // no inbound links
524
+ "LNK003",
525
+ // weak anchor, checked only when the target route exists
526
+ "LNK004",
527
+ // too few inbound links
528
+ "SEO009",
529
+ // hreflang alternate target missing from the site
530
+ "SEO012",
531
+ // hreflang alternate does not link back
532
+ "SEO016",
533
+ // canonical points at another crawlable route
534
+ "SEO017",
535
+ // title and description shared with another route
536
+ "CNT005"
537
+ // visible text duplicated on another route
538
+ ]);
539
+ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter, blockMappings, contentFields) {
346
540
  const adapted = await adaptContentItemWithContext(
347
541
  event.content,
348
542
  ctx.content,
349
- ctx.site?.locale
543
+ ctx.site?.locale,
544
+ blockMappings,
545
+ contentFields
350
546
  );
351
547
  const document = adapted.document;
548
+ recordContentCoverage(ctx, [document]);
352
549
  const { kv, log } = ctx;
353
550
  const stored = { document, meta: adapted.meta };
354
- await persistDocument(ctx, stored);
355
- const result = await evaluator([document]);
551
+ if (!await persistDocument(ctx, stored)) {
552
+ log?.info?.(`Aexeo afterSave skipped: ${document.route} has a newer save`);
553
+ return;
554
+ }
555
+ const result = await evaluator([document], { collections: [adapted.meta.collection] });
356
556
  if (!result.ok) {
357
557
  log?.error?.(`afterSave evaluator failure (${result.reason})`, {
358
558
  detail: result.detail
359
559
  });
360
560
  return;
361
561
  }
362
- const pageFindings = result.findings.filter(
363
- (finding) => finding.scope === "page"
562
+ const storedGraphFindings = (await readFindings(kv, document.route)).filter(
563
+ (finding) => SITE_GRAPH_RULE_IDS.has(finding.rule_id)
364
564
  );
565
+ const pageFindings = [
566
+ ...result.findings.filter(
567
+ (finding) => finding.scope === "page" && !SITE_GRAPH_RULE_IDS.has(finding.rule_id)
568
+ ),
569
+ ...storedGraphFindings
570
+ ];
365
571
  const filtered = suppressionFilter === void 0 ? pageFindings : suppressionFilter.apply(
366
572
  {
367
573
  route: document.route,
@@ -370,6 +576,10 @@ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilte
370
576
  },
371
577
  pageFindings
372
578
  );
579
+ if (await isSuperseded(kv, document.route, adapted.meta.updatedAt)) {
580
+ log?.info?.(`Aexeo afterSave discarded: ${document.route} changed while evaluating`);
581
+ return;
582
+ }
373
583
  await kv.set(findingsKey(document.route), {
374
584
  route: document.route,
375
585
  findings: filtered
@@ -386,7 +596,9 @@ async function evaluateAndPersistAll(ctx, options) {
386
596
  totalFindings: 0,
387
597
  errors: []
388
598
  };
599
+ const storedAtStart = await readAllStoredDocuments(kv);
389
600
  const documents = [];
601
+ const documentCollections = [];
390
602
  const adaptedByRoute = /* @__PURE__ */ new Map();
391
603
  const documentRoutes = /* @__PURE__ */ new Set();
392
604
  for (const collection of collections) {
@@ -401,9 +613,12 @@ async function evaluateAndPersistAll(ctx, options) {
401
613
  const adapted = await adaptContentItemWithContext(
402
614
  item,
403
615
  ctx.content,
404
- ctx.site?.locale
616
+ ctx.site?.locale,
617
+ options.blockMappings,
618
+ options.contentFieldsByCollection?.[collection]
405
619
  );
406
620
  documents.push(adapted.document);
621
+ documentCollections.push(adapted.meta.collection);
407
622
  documentRoutes.add(adapted.document.route);
408
623
  adaptedByRoute.set(adapted.document.route, {
409
624
  document: adapted.document,
@@ -419,10 +634,14 @@ async function evaluateAndPersistAll(ctx, options) {
419
634
  } while (cursor !== void 0);
420
635
  }
421
636
  summary.documentsScanned = documents.length;
637
+ recordContentCoverage(ctx, documents);
422
638
  for (const stored of adaptedByRoute.values()) {
423
- await kv.set(documentKey(stored.document.route), stored);
639
+ const current = await readStoredDocument(kv, stored.document.route);
640
+ if (!isOlderRevision(stored.meta.updatedAt, current?.meta.updatedAt)) {
641
+ await kv.set(documentKey(stored.document.route), stored);
642
+ }
424
643
  }
425
- const result = await options.evaluator(documents);
644
+ const result = await options.evaluator(documents, { collections: documentCollections });
426
645
  if (!result.ok) {
427
646
  log?.error?.(`aexeo evaluator failure (${result.reason})`, {
428
647
  detail: result.detail
@@ -443,6 +662,9 @@ async function evaluateAndPersistAll(ctx, options) {
443
662
  let totalAfterSuppression = 0;
444
663
  for (const [route, findings] of findingsByRoute) {
445
664
  const stored = adaptedByRoute.get(route);
665
+ if (stored !== void 0 && await isSuperseded(kv, route, stored.meta.updatedAt)) {
666
+ continue;
667
+ }
446
668
  const context = {
447
669
  route,
448
670
  ...stored?.meta.collection === void 0 ? {} : { collection: stored.meta.collection },
@@ -455,29 +677,52 @@ async function evaluateAndPersistAll(ctx, options) {
455
677
  }
456
678
  summary.totalFindings = totalAfterSuppression;
457
679
  if (summary.errors.length === 0) {
458
- await removeStaleDocuments(ctx, collections, documentRoutes);
680
+ await removeStaleDocuments(ctx, collections, documentRoutes, storedAtStart);
459
681
  }
460
682
  return summary;
461
683
  }
684
+ function isOlderRevision(revision, other) {
685
+ if (revision === void 0 || other === void 0) return false;
686
+ return Date.parse(revision) < Date.parse(other);
687
+ }
688
+ async function readStoredDocument(kv, route) {
689
+ return toStoredDocument(await kv.get(documentKey(route)));
690
+ }
691
+ async function isSuperseded(kv, route, revision) {
692
+ const current = await readStoredDocument(kv, route);
693
+ return current === null || isOlderRevision(revision, current.meta.updatedAt);
694
+ }
462
695
  async function persistDocument(ctx, stored) {
463
696
  const route = stored.document.route;
464
- const entries = await ctx.kv.list("document:");
465
- for (const entry of entries) {
697
+ const sameEntry = (await ctx.kv.list("document:")).flatMap((entry) => {
466
698
  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);
699
+ return previous !== null && previous.meta.id === stored.meta.id && previous.meta.collection === stored.meta.collection ? [{ key: entry.key, previous }] : [];
700
+ });
701
+ if (sameEntry.some(
702
+ ({ previous }) => isOlderRevision(stored.meta.updatedAt, previous.meta.updatedAt)
703
+ )) {
704
+ return false;
705
+ }
706
+ for (const { key, previous } of sameEntry) {
707
+ if (previous.document.route === route) continue;
708
+ await ctx.kv.delete(key);
471
709
  await ctx.kv.delete(findingsKey(previous.document.route));
472
710
  }
473
711
  await ctx.kv.set(documentKey(route), stored);
712
+ return true;
474
713
  }
475
- async function removeStaleDocuments(ctx, collections, currentRoutes) {
714
+ async function removeStaleDocuments(ctx, collections, currentRoutes, storedAtStart) {
476
715
  const scannedCollections = new Set(collections);
716
+ const revisionAtStart = new Map(
717
+ storedAtStart.map((stored) => [
718
+ stored.document.route,
719
+ stored.meta.updatedAt
720
+ ])
721
+ );
477
722
  const entries = await ctx.kv.list("document:");
478
723
  for (const entry of entries) {
479
724
  const stored = toStoredDocument(entry.value);
480
- if (stored === null || !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route)) {
725
+ 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
726
  continue;
482
727
  }
483
728
  await ctx.kv.delete(entry.key);
@@ -552,6 +797,12 @@ function validateSidecarUrl(raw, allowedHosts = []) {
552
797
  };
553
798
  }
554
799
  const host = parsed.hostname.toLowerCase();
800
+ if (host.endsWith(".")) {
801
+ return {
802
+ ok: false,
803
+ error: `Sidecar URL host must not end with a dot: ${host}`
804
+ };
805
+ }
555
806
  if (isBlockedHost(host)) {
556
807
  return {
557
808
  ok: false,
@@ -628,15 +879,7 @@ function hostMatchesAllowlist(host, allowedHosts) {
628
879
 
629
880
  // src/evaluator-sandbox.ts
630
881
  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
- };
882
+ return null;
640
883
  }
641
884
 
642
885
  // src/findings-view.ts
@@ -675,6 +918,8 @@ async function handleRefresh(options) {
675
918
  summary = await evaluateAndPersistAll(options.ctx, {
676
919
  collections: options.collections,
677
920
  evaluator: options.evaluator,
921
+ blockMappings: options.blockMappings,
922
+ contentFieldsByCollection: options.contentFieldsByCollection,
678
923
  // Always forwarded. See RefreshOptions.suppressionFilter.
679
924
  suppressionFilter: options.suppressionFilter
680
925
  });
@@ -815,7 +1060,7 @@ function findingsTable(rows) {
815
1060
  }))
816
1061
  };
817
1062
  }
818
- async function renderScoreWidget(kv, tuning) {
1063
+ async function renderScoreWidget(kv, tuning, log) {
819
1064
  const documents = await readAllDocuments(kv);
820
1065
  if (documents.length === 0) {
821
1066
  return {
@@ -828,28 +1073,48 @@ async function renderScoreWidget(kv, tuning) {
828
1073
  ]
829
1074
  };
830
1075
  }
831
- const score = await tuning.score(documents, kv);
1076
+ let score;
1077
+ try {
1078
+ score = await tuning.score(documents, kv);
1079
+ } catch (error) {
1080
+ log?.error("Aexeo score widget failed to initialize", { error });
1081
+ return {
1082
+ blocks: [
1083
+ { type: "banner", title: "SEO score is temporarily unavailable", variant: "alert" },
1084
+ { type: "context", text: "The authoring interface remains available. Retry after the evaluator runtime has been checked." }
1085
+ ]
1086
+ };
1087
+ }
1088
+ if (score === null) {
1089
+ return {
1090
+ blocks: [
1091
+ { type: "header", text: "SEO score" },
1092
+ {
1093
+ type: "context",
1094
+ text: "The site score is unavailable in sandboxed mode. Findings are not affected."
1095
+ }
1096
+ ]
1097
+ };
1098
+ }
832
1099
  const blocks = [
833
1100
  {
834
1101
  type: "stats",
835
1102
  items: [
836
- { label: "Overall", value: `${score.overall_score}` },
837
- { label: "Citation", value: `${score.citation_readiness_score}` },
1103
+ {
1104
+ label: "Citation",
1105
+ value: score.citation_readiness_score?.toString() ?? "unknown"
1106
+ },
838
1107
  {
839
1108
  label: tuning.truthLabel(score),
840
- value: `${score.truth_consistency_score}`
1109
+ value: score.truth_consistency_score?.toString() ?? "unknown"
841
1110
  },
842
- { label: "Answers", value: `${score.answer_pack_score}` }
1111
+ {
1112
+ label: "Answers",
1113
+ value: `${score.answer_pack.routes_with_answer_blocks}/${score.answer_pack.routes_analyzed} routes`
1114
+ }
843
1115
  ]
844
1116
  }
845
1117
  ];
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
1118
  if (score.blockers.length > 0) {
854
1119
  blocks.push({
855
1120
  type: "context",
@@ -1061,7 +1326,7 @@ async function handleBlockAction(ctx, actionId, value) {
1061
1326
  return notFound(actionId);
1062
1327
  }
1063
1328
  async function handleRefresh2(ctx) {
1064
- const sandboxEvaluator = async (documents) => {
1329
+ const sandboxEvaluator = async (documents, context) => {
1065
1330
  const runtime = await readSidecarConfig(ctx.ctx);
1066
1331
  if (runtime === null) {
1067
1332
  return {
@@ -1072,8 +1337,9 @@ async function handleRefresh2(ctx) {
1072
1337
  }
1073
1338
  return evaluateViaSidecar(
1074
1339
  ctx.ctx.http,
1075
- { url: runtime.url, authToken: runtime.token },
1076
- documents
1340
+ { url: runtime.url, authToken: runtime.token, log: ctx.ctx.log },
1341
+ documents,
1342
+ context?.collections
1077
1343
  );
1078
1344
  };
1079
1345
  return handleRefresh({
@@ -1083,18 +1349,28 @@ async function handleRefresh2(ctx) {
1083
1349
  collections: DEFAULT_COLLECTIONS,
1084
1350
  evaluator: sandboxEvaluator,
1085
1351
  suppressionFilter: sandboxSuppressionFilter,
1352
+ blockMappings: await readSandboxBlockMappings(ctx.ctx),
1353
+ contentFieldsByCollection: await readSandboxContentFields(ctx.ctx),
1086
1354
  tuning: adminViewTuning
1087
1355
  });
1088
1356
  }
1089
1357
  async function renderSetupPage(ctx, options = {}) {
1090
1358
  const existing = await readSidecarConfig(ctx.ctx);
1359
+ const tokenInPlaintext = await isTokenStoredInPlaintext(ctx.ctx);
1091
1360
  const blocks = [
1092
1361
  { type: "header", text: "Aexeo setup" },
1093
1362
  {
1094
1363
  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."
1364
+ 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
1365
  }
1097
1366
  ];
1367
+ if (tokenInPlaintext) {
1368
+ blocks.push({
1369
+ type: "banner",
1370
+ title: "EVAL_TOKEN is currently stored unencrypted in plugin KV. Save this form to encrypt it.",
1371
+ variant: "alert"
1372
+ });
1373
+ }
1098
1374
  if (options.bannerError !== void 0) {
1099
1375
  blocks.push({
1100
1376
  type: "banner",
@@ -1135,7 +1411,7 @@ async function renderSetupPage(ctx, options = {}) {
1135
1411
  { type: "divider" },
1136
1412
  {
1137
1413
  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.`
1414
+ 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
1415
  }
1140
1416
  );
1141
1417
  return { blocks };
@@ -1172,14 +1448,23 @@ async function handleSetupSubmit(ctx, values) {
1172
1448
  url: validated.url,
1173
1449
  token: resolvedToken
1174
1450
  };
1175
- await writeSidecarConfig(ctx.ctx, next);
1451
+ try {
1452
+ await writeSidecarConfig(ctx.ctx, next);
1453
+ } catch (err) {
1454
+ if (!(err instanceof TokenStorageError)) throw err;
1455
+ return renderSetupPage(ctx, {
1456
+ bannerError: err.message,
1457
+ initialUrl: validated.url
1458
+ });
1459
+ }
1176
1460
  return renderSetupPage(ctx, {
1177
1461
  bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
1178
1462
  });
1179
1463
  }
1180
1464
  var sandbox_entry_default = definePlugin({
1181
1465
  hooks: {
1182
- "content:afterSave": handleAfterSave
1466
+ "content:afterSave": handleAfterSave,
1467
+ "content:afterDelete": handleAfterDelete
1183
1468
  },
1184
1469
  routes: {
1185
1470
  admin: { handler: handleAdminRoute }