@aeptus/aexeo-emdash 0.8.18 → 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 (84) hide show
  1. package/CHANGELOG.md +196 -4
  2. package/INSTALL.md +126 -8
  3. package/LICENSE +0 -0
  4. package/README.md +12 -5
  5. package/dist/adapter.d.ts +30 -2
  6. package/dist/adapter.d.ts.map +1 -1
  7. package/dist/admin/Absorbability.d.ts +0 -0
  8. package/dist/admin/Absorbability.d.ts.map +0 -0
  9. package/dist/admin/Accessibility.d.ts +0 -0
  10. package/dist/admin/Accessibility.d.ts.map +0 -0
  11. package/dist/admin/Citability.d.ts +0 -0
  12. package/dist/admin/Citability.d.ts.map +0 -0
  13. package/dist/admin/EntityLegitimacy.d.ts +0 -0
  14. package/dist/admin/EntityLegitimacy.d.ts.map +0 -0
  15. package/dist/admin/EntityPresence.d.ts +0 -0
  16. package/dist/admin/EntityPresence.d.ts.map +0 -0
  17. package/dist/admin/Facts.d.ts +0 -0
  18. package/dist/admin/Facts.d.ts.map +0 -0
  19. package/dist/admin/Findings.d.ts +0 -0
  20. package/dist/admin/Findings.d.ts.map +0 -0
  21. package/dist/admin/PillarView.d.ts +0 -0
  22. package/dist/admin/PillarView.d.ts.map +0 -0
  23. package/dist/admin/Retrievability.d.ts +0 -0
  24. package/dist/admin/Retrievability.d.ts.map +0 -0
  25. package/dist/admin.d.ts +0 -0
  26. package/dist/admin.d.ts.map +1 -1
  27. package/dist/admin.js +2 -2
  28. package/dist/configured.d.ts +6 -0
  29. package/dist/configured.d.ts.map +1 -1
  30. package/dist/configured.js +817 -334
  31. package/dist/content-evaluator.d.ts +14 -0
  32. package/dist/content-evaluator.d.ts.map +1 -0
  33. package/dist/content.d.ts +51 -0
  34. package/dist/content.d.ts.map +1 -0
  35. package/dist/content.js +365 -0
  36. package/dist/data-route.d.ts +3 -0
  37. package/dist/data-route.d.ts.map +1 -1
  38. package/dist/evaluator-sandbox.d.ts +1 -1
  39. package/dist/evaluator-sandbox.d.ts.map +1 -1
  40. package/dist/evaluator.d.ts +1 -1
  41. package/dist/evaluator.d.ts.map +1 -1
  42. package/dist/facts-route.d.ts +0 -0
  43. package/dist/facts-route.d.ts.map +0 -0
  44. package/dist/findings-view.d.ts +141 -0
  45. package/dist/findings-view.d.ts.map +1 -0
  46. package/dist/index.d.ts +15 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +111 -5
  49. package/dist/inventory.d.ts +51 -0
  50. package/dist/inventory.d.ts.map +1 -0
  51. package/dist/plugin.d.ts +51 -4
  52. package/dist/plugin.d.ts.map +1 -1
  53. package/dist/presence-check.d.ts +1 -0
  54. package/dist/presence-check.d.ts.map +1 -1
  55. package/dist/presence-route.d.ts +0 -0
  56. package/dist/presence-route.d.ts.map +0 -0
  57. package/dist/sandbox-entry.d.ts +4 -23
  58. package/dist/sandbox-entry.d.ts.map +1 -1
  59. package/dist/sandbox-entry.js +738 -396
  60. package/dist/sandbox.d.ts +2 -0
  61. package/dist/sandbox.d.ts.map +1 -1
  62. package/dist/sidecar-url.d.ts +18 -0
  63. package/dist/sidecar-url.d.ts.map +1 -0
  64. package/dist/sidecar.d.ts +7 -1
  65. package/dist/sidecar.d.ts.map +1 -1
  66. package/dist/suppressions.d.ts +0 -0
  67. package/dist/suppressions.d.ts.map +0 -0
  68. package/dist/types.d.ts +11 -4
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/version.d.ts +1 -1
  71. package/dist/version.d.ts.map +1 -1
  72. package/dist/wasm-init.d.ts +4 -0
  73. package/dist/wasm-init.d.ts.map +1 -1
  74. package/package.json +21 -8
  75. package/wasm/aexeo_emdash_bridge.d.ts +11 -0
  76. package/wasm/aexeo_emdash_bridge.js +1 -1
  77. package/wasm/aexeo_emdash_bridge_bg.d.ts +11 -0
  78. package/wasm/aexeo_emdash_bridge_bg.js +68 -0
  79. package/wasm/aexeo_emdash_bridge_bg.wasm +0 -0
  80. package/wasm/aexeo_emdash_bridge_bg.wasm.d.ts +2 -0
  81. package/dist/indexnow.d.ts +0 -15
  82. package/dist/indexnow.d.ts.map +0 -1
  83. package/dist/mcp.d.ts +0 -28
  84. package/dist/mcp.d.ts.map +0 -1
@@ -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();
@@ -255,11 +346,41 @@ function trimTrailingSlash(url) {
255
346
  }
256
347
 
257
348
  // src/plugin.ts
349
+ function buildAllowedHosts(evaluatorHost) {
350
+ const host = normalizeHost(evaluatorHost);
351
+ return host === null ? [] : [host];
352
+ }
353
+ function normalizeHost(evaluatorHost) {
354
+ const host = evaluatorHost?.trim() ?? "";
355
+ return host.length === 0 ? null : host;
356
+ }
258
357
  var CONFIG_URL_KEY = "config:evaluator_url";
259
358
  var CONFIG_TOKEN_KEY = "config:eval_token";
260
- async function readSidecarConfig(kv) {
261
- const url = await kv.get(CONFIG_URL_KEY);
262
- const token = await kv.get(CONFIG_TOKEN_KEY);
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";
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
+ },
375
+ [SETTINGS_TOKEN_KEY]: {
376
+ type: "secret",
377
+ label: "EVAL_TOKEN",
378
+ description: "Bearer token for your aexeo-crawl-worker Worker. Encrypted at rest by the emdash host."
379
+ }
380
+ };
381
+ async function readSidecarConfig(store) {
382
+ const url = await store.kv.get(CONFIG_URL_KEY);
383
+ const token = await readStoredToken(store);
263
384
  if (typeof url !== "string" || typeof token !== "string") {
264
385
  return null;
265
386
  }
@@ -268,48 +389,185 @@ async function readSidecarConfig(kv) {
268
389
  }
269
390
  return { url, token };
270
391
  }
271
- async function writeSidecarConfig(kv, config) {
272
- await kv.set(CONFIG_URL_KEY, config.url);
273
- await kv.set(CONFIG_TOKEN_KEY, config.token);
392
+ async function writeSidecarConfig(store, config) {
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;
402
+ }
403
+ async function readStoredToken(store) {
404
+ const legacy = await readLegacyToken(store);
405
+ if (legacy !== null) {
406
+ return legacy;
407
+ }
408
+ const { settings } = store;
409
+ if (settings !== void 0) {
410
+ try {
411
+ const stored = await settings.get(SETTINGS_TOKEN_KEY);
412
+ if (typeof stored === "string" && stored.length > 0) {
413
+ return stored;
414
+ }
415
+ } catch (err) {
416
+ store.log?.warn?.(
417
+ `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings: ${describeError(err, "")}`
418
+ );
419
+ }
420
+ }
421
+ return null;
422
+ }
423
+ var TokenStorageError = class extends Error {
424
+ };
425
+ async function writeStoredToken(store, token) {
426
+ const { settings } = store;
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
+ );
439
+ }
440
+ }
441
+ function describeError(err, secret) {
442
+ const raw = err instanceof Error ? err.message : String(err);
443
+ return secret.length === 0 ? raw : raw.split(secret).join("[redacted]");
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
+ }
274
495
  }
275
496
  async function handleAfterSave(event, ctx) {
276
- const runtime = await readSidecarConfig(ctx.kv);
497
+ const runtime = await readSidecarConfig(ctx);
277
498
  if (runtime === null) {
278
499
  ctx.log?.warn?.(
279
500
  "Aexeo afterSave skipped: configure the evaluator URL and token on the Setup page"
280
501
  );
281
502
  return;
282
503
  }
504
+ const [blockMappings, fields] = await Promise.all([readSandboxBlockMappings(ctx), readSandboxContentFields(ctx)]);
283
505
  await handleAfterSaveConfigured(
284
506
  event,
285
507
  ctx,
286
- (documents) => evaluateViaSidecar(
508
+ (documents, context) => evaluateViaSidecar(
287
509
  ctx.http,
288
- { url: runtime.url, authToken: runtime.token },
289
- documents
290
- )
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]
291
517
  );
292
518
  }
293
- 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) {
294
540
  const adapted = await adaptContentItemWithContext(
295
541
  event.content,
296
542
  ctx.content,
297
- ctx.site?.locale
543
+ ctx.site?.locale,
544
+ blockMappings,
545
+ contentFields
298
546
  );
299
547
  const document = adapted.document;
548
+ recordContentCoverage(ctx, [document]);
300
549
  const { kv, log } = ctx;
301
550
  const stored = { document, meta: adapted.meta };
302
- await persistDocument(ctx, stored);
303
- 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] });
304
556
  if (!result.ok) {
305
557
  log?.error?.(`afterSave evaluator failure (${result.reason})`, {
306
558
  detail: result.detail
307
559
  });
308
560
  return;
309
561
  }
310
- const pageFindings = result.findings.filter(
311
- (finding) => finding.scope === "page"
562
+ const storedGraphFindings = (await readFindings(kv, document.route)).filter(
563
+ (finding) => SITE_GRAPH_RULE_IDS.has(finding.rule_id)
312
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
+ ];
313
571
  const filtered = suppressionFilter === void 0 ? pageFindings : suppressionFilter.apply(
314
572
  {
315
573
  route: document.route,
@@ -318,6 +576,10 @@ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilte
318
576
  },
319
577
  pageFindings
320
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
+ }
321
583
  await kv.set(findingsKey(document.route), {
322
584
  route: document.route,
323
585
  findings: filtered
@@ -334,7 +596,9 @@ async function evaluateAndPersistAll(ctx, options) {
334
596
  totalFindings: 0,
335
597
  errors: []
336
598
  };
599
+ const storedAtStart = await readAllStoredDocuments(kv);
337
600
  const documents = [];
601
+ const documentCollections = [];
338
602
  const adaptedByRoute = /* @__PURE__ */ new Map();
339
603
  const documentRoutes = /* @__PURE__ */ new Set();
340
604
  for (const collection of collections) {
@@ -349,9 +613,12 @@ async function evaluateAndPersistAll(ctx, options) {
349
613
  const adapted = await adaptContentItemWithContext(
350
614
  item,
351
615
  ctx.content,
352
- ctx.site?.locale
616
+ ctx.site?.locale,
617
+ options.blockMappings,
618
+ options.contentFieldsByCollection?.[collection]
353
619
  );
354
620
  documents.push(adapted.document);
621
+ documentCollections.push(adapted.meta.collection);
355
622
  documentRoutes.add(adapted.document.route);
356
623
  adaptedByRoute.set(adapted.document.route, {
357
624
  document: adapted.document,
@@ -367,10 +634,14 @@ async function evaluateAndPersistAll(ctx, options) {
367
634
  } while (cursor !== void 0);
368
635
  }
369
636
  summary.documentsScanned = documents.length;
637
+ recordContentCoverage(ctx, documents);
370
638
  for (const stored of adaptedByRoute.values()) {
371
- 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
+ }
372
643
  }
373
- const result = await options.evaluator(documents);
644
+ const result = await options.evaluator(documents, { collections: documentCollections });
374
645
  if (!result.ok) {
375
646
  log?.error?.(`aexeo evaluator failure (${result.reason})`, {
376
647
  detail: result.detail
@@ -378,19 +649,22 @@ async function evaluateAndPersistAll(ctx, options) {
378
649
  summary.errors.push(`${result.reason}: ${result.detail}`);
379
650
  return summary;
380
651
  }
381
- const findingsByRoute2 = /* @__PURE__ */ new Map();
652
+ const findingsByRoute = /* @__PURE__ */ new Map();
382
653
  for (const route of documentRoutes) {
383
- findingsByRoute2.set(route, []);
654
+ findingsByRoute.set(route, []);
384
655
  }
385
656
  for (const finding of result.findings) {
386
657
  const bucket = finding.scope === "page" ? finding.path : "*";
387
- const list = findingsByRoute2.get(bucket) ?? [];
658
+ const list = findingsByRoute.get(bucket) ?? [];
388
659
  list.push(finding);
389
- findingsByRoute2.set(bucket, list);
660
+ findingsByRoute.set(bucket, list);
390
661
  }
391
662
  let totalAfterSuppression = 0;
392
- for (const [route, findings] of findingsByRoute2) {
663
+ for (const [route, findings] of findingsByRoute) {
393
664
  const stored = adaptedByRoute.get(route);
665
+ if (stored !== void 0 && await isSuperseded(kv, route, stored.meta.updatedAt)) {
666
+ continue;
667
+ }
394
668
  const context = {
395
669
  route,
396
670
  ...stored?.meta.collection === void 0 ? {} : { collection: stored.meta.collection },
@@ -403,29 +677,52 @@ async function evaluateAndPersistAll(ctx, options) {
403
677
  }
404
678
  summary.totalFindings = totalAfterSuppression;
405
679
  if (summary.errors.length === 0) {
406
- await removeStaleDocuments(ctx, collections, documentRoutes);
680
+ await removeStaleDocuments(ctx, collections, documentRoutes, storedAtStart);
407
681
  }
408
682
  return summary;
409
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
+ }
410
695
  async function persistDocument(ctx, stored) {
411
696
  const route = stored.document.route;
412
- const entries = await ctx.kv.list("document:");
413
- for (const entry of entries) {
697
+ const sameEntry = (await ctx.kv.list("document:")).flatMap((entry) => {
414
698
  const previous = toStoredDocument(entry.value);
415
- if (previous === null || previous.document.route === route || previous.meta.id !== stored.meta.id || previous.meta.collection !== stored.meta.collection) {
416
- continue;
417
- }
418
- 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);
419
709
  await ctx.kv.delete(findingsKey(previous.document.route));
420
710
  }
421
711
  await ctx.kv.set(documentKey(route), stored);
712
+ return true;
422
713
  }
423
- async function removeStaleDocuments(ctx, collections, currentRoutes) {
714
+ async function removeStaleDocuments(ctx, collections, currentRoutes, storedAtStart) {
424
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
+ );
425
722
  const entries = await ctx.kv.list("document:");
426
723
  for (const entry of entries) {
427
724
  const stored = toStoredDocument(entry.value);
428
- 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) {
429
726
  continue;
430
727
  }
431
728
  await ctx.kv.delete(entry.key);
@@ -481,174 +778,111 @@ async function readFindings(kv, route) {
481
778
  return stored.findings;
482
779
  }
483
780
 
484
- // src/evaluator-sandbox.ts
485
- async function evaluate(_documents) {
486
- return [];
487
- }
488
- async function scoreSite(_documents) {
489
- return {
490
- overall_score: 0,
491
- citation_readiness_score: 0,
492
- truth_consistency_score: 0,
493
- answer_pack_score: 0,
494
- external_trust_alignment_score: null,
495
- route_scores: [],
496
- blockers: []
497
- };
498
- }
499
- function errorFindings(findings) {
500
- return findings.filter((finding) => finding.severity === "error");
501
- }
502
- function findingsByRoute(findings) {
503
- const grouped = /* @__PURE__ */ new Map();
504
- for (const finding of findings) {
505
- const bucket = grouped.get(finding.path);
506
- if (bucket) {
507
- bucket.push(finding);
508
- } else {
509
- grouped.set(finding.path, [finding]);
510
- }
781
+ // src/sidecar-url.ts
782
+ function validateSidecarUrl(raw, allowedHosts = []) {
783
+ const url = raw.trim();
784
+ if (url.length === 0) {
785
+ return { ok: false, error: "Sidecar URL is required." };
511
786
  }
512
- return grouped;
513
- }
514
-
515
- // src/indexnow.ts
516
- var INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
517
- async function submitIndexNow(config, urls) {
518
- const host = hostOf(config.siteUrl);
519
- if (host === null) {
520
- return failure("siteUrl is not a valid URL", []);
521
- }
522
- const keyLocation = config.keyLocation ?? defaultKeyLocation(config);
523
- const { submitted, rejected } = partitionByHost(urls, host);
524
- if (submitted.length === 0) {
525
- return failure("no submittable URLs after host check", rejected);
526
- }
527
- const payload = {
528
- host,
529
- key: config.key,
530
- keyLocation,
531
- urlList: submitted
532
- };
533
- const response = await fetch(INDEXNOW_ENDPOINT, {
534
- method: "POST",
535
- headers: { "content-type": "application/json" },
536
- body: JSON.stringify(payload)
537
- });
538
- return {
539
- ok: response.ok,
540
- status: response.status,
541
- endpoint: INDEXNOW_ENDPOINT,
542
- submitted,
543
- rejected
544
- };
545
- }
546
- function hostOf(rawUrl) {
787
+ let parsed;
547
788
  try {
548
- return new URL(rawUrl).host;
789
+ parsed = new URL(url);
549
790
  } catch {
550
- return null;
791
+ return { ok: false, error: `Sidecar URL is not a valid URL: ${url}` };
551
792
  }
793
+ if (parsed.protocol !== "https:") {
794
+ return {
795
+ ok: false,
796
+ error: `Sidecar URL must be https: \u2014 the EVAL_TOKEN is sent as a bearer header and must never travel in cleartext. Got ${parsed.protocol}`
797
+ };
798
+ }
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
+ }
806
+ if (isBlockedHost(host)) {
807
+ return {
808
+ ok: false,
809
+ error: "Sidecar URL must not point at a loopback, private, or link-local address. Use the deployed *.workers.dev URL."
810
+ };
811
+ }
812
+ if (allowedHosts.length > 0 && !hostMatchesAllowlist(host, allowedHosts)) {
813
+ return {
814
+ ok: false,
815
+ error: `Sidecar host "${host}" is not in this plugin's outbound allowlist (${allowedHosts.join(", ")}). The emdash sandbox would reject the fetch with "Host not allowed" \u2014 update evaluatorHost in astro.config and rebuild, or use that host.`
816
+ };
817
+ }
818
+ return { ok: true, url };
552
819
  }
553
- function defaultKeyLocation(config) {
554
- const siteUrl = config.siteUrl.endsWith("/") ? config.siteUrl.slice(0, -1) : config.siteUrl;
555
- return `${siteUrl}/${config.key}.txt`;
556
- }
557
- function partitionByHost(urls, expectedHost) {
558
- const submitted = [];
559
- const rejected = [];
560
- for (const candidate of urls) {
561
- const host = hostOf(candidate);
562
- if (host === expectedHost) {
563
- submitted.push(candidate);
564
- } else {
565
- rejected.push(candidate);
566
- }
820
+ function isBlockedHost(host) {
821
+ if (host === "localhost" || host.endsWith(".localhost")) {
822
+ return true;
823
+ }
824
+ if (host.startsWith("[")) {
825
+ return isBlockedIpv6(host);
567
826
  }
568
- return { submitted, rejected };
827
+ return isBlockedIpv4(host);
569
828
  }
570
- function failure(reason, rejected) {
571
- return {
572
- ok: false,
573
- status: 0,
574
- endpoint: INDEXNOW_ENDPOINT,
575
- submitted: [],
576
- rejected,
577
- reason
578
- };
829
+ function isBlockedIpv4(host) {
830
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
831
+ if (match === null) {
832
+ return false;
833
+ }
834
+ const first = Number(match[1]);
835
+ const second = Number(match[2]);
836
+ if (first === 0) return true;
837
+ if (first === 127) return true;
838
+ if (first === 10) return true;
839
+ if (first === 172 && second >= 16 && second <= 31) return true;
840
+ if (first === 192 && second === 168) return true;
841
+ if (first === 169 && second === 254) return true;
842
+ if (first === 100 && second >= 64 && second <= 127) return true;
843
+ if (first >= 224) return true;
844
+ return false;
579
845
  }
580
-
581
- // src/mcp.ts
582
- var checkTool = {
583
- name: "aexeo.check",
584
- description: "Evaluate a list of EmdashDocument values against the Aexeo rule engine and return the stable Finding contract. Mirrors the CLI's `aexeo-cli check` surface so agents, the admin UI, and the CI gate all see the same rule ids.",
585
- inputSchema: {
586
- type: "object",
587
- properties: {
588
- documents: {
589
- type: "array",
590
- description: "Array of EmdashDocument values to evaluate. Each entry carries route, title, optional description/canonical/lang, alternates, meta map, schema, and Portable Text body."
591
- },
592
- configJson: {
593
- type: "string",
594
- description: "Optional JSON-serialized Aexeo config. When absent the evaluator uses Config::default(), matching a cold CLI run."
595
- }
596
- },
597
- required: ["documents"]
598
- },
599
- handler: async ({ documents, configJson }) => {
600
- const options = configJson === void 0 ? {} : { configJson };
601
- const findings = await evaluate(documents, options);
602
- const errors = errorFindings(findings);
603
- const grouped = findingsByRoute(findings);
604
- const byRoute = {};
605
- for (const [route, items] of grouped.entries()) {
606
- byRoute[route] = items;
607
- }
608
- return {
609
- findings,
610
- errors,
611
- byRoute,
612
- totals: {
613
- findings: findings.length,
614
- errors: errors.length,
615
- warnings: findings.length - errors.length
616
- }
617
- };
846
+ function isBlockedIpv6(host) {
847
+ const inner = host.slice(1, -1).toLowerCase();
848
+ if (inner === "::" || inner === "::1") {
849
+ return true;
618
850
  }
619
- };
620
- var indexNowSubmitTool = {
621
- name: "aexeo.indexnow.submit",
622
- description: "Submit a list of URLs to the IndexNow freshness endpoint. Mirrors the aexeo-cli `indexnow submit` contract: the IndexNow protocol requires every submitted URL to belong to the configured site host. URLs that do not match the host are returned in `rejected` and never sent over the wire.",
623
- inputSchema: {
624
- type: "object",
625
- properties: {
626
- config: {
627
- type: "object",
628
- description: "IndexNow configuration: siteUrl, key, and optional keyLocation override.",
629
- properties: {
630
- siteUrl: { type: "string" },
631
- key: { type: "string" },
632
- keyLocation: { type: "string" }
633
- },
634
- required: ["siteUrl", "key"]
635
- },
636
- urls: {
637
- type: "array",
638
- items: { type: "string" },
639
- description: "Absolute URLs to submit. Must share siteUrl's host."
640
- }
641
- },
642
- required: ["config", "urls"]
643
- },
644
- handler: async ({ config, urls }) => submitIndexNow(config, urls)
645
- };
646
- var tools = [checkTool, indexNowSubmitTool];
851
+ if (inner.startsWith("::ffff:") || inner.startsWith("::7f")) {
852
+ return true;
853
+ }
854
+ const firstHextet = Number.parseInt(inner.split(":")[0] ?? "", 16);
855
+ if (Number.isNaN(firstHextet)) {
856
+ return false;
857
+ }
858
+ if ((firstHextet & 65024) === 64512) {
859
+ return true;
860
+ }
861
+ if ((firstHextet & 65472) === 65152) {
862
+ return true;
863
+ }
864
+ return false;
865
+ }
866
+ function hostMatchesAllowlist(host, allowedHosts) {
867
+ return allowedHosts.some((allowed) => {
868
+ const entry = allowed.trim().toLowerCase();
869
+ if (entry.length === 0) {
870
+ return false;
871
+ }
872
+ if (entry.startsWith("*.")) {
873
+ const suffix = entry.slice(1);
874
+ return host.endsWith(suffix) && host.length > suffix.length;
875
+ }
876
+ return host === entry;
877
+ });
878
+ }
647
879
 
648
- // src/sandbox-entry.ts
649
- function definePlugin(plugin) {
650
- return plugin;
880
+ // src/evaluator-sandbox.ts
881
+ async function scoreSite(_documents) {
882
+ return null;
651
883
  }
884
+
885
+ // src/findings-view.ts
652
886
  function normalizeInteraction(input) {
653
887
  if (!input || typeof input !== "object") {
654
888
  return { type: "page_load", page: "/findings" };
@@ -678,107 +912,33 @@ function normalizeInteraction(input) {
678
912
  }
679
913
  return { type: "page_load", page: "/findings" };
680
914
  }
681
- async function handleAdminRoute(input, ctx) {
682
- const body = normalizeInteraction(input?.input);
683
- ctx.log?.info?.(
684
- `aexeo route: type=${body.type} page=${body.type === "page_load" ? body.page : ""}`
685
- );
686
- const dispatch = { body, kv: ctx.kv, ctx };
687
- if (body.type === "page_load") {
688
- return handlePageLoad(dispatch, body.page);
689
- }
690
- if (body.type === "block_action") {
691
- return handleBlockAction(
692
- dispatch,
693
- body.action_id,
694
- body.value
695
- );
696
- }
697
- if (body.type === "form_submit") {
698
- return handleFormSubmit(
699
- dispatch,
700
- body.action_id,
701
- body.values
702
- );
703
- }
704
- return handlePageLoad(dispatch, "findings");
705
- }
706
- async function handleFormSubmit(ctx, actionId, values) {
707
- if (actionId === "save_setup") {
708
- return handleSetupSubmit(ctx, values);
709
- }
710
- if (actionId === "view_document") {
711
- const picked = values["route_picker"];
712
- if (typeof picked === "string" && picked.length > 0) {
713
- return renderDocumentPanel(ctx, picked);
714
- }
715
- }
716
- return handlePageLoad(ctx, "findings");
717
- }
718
- async function handlePageLoad(ctx, page) {
719
- const normalized = page.startsWith("/") ? page.slice(1) : page;
720
- if (normalized === "findings") {
721
- return renderFindingsPage(ctx);
722
- }
723
- if (normalized === "widget:aexeo-score") {
724
- return renderScoreWidget(ctx);
725
- }
726
- if (normalized === "document") {
727
- return renderDocumentPanel(ctx);
728
- }
729
- if (normalized === "setup") {
730
- return renderSetupPage(ctx);
731
- }
732
- return notFound(page);
733
- }
734
- async function handleBlockAction(ctx, actionId, value) {
735
- if (actionId === "view_document" && typeof value === "string") {
736
- return renderDocumentPanel(ctx, value);
737
- }
738
- if (actionId === "refresh_findings") {
739
- return handleRefresh(ctx);
740
- }
741
- if (actionId.startsWith("filter:")) {
742
- return renderFindingsPage(ctx);
743
- }
744
- return notFound(actionId);
745
- }
746
- async function handleRefresh(ctx) {
747
- const sandboxEvaluator = async (documents) => {
748
- const runtime = await readSidecarConfig(ctx.kv);
749
- if (runtime === null) {
750
- return {
751
- ok: false,
752
- reason: "config_missing",
753
- detail: "sidecar not configured \u2014 open the Aexeo Setup page and enter your evaluator URL and token"
754
- };
755
- }
756
- return evaluateViaSidecar(
757
- ctx.ctx.http,
758
- { url: runtime.url, authToken: runtime.token },
759
- documents
760
- );
761
- };
915
+ async function handleRefresh(options) {
762
916
  let summary;
763
917
  try {
764
- summary = await evaluateAndPersistAll(ctx.ctx, {
765
- evaluator: sandboxEvaluator
918
+ summary = await evaluateAndPersistAll(options.ctx, {
919
+ collections: options.collections,
920
+ evaluator: options.evaluator,
921
+ blockMappings: options.blockMappings,
922
+ contentFieldsByCollection: options.contentFieldsByCollection,
923
+ // Always forwarded. See RefreshOptions.suppressionFilter.
924
+ suppressionFilter: options.suppressionFilter
766
925
  });
767
926
  } catch (err) {
768
927
  const detail = err instanceof Error ? err.message : String(err);
928
+ const message = `Refresh failed: ${detail}`;
769
929
  return {
770
930
  blocks: [
771
931
  { type: "header", text: "SEO findings" },
772
932
  {
773
933
  type: "banner",
774
- title: `Refresh failed: ${detail}`,
775
- variant: "alert"
934
+ title: message,
935
+ variant: options.tuning.refreshFailureVariant
776
936
  }
777
937
  ],
778
- toast: { message: `Refresh failed: ${detail}`, type: "error" }
938
+ toast: { message, type: "error" }
779
939
  };
780
940
  }
781
- const refreshed = await renderFindingsPage(ctx);
941
+ const refreshed = await renderFindingsPage(options.ctx.kv, options.tuning);
782
942
  const toastMessage = summary.errors.length === 0 ? `Refreshed ${summary.routesUpdated} routes (${summary.totalFindings} findings across ${summary.documentsScanned} documents)` : `Refresh completed with ${summary.errors.length} errors \u2014 see banner`;
783
943
  if (summary.errors.length > 0) {
784
944
  refreshed.blocks.unshift({
@@ -795,10 +955,43 @@ async function handleRefresh(ctx) {
795
955
  }
796
956
  };
797
957
  }
798
- async function renderFindingsPage(ctx) {
799
- const findings = await readAllFindings(ctx.kv);
958
+ async function readAllFindings(kv) {
959
+ const entries = await kv.list(
960
+ "findings:"
961
+ );
962
+ const out = [];
963
+ for (const entry of entries) {
964
+ if (entry.value === null) {
965
+ continue;
966
+ }
967
+ const route = entry.key.replace(/^findings:/, "");
968
+ for (const finding of entry.value.findings) {
969
+ out.push({ ...finding, document_route: route });
970
+ }
971
+ }
972
+ return out;
973
+ }
974
+ function severityFirst(a, b) {
975
+ const rank = (severity) => severity === "error" ? 0 : 1;
976
+ const diff = rank(a.severity) - rank(b.severity);
977
+ if (diff !== 0) {
978
+ return diff;
979
+ }
980
+ return a.rule_id.localeCompare(b.rule_id);
981
+ }
982
+ function uniqueRoutes(rows) {
983
+ const routes = /* @__PURE__ */ new Set();
984
+ for (const row of rows) {
985
+ routes.add(row.document_route);
986
+ }
987
+ return [...routes].sort();
988
+ }
989
+ async function renderFindingsPage(kv, tuning) {
990
+ const findings = await readAllFindings(kv);
800
991
  const errors = findings.filter((finding) => finding.severity === "error");
801
- const warnings = findings.filter((finding) => finding.severity === "warning");
992
+ const warnings = findings.filter(
993
+ (finding) => finding.severity === "warning"
994
+ );
802
995
  const sorted = [...findings].sort(severityFirst);
803
996
  const routes = uniqueRoutes(findings);
804
997
  const blocks = [
@@ -812,8 +1005,8 @@ async function renderFindingsPage(ctx) {
812
1005
  type: "actions",
813
1006
  elements: [
814
1007
  // Refresh evaluates every configured entry. Pressing it lists
815
- // content via the live in-request bridge,
816
- // calls the sidecar /evaluate, and writes findings back to KV.
1008
+ // content via the live in-request bridge, evaluates it, and
1009
+ // writes findings back to KV (with suppressions applied).
817
1010
  {
818
1011
  type: "button",
819
1012
  label: "Refresh",
@@ -831,7 +1024,7 @@ async function renderFindingsPage(ctx) {
831
1024
  },
832
1025
  sorted.length === 0 ? {
833
1026
  type: "context",
834
- text: "Once a document publishes, its rule findings list here."
1027
+ text: tuning.emptyFindingsText
835
1028
  } : findingsTable(sorted)
836
1029
  ];
837
1030
  if (routes.length > 0) {
@@ -850,37 +1043,6 @@ async function renderFindingsPage(ctx) {
850
1043
  }
851
1044
  return { blocks };
852
1045
  }
853
- async function readAllFindings(kv) {
854
- const entries = await kv.list(
855
- "findings:"
856
- );
857
- const out = [];
858
- for (const entry of entries) {
859
- if (entry.value === null) {
860
- continue;
861
- }
862
- const route = entry.key.replace(/^findings:/, "");
863
- for (const finding of entry.value.findings) {
864
- out.push({ ...finding, document_route: route });
865
- }
866
- }
867
- return out;
868
- }
869
- function uniqueRoutes(rows) {
870
- const routes = /* @__PURE__ */ new Set();
871
- for (const row of rows) {
872
- routes.add(row.document_route);
873
- }
874
- return [...routes].sort();
875
- }
876
- function severityFirst(a, b) {
877
- const rank = (severity) => severity === "error" ? 0 : 1;
878
- const diff = rank(a.severity) - rank(b.severity);
879
- if (diff !== 0) {
880
- return diff;
881
- }
882
- return a.rule_id.localeCompare(b.rule_id);
883
- }
884
1046
  function findingsTable(rows) {
885
1047
  return {
886
1048
  type: "table",
@@ -898,50 +1060,61 @@ function findingsTable(rows) {
898
1060
  }))
899
1061
  };
900
1062
  }
901
- async function renderScoreWidget(ctx) {
902
- const documents = await readAllDocuments(ctx.kv);
1063
+ async function renderScoreWidget(kv, tuning, log) {
1064
+ const documents = await readAllDocuments(kv);
903
1065
  if (documents.length === 0) {
904
1066
  return {
905
1067
  blocks: [
906
1068
  { type: "header", text: "SEO score" },
907
1069
  {
908
1070
  type: "context",
909
- text: "No documents saved yet \u2014 score appears after the first emdash save."
1071
+ text: tuning.emptyScoreText
1072
+ }
1073
+ ]
1074
+ };
1075
+ }
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."
910
1095
  }
911
1096
  ]
912
1097
  };
913
1098
  }
914
- const score = await scoreSite(documents);
915
1099
  const blocks = [
916
1100
  {
917
1101
  type: "stats",
918
1102
  items: [
919
- {
920
- label: "Overall",
921
- value: `${score.overall_score}`
922
- },
923
1103
  {
924
1104
  label: "Citation",
925
- value: `${score.citation_readiness_score}`
1105
+ value: score.citation_readiness_score?.toString() ?? "unknown"
926
1106
  },
927
1107
  {
928
- label: "Truth",
929
- value: `${score.truth_consistency_score}`
1108
+ label: tuning.truthLabel(score),
1109
+ value: score.truth_consistency_score?.toString() ?? "unknown"
930
1110
  },
931
1111
  {
932
1112
  label: "Answers",
933
- value: `${score.answer_pack_score}`
1113
+ value: `${score.answer_pack.routes_with_answer_blocks}/${score.answer_pack.routes_analyzed} routes`
934
1114
  }
935
1115
  ]
936
1116
  }
937
1117
  ];
938
- if (score.overall_score < 60) {
939
- blocks.unshift({
940
- type: "banner",
941
- title: `Site score is ${score.overall_score} \u2014 below the 60 quality threshold`,
942
- variant: "alert"
943
- });
944
- }
945
1118
  if (score.blockers.length > 0) {
946
1119
  blocks.push({
947
1120
  type: "context",
@@ -957,7 +1130,7 @@ function topBlockersLine(score) {
957
1130
  }
958
1131
  return `Top blockers: ${top.join(" \u2022 ")}`;
959
1132
  }
960
- async function renderDocumentPanel(ctx, route) {
1133
+ async function renderDocumentPanel(kv, route) {
961
1134
  if (route === void 0) {
962
1135
  return {
963
1136
  blocks: [
@@ -969,9 +1142,11 @@ async function renderDocumentPanel(ctx, route) {
969
1142
  ]
970
1143
  };
971
1144
  }
972
- const findings = await readFindings(ctx.kv, route);
1145
+ const findings = await readFindings(kv, route);
973
1146
  const errors = findings.filter((finding) => finding.severity === "error");
974
- const warnings = findings.filter((finding) => finding.severity === "warning");
1147
+ const warnings = findings.filter(
1148
+ (finding) => finding.severity === "warning"
1149
+ );
975
1150
  const sorted = [...findings].sort(severityFirst);
976
1151
  return {
977
1152
  blocks: [
@@ -1004,15 +1179,198 @@ function documentFindingsTable(findings) {
1004
1179
  }))
1005
1180
  };
1006
1181
  }
1182
+ function notFound(page) {
1183
+ return {
1184
+ blocks: [
1185
+ { type: "header", text: "Not found" },
1186
+ { type: "context", text: `unknown page: ${page}` }
1187
+ ]
1188
+ };
1189
+ }
1190
+
1191
+ // src/suppressions.ts
1192
+ var NOOP_FILTER = {
1193
+ apply: (_context, findings) => [...findings]
1194
+ };
1195
+ function compileSuppressions(rules) {
1196
+ if (rules === void 0 || rules.length === 0) {
1197
+ return NOOP_FILTER;
1198
+ }
1199
+ rules.forEach((rule, index) => {
1200
+ const hasRoute = rule.routePattern !== void 0 && rule.routePattern.length > 0;
1201
+ const hasRuleIds = rule.ruleIds !== void 0 && rule.ruleIds.length > 0;
1202
+ const hasCollections = rule.collections !== void 0 && rule.collections.length > 0;
1203
+ const hasStatuses = rule.statuses !== void 0 && rule.statuses.length > 0;
1204
+ if (!hasRoute && !hasRuleIds && !hasCollections && !hasStatuses) {
1205
+ throw new Error(
1206
+ `aexeoPlugin: suppressions[${index}] is empty \u2014 set at least one of routePattern, ruleIds, collections, statuses`
1207
+ );
1208
+ }
1209
+ });
1210
+ const compiled = rules.map((rule) => ({
1211
+ matchRoute: rule.routePattern === void 0 ? matchAlways : globToMatcher(rule.routePattern),
1212
+ ruleIds: rule.ruleIds === void 0 || rule.ruleIds.length === 0 ? null : new Set(rule.ruleIds),
1213
+ collections: rule.collections === void 0 || rule.collections.length === 0 ? null : new Set(rule.collections),
1214
+ statuses: rule.statuses === void 0 || rule.statuses.length === 0 ? null : new Set(rule.statuses)
1215
+ }));
1216
+ return {
1217
+ apply(context, findings) {
1218
+ return findings.filter(
1219
+ (finding) => !compiled.some((rule) => {
1220
+ if (!rule.matchRoute(context.route)) return false;
1221
+ if (rule.ruleIds !== null && !rule.ruleIds.has(finding.rule_id)) {
1222
+ return false;
1223
+ }
1224
+ if (rule.collections !== null) {
1225
+ if (context.collection === void 0 || !rule.collections.has(context.collection)) {
1226
+ return false;
1227
+ }
1228
+ }
1229
+ if (rule.statuses !== null) {
1230
+ if (context.status === void 0 || !rule.statuses.has(context.status)) {
1231
+ return false;
1232
+ }
1233
+ }
1234
+ return true;
1235
+ })
1236
+ );
1237
+ }
1238
+ };
1239
+ }
1240
+ var matchAlways = () => true;
1241
+ function globToMatcher(pattern) {
1242
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1243
+ const DOUBLE_STAR_TOKEN = "";
1244
+ const body = escaped.replace(/\*\*/g, DOUBLE_STAR_TOKEN).replace(/\*/g, "[^/]*").replace(new RegExp(DOUBLE_STAR_TOKEN, "g"), ".*").replace(/\?/g, "[^/]");
1245
+ const re = new RegExp(`^${body}$`);
1246
+ return (route) => re.test(route);
1247
+ }
1248
+
1249
+ // src/sandbox-entry.ts
1250
+ function definePlugin(plugin) {
1251
+ return plugin;
1252
+ }
1253
+ var sandboxSuppressionFilter = compileSuppressions(
1254
+ void 0
1255
+ );
1256
+ var adminViewTuning = {
1257
+ emptyFindingsText: "Once a document publishes, its rule findings list here.",
1258
+ emptyScoreText: "No documents saved yet \u2014 score appears after the first emdash save.",
1259
+ refreshFailureVariant: "alert",
1260
+ score: async (documents) => scoreSite(documents),
1261
+ truthLabel: () => "Truth"
1262
+ };
1263
+ async function handleAdminRoute(input, ctx) {
1264
+ const body = normalizeInteraction(input?.input);
1265
+ ctx.log?.info?.(
1266
+ `aexeo route: type=${body.type} page=${body.type === "page_load" ? body.page : ""}`
1267
+ );
1268
+ const dispatch = { body, kv: ctx.kv, ctx };
1269
+ if (body.type === "page_load") {
1270
+ return handlePageLoad(dispatch, body.page);
1271
+ }
1272
+ if (body.type === "block_action") {
1273
+ return handleBlockAction(
1274
+ dispatch,
1275
+ body.action_id,
1276
+ body.value
1277
+ );
1278
+ }
1279
+ if (body.type === "form_submit") {
1280
+ return handleFormSubmit(
1281
+ dispatch,
1282
+ body.action_id,
1283
+ body.values
1284
+ );
1285
+ }
1286
+ return handlePageLoad(dispatch, "findings");
1287
+ }
1288
+ async function handleFormSubmit(ctx, actionId, values) {
1289
+ if (actionId === "save_setup") {
1290
+ return handleSetupSubmit(ctx, values);
1291
+ }
1292
+ if (actionId === "view_document") {
1293
+ const picked = values["route_picker"];
1294
+ if (typeof picked === "string" && picked.length > 0) {
1295
+ return renderDocumentPanel(ctx.kv, picked);
1296
+ }
1297
+ }
1298
+ return handlePageLoad(ctx, "findings");
1299
+ }
1300
+ async function handlePageLoad(ctx, page) {
1301
+ const normalized = page.startsWith("/") ? page.slice(1) : page;
1302
+ if (normalized === "findings") {
1303
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1304
+ }
1305
+ if (normalized === "widget:aexeo-score") {
1306
+ return renderScoreWidget(ctx.kv, adminViewTuning);
1307
+ }
1308
+ if (normalized === "document") {
1309
+ return renderDocumentPanel(ctx.kv);
1310
+ }
1311
+ if (normalized === "setup") {
1312
+ return renderSetupPage(ctx);
1313
+ }
1314
+ return notFound(page);
1315
+ }
1316
+ async function handleBlockAction(ctx, actionId, value) {
1317
+ if (actionId === "view_document" && typeof value === "string") {
1318
+ return renderDocumentPanel(ctx.kv, value);
1319
+ }
1320
+ if (actionId === "refresh_findings") {
1321
+ return handleRefresh2(ctx);
1322
+ }
1323
+ if (actionId.startsWith("filter:")) {
1324
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1325
+ }
1326
+ return notFound(actionId);
1327
+ }
1328
+ async function handleRefresh2(ctx) {
1329
+ const sandboxEvaluator = async (documents, context) => {
1330
+ const runtime = await readSidecarConfig(ctx.ctx);
1331
+ if (runtime === null) {
1332
+ return {
1333
+ ok: false,
1334
+ reason: "config_missing",
1335
+ detail: "sidecar not configured \u2014 open the Aexeo Setup page and enter your evaluator URL and token"
1336
+ };
1337
+ }
1338
+ return evaluateViaSidecar(
1339
+ ctx.ctx.http,
1340
+ { url: runtime.url, authToken: runtime.token, log: ctx.ctx.log },
1341
+ documents,
1342
+ context?.collections
1343
+ );
1344
+ };
1345
+ return handleRefresh({
1346
+ ctx: ctx.ctx,
1347
+ // The sandboxed descriptor takes no collection knobs, so the sweep
1348
+ // always covers the default set.
1349
+ collections: DEFAULT_COLLECTIONS,
1350
+ evaluator: sandboxEvaluator,
1351
+ suppressionFilter: sandboxSuppressionFilter,
1352
+ blockMappings: await readSandboxBlockMappings(ctx.ctx),
1353
+ contentFieldsByCollection: await readSandboxContentFields(ctx.ctx),
1354
+ tuning: adminViewTuning
1355
+ });
1356
+ }
1007
1357
  async function renderSetupPage(ctx, options = {}) {
1008
- const existing = await readSidecarConfig(ctx.kv);
1358
+ const existing = await readSidecarConfig(ctx.ctx);
1359
+ const tokenInPlaintext = await isTokenStoredInPlaintext(ctx.ctx);
1009
1360
  const blocks = [
1010
1361
  { type: "header", text: "Aexeo setup" },
1011
1362
  {
1012
1363
  type: "context",
1013
- text: "Paste the URL and auth token of your deployed aexeo-crawl-worker. These are stored in plugin KV and read at runtime \u2014 no rebuild 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."
1014
1365
  }
1015
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
+ }
1016
1374
  if (options.bannerError !== void 0) {
1017
1375
  blocks.push({
1018
1376
  type: "banner",
@@ -1053,44 +1411,28 @@ async function renderSetupPage(ctx, options = {}) {
1053
1411
  { type: "divider" },
1054
1412
  {
1055
1413
  type: "context",
1056
- 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.`
1057
1415
  }
1058
1416
  );
1059
1417
  return { blocks };
1060
1418
  }
1419
+ function runtimeAllowedHosts() {
1420
+ const env = typeof process === "undefined" ? void 0 : process.env;
1421
+ return buildAllowedHosts(env?.["AEXEO_EVALUATOR_HOST"] ?? null);
1422
+ }
1061
1423
  async function handleSetupSubmit(ctx, values) {
1062
1424
  const rawUrl = values["evaluator_url"];
1063
1425
  const rawToken = values["eval_token"];
1064
1426
  const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
1065
1427
  const token = typeof rawToken === "string" ? rawToken.trim() : "";
1066
- if (url.length === 0) {
1067
- return renderSetupPage(ctx, {
1068
- bannerError: "Sidecar URL is required.",
1069
- initialUrl: url
1070
- });
1071
- }
1072
- let parsed;
1073
- try {
1074
- parsed = new URL(url);
1075
- } catch {
1076
- return renderSetupPage(ctx, {
1077
- bannerError: `Sidecar URL is not a valid URL: ${url}`,
1078
- initialUrl: url
1079
- });
1080
- }
1081
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1082
- return renderSetupPage(ctx, {
1083
- bannerError: `Sidecar URL must be http(s): got ${parsed.protocol}`,
1084
- initialUrl: url
1085
- });
1086
- }
1087
- if (parsed.hostname === "localhost" || parsed.hostname.endsWith(".localhost") || /^127\./.test(parsed.hostname)) {
1428
+ const validated = validateSidecarUrl(url, runtimeAllowedHosts());
1429
+ if (!validated.ok) {
1088
1430
  return renderSetupPage(ctx, {
1089
- bannerError: "emdash's sandbox blocks fetches to localhost (anti-SSRF). Use the deployed *.workers.dev URL.",
1431
+ bannerError: validated.error,
1090
1432
  initialUrl: url
1091
1433
  });
1092
1434
  }
1093
- const existing = await readSidecarConfig(ctx.kv);
1435
+ const existing = await readSidecarConfig(ctx.ctx);
1094
1436
  let resolvedToken;
1095
1437
  if (token.length > 0) {
1096
1438
  resolvedToken = token;
@@ -1099,34 +1441,34 @@ async function handleSetupSubmit(ctx, values) {
1099
1441
  } else {
1100
1442
  return renderSetupPage(ctx, {
1101
1443
  bannerError: "EVAL_TOKEN is required on first setup.",
1102
- initialUrl: url
1444
+ initialUrl: validated.url
1445
+ });
1446
+ }
1447
+ const next = {
1448
+ url: validated.url,
1449
+ token: resolvedToken
1450
+ };
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
1103
1458
  });
1104
1459
  }
1105
- const next = { url, token: resolvedToken };
1106
- await writeSidecarConfig(ctx.kv, next);
1107
1460
  return renderSetupPage(ctx, {
1108
1461
  bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
1109
1462
  });
1110
1463
  }
1111
- function notFound(page) {
1112
- return {
1113
- blocks: [
1114
- { type: "header", text: "Not found" },
1115
- { type: "context", text: `unknown page: ${page}` }
1116
- ]
1117
- };
1118
- }
1119
1464
  var sandbox_entry_default = definePlugin({
1120
1465
  hooks: {
1121
- "content:afterSave": handleAfterSave
1466
+ "content:afterSave": handleAfterSave,
1467
+ "content:afterDelete": handleAfterDelete
1122
1468
  },
1123
1469
  routes: {
1124
1470
  admin: { handler: handleAdminRoute }
1125
- },
1126
- // emdash's MCP server picks tools up from this field. The exact
1127
- // host-side spec for plugin-contributed tools is still being mapped;
1128
- // the field is harmless if unrecognized.
1129
- mcpTools: tools
1471
+ }
1130
1472
  });
1131
1473
  export {
1132
1474
  sandbox_entry_default as default