@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
@@ -5,13 +5,15 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/adapter.ts
8
- async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
8
+ async function adaptContentItemWithContext(content, contentApi, defaultLocale, blockMappings, contentFields) {
9
9
  const publicUrl = content.status === "published" ? await contentApi.getPublicUrl(content.type, content.id) : null;
10
10
  const route = publicUrl === null ? deriveRoute(content) : routeFromPublicUrl(publicUrl);
11
11
  const adapted = adaptContentItem(content, {
12
12
  route,
13
13
  publicUrl,
14
- ...defaultLocale === void 0 ? {} : { defaultLocale }
14
+ ...defaultLocale === void 0 ? {} : { defaultLocale },
15
+ ...blockMappings === void 0 ? {} : { blockMappings },
16
+ ...contentFields === void 0 ? {} : { contentFields }
15
17
  });
16
18
  const translationSet = await contentApi.getTranslations(
17
19
  content.type,
@@ -53,8 +55,11 @@ function adaptContentItem(content, options = {}) {
53
55
  if (typeof locale === "string" && locale.length > 0) {
54
56
  document.lang = locale;
55
57
  }
56
- const body = extractPortableText(content.data);
57
- if (body !== null) document.body = body;
58
+ const body = options.contentFields === void 0 ? extractPortableText(content.data, options.blockMappings) : extractSelectedContent(content.data, options.contentFields, options.blockMappings);
59
+ if (body !== null) {
60
+ document.body = body.blocks;
61
+ if (body.capabilities !== void 0) document.body_capabilities = body.capabilities;
62
+ }
58
63
  return {
59
64
  document,
60
65
  meta: {
@@ -63,7 +68,8 @@ function adaptContentItem(content, options = {}) {
63
68
  slug: content.slug,
64
69
  status: content.status,
65
70
  title,
66
- publicUrl: options.publicUrl ?? null
71
+ publicUrl: options.publicUrl ?? null,
72
+ ...content.updatedAt === void 0 ? {} : { updatedAt: content.updatedAt }
67
73
  }
68
74
  };
69
75
  }
@@ -82,24 +88,53 @@ function routeFromPublicUrl(publicUrl) {
82
88
  function stringOrEmpty(value) {
83
89
  return typeof value === "string" ? value : "";
84
90
  }
85
- function extractPortableText(data) {
91
+ function extractSelectedContent(data, fields, mappings) {
92
+ if (fields.length === 0 || new Set(fields).size !== fields.length || fields.some((field) => typeof field !== "string" || field.length === 0)) return null;
93
+ const all = ["text", "headings", "paragraphs", "links"];
94
+ let capabilities = all;
95
+ const blocks = [];
96
+ let observed = false;
97
+ for (const field of fields) {
98
+ const value = data[field];
99
+ if (value === void 0 || value === null) continue;
100
+ observed = true;
101
+ const part = extractFirstPortableText({ content: value }, mappings);
102
+ if (part === null) return { blocks, capabilities: [] };
103
+ blocks.push(...part.blocks);
104
+ capabilities = capabilities.filter((capability) => part.capabilities === void 0 || part.capabilities.includes(capability));
105
+ }
106
+ if (!observed) return null;
107
+ return capabilities.length === all.length ? { blocks } : { blocks, capabilities: [...capabilities] };
108
+ }
109
+ function extractPortableText(data, mappings) {
110
+ const result = extractFirstPortableText(data, mappings);
111
+ if (result === null) return null;
112
+ const candidates = Object.entries(data).filter(
113
+ ([field, value]) => isPortableTextArray(value) || isEmdashBlocksArray(value, field) || (field === "body" || field === "content") && typeof value === "string" && value.trim().length > 0
114
+ );
115
+ return candidates.length > 1 ? { blocks: result.blocks, capabilities: [] } : result;
116
+ }
117
+ function extractFirstPortableText(data, mappings) {
118
+ let foundEmptyBody = false;
86
119
  for (const slug of ["body", "content"]) {
87
120
  const value = data[slug];
88
- if (isPortableTextArray(value)) return value;
89
- if (isEmdashBlocksArray(value, slug)) return blocksToPortableText(value);
121
+ if (Array.isArray(value) && value.length === 0 || typeof value === "string" && value.trim().length === 0) foundEmptyBody = true;
122
+ if (isPortableTextArray(value)) return { blocks: value };
123
+ if (isEmdashBlocksArray(value, slug)) return mapCustomBlocks(value, mappings);
90
124
  if (typeof value === "string" && value.trim().length > 0) {
91
- return [portableTextBlock(value.trim(), `${slug}-text`)];
125
+ return { blocks: [portableTextBlock(value.trim(), `${slug}-text`)], capabilities: ["text"] };
92
126
  }
93
127
  }
94
128
  const blocks = data["blocks"];
95
- if (isPortableTextArray(blocks)) return blocks;
96
- if (isEmdashBlocksArray(blocks, "blocks")) return blocksToPortableText(blocks);
129
+ if (Array.isArray(blocks) && blocks.length === 0) foundEmptyBody = true;
130
+ if (isPortableTextArray(blocks)) return { blocks };
131
+ if (isEmdashBlocksArray(blocks, "blocks")) return mapCustomBlocks(blocks, mappings);
97
132
  for (const [field, value] of Object.entries(data)) {
98
133
  if (field === "body" || field === "content" || field === "blocks") continue;
99
- if (isPortableTextArray(value)) return value;
100
- if (isEmdashBlocksArray(value, field)) return blocksToPortableText(value);
134
+ if (isPortableTextArray(value)) return { blocks: value };
135
+ if (isEmdashBlocksArray(value, field)) return mapCustomBlocks(value, mappings);
101
136
  }
102
- return null;
137
+ return foundEmptyBody ? { blocks: [] } : null;
103
138
  }
104
139
  function isPortableTextArray(value) {
105
140
  return Array.isArray(value) && value.length > 0 && value.every(
@@ -114,6 +149,52 @@ function isEmdashBlocksArray(value, field) {
114
149
  (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks")
115
150
  );
116
151
  }
152
+ function mapCustomBlocks(blocks, mappings) {
153
+ const unavailable = () => ({ blocks: blocksToPortableText(blocks), capabilities: [] });
154
+ const result = [];
155
+ const mapFields = (record, fields, depth) => {
156
+ if (depth > 8 || fields.length === 0) return false;
157
+ for (const entry of fields) {
158
+ if (!entry || typeof entry.field !== "string" || !entry.field || !["heading", "paragraph", "richText", "link", "repeat"].includes(entry.kind)) return false;
159
+ if (entry.kind === "heading" && entry.level !== void 0 && ![1, 2, 3, 4, 5, 6].includes(entry.level)) return false;
160
+ if (entry.kind === "link" && (typeof entry.hrefField !== "string" || !entry.hrefField)) return false;
161
+ if (entry.kind === "repeat" && (!Array.isArray(entry.fields) || entry.fields.length === 0)) return false;
162
+ const value = record[entry.field];
163
+ if ((value === void 0 || value === null || value === "") && entry.optional) {
164
+ if (entry.kind === "link" && record[entry.hrefField] != null && record[entry.hrefField] !== "") return false;
165
+ continue;
166
+ }
167
+ if (entry.kind === "richText") {
168
+ if (!Array.isArray(value) || value.length > 0 && !isPortableTextArray(value)) return false;
169
+ result.push(...value);
170
+ } else if (entry.kind === "heading" || entry.kind === "paragraph") {
171
+ if (typeof value !== "string") return false;
172
+ result.push({ ...portableTextBlock(value, ""), style: entry.kind === "heading" ? `h${entry.level ?? 2}` : "normal" });
173
+ } else if (entry.kind === "link") {
174
+ const href = record[entry.hrefField];
175
+ if (typeof value !== "string" || typeof href !== "string" || !href) return false;
176
+ result.push({
177
+ _type: "block",
178
+ children: [{ _type: "span", text: value, marks: ["link"] }],
179
+ markDefs: [{ _key: "link", _type: "link", href }]
180
+ });
181
+ } else if (entry.kind === "repeat") {
182
+ if (!Array.isArray(value) || !Array.isArray(entry.fields)) return false;
183
+ for (const item of value) {
184
+ if (!isRecord(item) || !mapFields(item, entry.fields, depth + 1)) return false;
185
+ }
186
+ } else {
187
+ return false;
188
+ }
189
+ }
190
+ return true;
191
+ };
192
+ for (const block of blocks) {
193
+ const fields = mappings?.[String(block["_type"])];
194
+ if (!Array.isArray(fields) || !mapFields(block, fields, 0)) return unavailable();
195
+ }
196
+ return { blocks: result };
197
+ }
117
198
  function blocksToPortableText(blocks) {
118
199
  const paragraphs = [];
119
200
  for (const block of blocks) {
@@ -197,35 +278,107 @@ function isRecord(value) {
197
278
  return typeof value === "object" && value !== null && !Array.isArray(value);
198
279
  }
199
280
 
281
+ // src/version.ts
282
+ var PACKAGE_VERSION = "0.9.0";
283
+
200
284
  // src/plugin.ts
201
285
  var SETTINGS_TOKEN_KEY = "eval_token";
286
+ var SETTINGS_BLOCK_MAPPINGS_KEY = "block_mappings";
287
+ var SETTINGS_CONTENT_FIELDS_KEY = "content_fields_by_collection";
202
288
  var PLUGIN_SETTINGS_SCHEMA = {
289
+ [SETTINGS_CONTENT_FIELDS_KEY]: {
290
+ type: "string",
291
+ label: "Content fields by collection (JSON)",
292
+ description: "Ordered rendered body fields per collection.",
293
+ multiline: true
294
+ },
295
+ [SETTINGS_BLOCK_MAPPINGS_KEY]: {
296
+ type: "string",
297
+ label: "Custom block mappings (JSON)",
298
+ description: "Ordered mappings for the rendered fields of each custom block type.",
299
+ multiline: true
300
+ },
203
301
  [SETTINGS_TOKEN_KEY]: {
204
302
  type: "secret",
205
303
  label: "EVAL_TOKEN",
206
304
  description: "Bearer token for your aexeo-crawl-worker Worker. Encrypted at rest by the emdash host."
207
305
  }
208
306
  };
209
- async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter) {
307
+ function recordContentCoverage(ctx, documents) {
308
+ const counts = { documents: documents.length, complete: 0, partial: 0, unavailable: 0 };
309
+ for (const document of documents) {
310
+ if (document.body === void 0 || document.body_capabilities?.length === 0) counts.unavailable++;
311
+ else if (document.body_capabilities === void 0 || ["text", "headings", "paragraphs", "links"].every(
312
+ (capability) => document.body_capabilities?.includes(capability)
313
+ )) counts.complete++;
314
+ else counts.partial++;
315
+ }
316
+ try {
317
+ ctx.log?.info?.("aexeo.content_coverage", counts);
318
+ } catch {
319
+ }
320
+ }
321
+ async function handleAfterDelete(event, ctx) {
322
+ for (const entry of await ctx.kv.list("document:")) {
323
+ const stored = toStoredDocument(entry.value);
324
+ if (stored !== null && stored.meta.id === event.id && stored.meta.collection === event.collection) {
325
+ await ctx.kv.delete(entry.key);
326
+ await ctx.kv.delete(findingsKey(stored.document.route));
327
+ }
328
+ }
329
+ }
330
+ var SITE_GRAPH_RULE_IDS = /* @__PURE__ */ new Set([
331
+ "LNK001",
332
+ // internal link target missing from the site
333
+ "LNK002",
334
+ // no inbound links
335
+ "LNK003",
336
+ // weak anchor, checked only when the target route exists
337
+ "LNK004",
338
+ // too few inbound links
339
+ "SEO009",
340
+ // hreflang alternate target missing from the site
341
+ "SEO012",
342
+ // hreflang alternate does not link back
343
+ "SEO016",
344
+ // canonical points at another crawlable route
345
+ "SEO017",
346
+ // title and description shared with another route
347
+ "CNT005"
348
+ // visible text duplicated on another route
349
+ ]);
350
+ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter, blockMappings, contentFields) {
210
351
  const adapted = await adaptContentItemWithContext(
211
352
  event.content,
212
353
  ctx.content,
213
- ctx.site?.locale
354
+ ctx.site?.locale,
355
+ blockMappings,
356
+ contentFields
214
357
  );
215
358
  const document = adapted.document;
359
+ recordContentCoverage(ctx, [document]);
216
360
  const { kv, log } = ctx;
217
361
  const stored = { document, meta: adapted.meta };
218
- await persistDocument(ctx, stored);
219
- const result = await evaluator([document]);
362
+ if (!await persistDocument(ctx, stored)) {
363
+ log?.info?.(`Aexeo afterSave skipped: ${document.route} has a newer save`);
364
+ return;
365
+ }
366
+ const result = await evaluator([document], { collections: [adapted.meta.collection] });
220
367
  if (!result.ok) {
221
368
  log?.error?.(`afterSave evaluator failure (${result.reason})`, {
222
369
  detail: result.detail
223
370
  });
224
371
  return;
225
372
  }
226
- const pageFindings = result.findings.filter(
227
- (finding) => finding.scope === "page"
373
+ const storedGraphFindings = (await readFindings(kv, document.route)).filter(
374
+ (finding) => SITE_GRAPH_RULE_IDS.has(finding.rule_id)
228
375
  );
376
+ const pageFindings = [
377
+ ...result.findings.filter(
378
+ (finding) => finding.scope === "page" && !SITE_GRAPH_RULE_IDS.has(finding.rule_id)
379
+ ),
380
+ ...storedGraphFindings
381
+ ];
229
382
  const filtered = suppressionFilter === void 0 ? pageFindings : suppressionFilter.apply(
230
383
  {
231
384
  route: document.route,
@@ -234,6 +387,10 @@ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilte
234
387
  },
235
388
  pageFindings
236
389
  );
390
+ if (await isSuperseded(kv, document.route, adapted.meta.updatedAt)) {
391
+ log?.info?.(`Aexeo afterSave discarded: ${document.route} changed while evaluating`);
392
+ return;
393
+ }
237
394
  await kv.set(findingsKey(document.route), {
238
395
  route: document.route,
239
396
  findings: filtered
@@ -250,7 +407,9 @@ async function evaluateAndPersistAll(ctx, options) {
250
407
  totalFindings: 0,
251
408
  errors: []
252
409
  };
410
+ const storedAtStart = await readAllStoredDocuments(kv);
253
411
  const documents = [];
412
+ const documentCollections = [];
254
413
  const adaptedByRoute = /* @__PURE__ */ new Map();
255
414
  const documentRoutes = /* @__PURE__ */ new Set();
256
415
  for (const collection of collections) {
@@ -265,9 +424,12 @@ async function evaluateAndPersistAll(ctx, options) {
265
424
  const adapted = await adaptContentItemWithContext(
266
425
  item,
267
426
  ctx.content,
268
- ctx.site?.locale
427
+ ctx.site?.locale,
428
+ options.blockMappings,
429
+ options.contentFieldsByCollection?.[collection]
269
430
  );
270
431
  documents.push(adapted.document);
432
+ documentCollections.push(adapted.meta.collection);
271
433
  documentRoutes.add(adapted.document.route);
272
434
  adaptedByRoute.set(adapted.document.route, {
273
435
  document: adapted.document,
@@ -283,10 +445,14 @@ async function evaluateAndPersistAll(ctx, options) {
283
445
  } while (cursor !== void 0);
284
446
  }
285
447
  summary.documentsScanned = documents.length;
448
+ recordContentCoverage(ctx, documents);
286
449
  for (const stored of adaptedByRoute.values()) {
287
- await kv.set(documentKey(stored.document.route), stored);
450
+ const current = await readStoredDocument(kv, stored.document.route);
451
+ if (!isOlderRevision(stored.meta.updatedAt, current?.meta.updatedAt)) {
452
+ await kv.set(documentKey(stored.document.route), stored);
453
+ }
288
454
  }
289
- const result = await options.evaluator(documents);
455
+ const result = await options.evaluator(documents, { collections: documentCollections });
290
456
  if (!result.ok) {
291
457
  log?.error?.(`aexeo evaluator failure (${result.reason})`, {
292
458
  detail: result.detail
@@ -307,6 +473,9 @@ async function evaluateAndPersistAll(ctx, options) {
307
473
  let totalAfterSuppression = 0;
308
474
  for (const [route, findings] of findingsByRoute) {
309
475
  const stored = adaptedByRoute.get(route);
476
+ if (stored !== void 0 && await isSuperseded(kv, route, stored.meta.updatedAt)) {
477
+ continue;
478
+ }
310
479
  const context = {
311
480
  route,
312
481
  ...stored?.meta.collection === void 0 ? {} : { collection: stored.meta.collection },
@@ -319,29 +488,52 @@ async function evaluateAndPersistAll(ctx, options) {
319
488
  }
320
489
  summary.totalFindings = totalAfterSuppression;
321
490
  if (summary.errors.length === 0) {
322
- await removeStaleDocuments(ctx, collections, documentRoutes);
491
+ await removeStaleDocuments(ctx, collections, documentRoutes, storedAtStart);
323
492
  }
324
493
  return summary;
325
494
  }
495
+ function isOlderRevision(revision, other) {
496
+ if (revision === void 0 || other === void 0) return false;
497
+ return Date.parse(revision) < Date.parse(other);
498
+ }
499
+ async function readStoredDocument(kv, route) {
500
+ return toStoredDocument(await kv.get(documentKey(route)));
501
+ }
502
+ async function isSuperseded(kv, route, revision) {
503
+ const current = await readStoredDocument(kv, route);
504
+ return current === null || isOlderRevision(revision, current.meta.updatedAt);
505
+ }
326
506
  async function persistDocument(ctx, stored) {
327
507
  const route = stored.document.route;
328
- const entries = await ctx.kv.list("document:");
329
- for (const entry of entries) {
508
+ const sameEntry = (await ctx.kv.list("document:")).flatMap((entry) => {
330
509
  const previous = toStoredDocument(entry.value);
331
- if (previous === null || previous.document.route === route || previous.meta.id !== stored.meta.id || previous.meta.collection !== stored.meta.collection) {
332
- continue;
333
- }
334
- await ctx.kv.delete(entry.key);
510
+ return previous !== null && previous.meta.id === stored.meta.id && previous.meta.collection === stored.meta.collection ? [{ key: entry.key, previous }] : [];
511
+ });
512
+ if (sameEntry.some(
513
+ ({ previous }) => isOlderRevision(stored.meta.updatedAt, previous.meta.updatedAt)
514
+ )) {
515
+ return false;
516
+ }
517
+ for (const { key, previous } of sameEntry) {
518
+ if (previous.document.route === route) continue;
519
+ await ctx.kv.delete(key);
335
520
  await ctx.kv.delete(findingsKey(previous.document.route));
336
521
  }
337
522
  await ctx.kv.set(documentKey(route), stored);
523
+ return true;
338
524
  }
339
- async function removeStaleDocuments(ctx, collections, currentRoutes) {
525
+ async function removeStaleDocuments(ctx, collections, currentRoutes, storedAtStart) {
340
526
  const scannedCollections = new Set(collections);
527
+ const revisionAtStart = new Map(
528
+ storedAtStart.map((stored) => [
529
+ stored.document.route,
530
+ stored.meta.updatedAt
531
+ ])
532
+ );
341
533
  const entries = await ctx.kv.list("document:");
342
534
  for (const entry of entries) {
343
535
  const stored = toStoredDocument(entry.value);
344
- if (stored === null || !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route)) {
536
+ 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) {
345
537
  continue;
346
538
  }
347
539
  await ctx.kv.delete(entry.key);
@@ -438,6 +630,8 @@ async function handleRefresh(options) {
438
630
  summary = await evaluateAndPersistAll(options.ctx, {
439
631
  collections: options.collections,
440
632
  evaluator: options.evaluator,
633
+ blockMappings: options.blockMappings,
634
+ contentFieldsByCollection: options.contentFieldsByCollection,
441
635
  // Always forwarded. See RefreshOptions.suppressionFilter.
442
636
  suppressionFilter: options.suppressionFilter
443
637
  });
@@ -578,7 +772,7 @@ function findingsTable(rows) {
578
772
  }))
579
773
  };
580
774
  }
581
- async function renderScoreWidget(kv, tuning) {
775
+ async function renderScoreWidget(kv, tuning, log) {
582
776
  const documents = await readAllDocuments(kv);
583
777
  if (documents.length === 0) {
584
778
  return {
@@ -591,28 +785,48 @@ async function renderScoreWidget(kv, tuning) {
591
785
  ]
592
786
  };
593
787
  }
594
- const score = await tuning.score(documents, kv);
788
+ let score;
789
+ try {
790
+ score = await tuning.score(documents, kv);
791
+ } catch (error) {
792
+ log?.error("Aexeo score widget failed to initialize", { error });
793
+ return {
794
+ blocks: [
795
+ { type: "banner", title: "SEO score is temporarily unavailable", variant: "alert" },
796
+ { type: "context", text: "The authoring interface remains available. Retry after the evaluator runtime has been checked." }
797
+ ]
798
+ };
799
+ }
800
+ if (score === null) {
801
+ return {
802
+ blocks: [
803
+ { type: "header", text: "SEO score" },
804
+ {
805
+ type: "context",
806
+ text: "The site score is unavailable in sandboxed mode. Findings are not affected."
807
+ }
808
+ ]
809
+ };
810
+ }
595
811
  const blocks = [
596
812
  {
597
813
  type: "stats",
598
814
  items: [
599
- { label: "Overall", value: `${score.overall_score}` },
600
- { label: "Citation", value: `${score.citation_readiness_score}` },
815
+ {
816
+ label: "Citation",
817
+ value: score.citation_readiness_score?.toString() ?? "unknown"
818
+ },
601
819
  {
602
820
  label: tuning.truthLabel(score),
603
- value: `${score.truth_consistency_score}`
821
+ value: score.truth_consistency_score?.toString() ?? "unknown"
604
822
  },
605
- { label: "Answers", value: `${score.answer_pack_score}` }
823
+ {
824
+ label: "Answers",
825
+ value: `${score.answer_pack.routes_with_answer_blocks}/${score.answer_pack.routes_analyzed} routes`
826
+ }
606
827
  ]
607
828
  }
608
829
  ];
609
- if (score.overall_score < 60) {
610
- blocks.unshift({
611
- type: "banner",
612
- title: `Site score is ${score.overall_score} \u2014 below the 60 quality threshold`,
613
- variant: "alert"
614
- });
615
- }
616
830
  if (score.blockers.length > 0) {
617
831
  blocks.push({
618
832
  type: "context",
@@ -695,11 +909,65 @@ __export(aexeo_emdash_bridge_bg_exports, {
695
909
  __wbg_set_wasm: () => __wbg_set_wasm,
696
910
  __wbg_stack_3b0d974bbf31e44f: () => __wbg_stack_3b0d974bbf31e44f,
697
911
  __wbindgen_init_externref_table: () => __wbindgen_init_externref_table,
912
+ evaluateContent: () => evaluateContent,
913
+ evaluateContentCollection: () => evaluateContentCollection,
698
914
  evaluateDocuments: () => evaluateDocuments,
699
915
  generateFactsPrompt: () => generateFactsPrompt,
700
916
  scoreIntelligence: () => scoreIntelligence,
701
917
  validateFactsManifest: () => validateFactsManifest
702
918
  });
919
+ function evaluateContent(document_json, preset_name, sources_json, evidence_json) {
920
+ let deferred6_0;
921
+ let deferred6_1;
922
+ try {
923
+ const ptr0 = passStringToWasm0(document_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
924
+ const len0 = WASM_VECTOR_LEN;
925
+ const ptr1 = passStringToWasm0(preset_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
926
+ const len1 = WASM_VECTOR_LEN;
927
+ var ptr2 = isLikeNone(sources_json) ? 0 : passStringToWasm0(sources_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
928
+ var len2 = WASM_VECTOR_LEN;
929
+ var ptr3 = isLikeNone(evidence_json) ? 0 : passStringToWasm0(evidence_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
930
+ var len3 = WASM_VECTOR_LEN;
931
+ const ret = wasm.evaluateContent(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3);
932
+ var ptr5 = ret[0];
933
+ var len5 = ret[1];
934
+ if (ret[3]) {
935
+ ptr5 = 0;
936
+ len5 = 0;
937
+ throw takeFromExternrefTable0(ret[2]);
938
+ }
939
+ deferred6_0 = ptr5;
940
+ deferred6_1 = len5;
941
+ return getStringFromWasm0(ptr5, len5);
942
+ } finally {
943
+ wasm.__wbindgen_free(deferred6_0, deferred6_1, 1);
944
+ }
945
+ }
946
+ function evaluateContentCollection(documents_json, preset_name, sources_json) {
947
+ let deferred5_0;
948
+ let deferred5_1;
949
+ try {
950
+ const ptr0 = passStringToWasm0(documents_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
951
+ const len0 = WASM_VECTOR_LEN;
952
+ const ptr1 = passStringToWasm0(preset_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
953
+ const len1 = WASM_VECTOR_LEN;
954
+ var ptr2 = isLikeNone(sources_json) ? 0 : passStringToWasm0(sources_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
955
+ var len2 = WASM_VECTOR_LEN;
956
+ const ret = wasm.evaluateContentCollection(ptr0, len0, ptr1, len1, ptr2, len2);
957
+ var ptr4 = ret[0];
958
+ var len4 = ret[1];
959
+ if (ret[3]) {
960
+ ptr4 = 0;
961
+ len4 = 0;
962
+ throw takeFromExternrefTable0(ret[2]);
963
+ }
964
+ deferred5_0 = ptr4;
965
+ deferred5_1 = len4;
966
+ return getStringFromWasm0(ptr4, len4);
967
+ } finally {
968
+ wasm.__wbindgen_free(deferred5_0, deferred5_1, 1);
969
+ }
970
+ }
703
971
  function evaluateDocuments(documents_json, config_json) {
704
972
  let deferred4_0;
705
973
  let deferred4_1;
@@ -969,6 +1237,10 @@ async function validateFactsManifest2(manifestJson, documentsJson) {
969
1237
  await ensureInitialized();
970
1238
  return validateFactsManifest(manifestJson, documentsJson);
971
1239
  }
1240
+ async function evaluateContent2(documentJson, presetName, sourcesJson, evidenceJson) {
1241
+ await ensureInitialized();
1242
+ return evaluateContent(documentJson, presetName, sourcesJson, evidenceJson);
1243
+ }
972
1244
 
973
1245
  // src/data-route.ts
974
1246
  async function handleDataRoute(ctx, options) {
@@ -977,6 +1249,8 @@ async function handleDataRoute(ctx, options) {
977
1249
  summary = await evaluateAndPersistAll(ctx, {
978
1250
  collections: options.collections,
979
1251
  evaluator: options.evaluator,
1252
+ blockMappings: options.blockMappings,
1253
+ contentFieldsByCollection: options.contentFieldsByCollection,
980
1254
  ...options.suppressionFilter === void 0 ? {} : { suppressionFilter: options.suppressionFilter }
981
1255
  });
982
1256
  await persistManifestStateFindings(ctx);
@@ -1245,12 +1519,16 @@ async function handleSave(ctx, body) {
1245
1519
  return { saved: true };
1246
1520
  }
1247
1521
 
1248
- // src/presence-route.ts
1249
- import { PluginRouteError as PluginRouteError2 } from "emdash";
1250
-
1251
1522
  // src/presence-check.ts
1252
1523
  var FETCH_TIMEOUT_MS = 5e3;
1253
1524
  var USER_AGENT = "aexeo-emdash-plugin/0.8 (+https://github.com/schiste/Aexeo; entity-presence diagnostic)";
1525
+ var PRESENCE_HOSTS = [
1526
+ "en.wikipedia.org",
1527
+ "www.wikidata.org",
1528
+ "api.github.com",
1529
+ "rdap.org",
1530
+ "index.commoncrawl.org"
1531
+ ];
1254
1532
  var SOURCE_NAMES = [
1255
1533
  "wikipedia",
1256
1534
  "wikidata",
@@ -1307,7 +1585,7 @@ async function checkWikipedia(input, fetcher) {
1307
1585
  return notFound2("wikipedia");
1308
1586
  }
1309
1587
  const title = String(titles[0]);
1310
- if (!fuzzyMatch(title, input.name)) {
1588
+ if (!namesEntity(title, input)) {
1311
1589
  return notFound2("wikipedia");
1312
1590
  }
1313
1591
  const articleUrl = String(urls[0] ?? "");
@@ -1337,7 +1615,7 @@ async function checkWikidata(input, fetcher) {
1337
1615
  return notFound2("wikidata");
1338
1616
  }
1339
1617
  const label = top.label ?? input.name;
1340
- if (!fuzzyMatch(label, input.name)) {
1618
+ if (!namesEntity(label, input)) {
1341
1619
  return notFound2("wikidata");
1342
1620
  }
1343
1621
  return found("wikidata", {
@@ -1426,9 +1704,27 @@ async function checkCommonCrawl(input, fetcher) {
1426
1704
  "no website URL in manifest, can't query crawl index"
1427
1705
  );
1428
1706
  }
1429
- const index = "CC-MAIN-2026-15";
1707
+ let index;
1708
+ try {
1709
+ const res = await timedFetch(
1710
+ fetcher,
1711
+ "https://index.commoncrawl.org/collinfo.json"
1712
+ );
1713
+ if (!res.ok) return unreachable("common_crawl", `HTTP ${res.status}`);
1714
+ const newest = newestCrawlId(await res.json());
1715
+ if (newest === null) {
1716
+ return unreachable(
1717
+ "common_crawl",
1718
+ "no crawl index listed in collinfo.json"
1719
+ );
1720
+ }
1721
+ index = newest;
1722
+ } catch (error) {
1723
+ return unreachable("common_crawl", errMsg(error));
1724
+ }
1430
1725
  const url = new URL(`https://index.commoncrawl.org/${index}-index`);
1431
1726
  url.searchParams.set("url", host);
1727
+ url.searchParams.set("matchType", "domain");
1432
1728
  url.searchParams.set("output", "json");
1433
1729
  url.searchParams.set("limit", "1");
1434
1730
  try {
@@ -1455,6 +1751,16 @@ async function checkCommonCrawl(input, fetcher) {
1455
1751
  return unreachable("common_crawl", errMsg(error));
1456
1752
  }
1457
1753
  }
1754
+ function newestCrawlId(collinfo) {
1755
+ if (!Array.isArray(collinfo)) return null;
1756
+ let newest = null;
1757
+ for (const crawl of collinfo) {
1758
+ const id = crawl?.id;
1759
+ if (typeof id !== "string" || !/^CC-MAIN-[A-Za-z0-9-]+$/.test(id)) continue;
1760
+ if (newest === null || id > newest) newest = id;
1761
+ }
1762
+ return newest;
1763
+ }
1458
1764
  async function timedFetch(fetcher, url, init) {
1459
1765
  const controller = new AbortController();
1460
1766
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -1500,10 +1806,45 @@ function errMsg(error) {
1500
1806
  }
1501
1807
  return String(error);
1502
1808
  }
1503
- function fuzzyMatch(a, b) {
1504
- const aL = a.toLowerCase().trim();
1505
- const bL = b.toLowerCase().trim();
1506
- return aL.includes(bL) || bL.includes(aL);
1809
+ function namesEntity(candidate, input) {
1810
+ const normalized = normalizeEntityName(candidate);
1811
+ return [input.name, ...input.aliases ?? []].some(
1812
+ (name) => normalizeEntityName(name) === normalized
1813
+ );
1814
+ }
1815
+ var LEGAL_FORMS = /* @__PURE__ */ new Set([
1816
+ "ab",
1817
+ "ag",
1818
+ "bv",
1819
+ "co",
1820
+ "company",
1821
+ "corp",
1822
+ "corporation",
1823
+ "gmbh",
1824
+ "inc",
1825
+ "incorporated",
1826
+ "kk",
1827
+ "limited",
1828
+ "llc",
1829
+ "llp",
1830
+ "lp",
1831
+ "ltd",
1832
+ "nv",
1833
+ "oy",
1834
+ "plc",
1835
+ "pty",
1836
+ "sa",
1837
+ "sarl",
1838
+ "sas",
1839
+ "srl"
1840
+ ]);
1841
+ function normalizeEntityName(name) {
1842
+ const words = name.replace(/\([^)]*\)/g, " ").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 0);
1843
+ let end = words.length;
1844
+ while (end > 0 && LEGAL_FORMS.has(words[end - 1])) end -= 1;
1845
+ const start = words[0] === "the" ? 1 : 0;
1846
+ const core = words.slice(start, end);
1847
+ return (core.length > 0 ? core : words).join(" ");
1507
1848
  }
1508
1849
  function sanitizeGitHubHandle(name) {
1509
1850
  const handle = name.replace(/[\s_]+/g, "-");
@@ -1542,6 +1883,7 @@ function formatCdxTimestamp(ts) {
1542
1883
  }
1543
1884
 
1544
1885
  // src/presence-route.ts
1886
+ import { PluginRouteError as PluginRouteError2 } from "emdash";
1545
1887
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1546
1888
  var PRESENCE_KEY = "presence:current";
1547
1889
  async function handlePresenceRoute(ctx) {
@@ -1664,8 +2006,61 @@ function globToMatcher(pattern) {
1664
2006
  return (route) => re.test(route);
1665
2007
  }
1666
2008
 
1667
- // src/version.ts
1668
- var PACKAGE_VERSION = "0.8.19";
2009
+ // src/content.ts
2010
+ async function evaluateContent3(document, preset, sources = {}, evidenceSources, onTelemetry) {
2011
+ const started = onTelemetry ? performance.now() : 0;
2012
+ const documentJson = JSON.stringify(document);
2013
+ const sourcesJson = JSON.stringify(sources);
2014
+ const result = evidenceSources === void 0 ? await evaluateContent2(documentJson, preset, sourcesJson) : await evaluateContent2(documentJson, preset, sourcesJson, JSON.stringify(evidenceSources));
2015
+ const report = JSON.parse(result);
2016
+ if (onTelemetry) {
2017
+ const event = { evaluation_ms: performance.now() - started, source_locations_mapped: report.source_locations?.length ?? 0 };
2018
+ for (const gate of report.gates) {
2019
+ const checked = gate.measurements?.checked_destinations;
2020
+ const invalid = gate.measurements?.invalid_destinations;
2021
+ if (checked !== void 0 && invalid !== void 0) {
2022
+ event.link_destinations ??= { checked: 0, invalid: 0 };
2023
+ event.link_destinations.checked += checked;
2024
+ event.link_destinations.invalid += invalid;
2025
+ }
2026
+ }
2027
+ onTelemetry(event);
2028
+ }
2029
+ return report;
2030
+ }
2031
+
2032
+ // src/content-evaluator.ts
2033
+ function withContentPreset(base, preset, evaluate) {
2034
+ if (!preset) return base;
2035
+ return async (documents, context) => {
2036
+ const result = await base(documents, context);
2037
+ if (!result.ok) return result;
2038
+ const findings = [...result.findings];
2039
+ try {
2040
+ for (const [index, document] of documents.entries()) {
2041
+ const collection = context?.collections[index];
2042
+ if (preset.collections !== void 0) {
2043
+ if (collection === void 0) throw new Error("collection is required for a scoped content preset");
2044
+ if (!preset.collections.includes(collection)) continue;
2045
+ }
2046
+ const selected = collection !== void 0 && preset.byCollection !== void 0 && Object.hasOwn(preset.byCollection, collection) ? preset.byCollection[collection] : void 0;
2047
+ const name = selected ?? preset.name;
2048
+ if (!name) throw new Error(`empty preset name for collection ${collection}`);
2049
+ const report = preset.evidenceSources === void 0 ? await evaluate(document, name, preset.sources) : await evaluate(document, name, preset.sources, preset.evidenceSources);
2050
+ const incomplete = report.gates.filter((gate) => gate.status === "not_checked");
2051
+ if (incomplete.length) return {
2052
+ ok: false,
2053
+ reason: "content_preset_incomplete",
2054
+ detail: `${document.route}: ${incomplete.map((gate) => `${gate.gate_id}: ${gate.reason}`).join(", ")}`
2055
+ };
2056
+ for (const gate of report.gates) findings.push(...gate.findings);
2057
+ }
2058
+ return { ok: true, findings };
2059
+ } catch (error) {
2060
+ return { ok: false, reason: "content_preset_error", detail: error instanceof Error ? error.message : String(error) };
2061
+ }
2062
+ };
2063
+ }
1669
2064
 
1670
2065
  // src/configured.ts
1671
2066
  import { definePlugin } from "emdash";
@@ -1731,10 +2126,15 @@ function createPlugin(options = {}) {
1731
2126
  options.excludeCollections
1732
2127
  );
1733
2128
  const suppressionFilter = compileSuppressions(options.suppressions);
2129
+ const evaluator = withContentPreset(inProcessEvaluator, options.contentPreset, evaluateContent3);
1734
2130
  return definePlugin({
1735
2131
  id: "aexeo-emdash",
1736
2132
  version: PACKAGE_VERSION,
1737
- capabilities: ["content:read"],
2133
+ // The entity-presence route fetches its public sources directly (see
2134
+ // presence-check.ts). rdap.org then redirects to the registry for the
2135
+ // domain's TLD; those registries cannot be listed here.
2136
+ capabilities: ["content:read", "network:request"],
2137
+ allowedHosts: [...PRESENCE_HOSTS],
1738
2138
  hooks: {
1739
2139
  // afterSave processes one saved document at a time — collections
1740
2140
  // list isn't needed here. The hook always runs regardless of
@@ -1742,16 +2142,26 @@ function createPlugin(options = {}) {
1742
2142
  "content:afterSave": (event, ctx) => handleAfterSaveConfigured(
1743
2143
  event,
1744
2144
  ctx,
1745
- inProcessEvaluator,
1746
- suppressionFilter
1747
- )
2145
+ evaluator,
2146
+ suppressionFilter,
2147
+ options.blockMappings,
2148
+ options.contentFieldsByCollection?.[event.content.type]
2149
+ ),
2150
+ "content:afterDelete": (event, ctx) => handleAfterDelete(event, ctx)
1748
2151
  },
2152
+ // emdash requires plugins:manage (admin) on a route that declares no
2153
+ // permission. Reading findings only needs plugins:read (editor); anything
2154
+ // that writes stays admin, stated explicitly so the split is visible.
1749
2155
  routes: {
1750
2156
  // The Refresh sweep (handleAdminRoute → handleRefresh) is the
1751
2157
  // one consumer of `collections`. Bind the resolved list here so
1752
2158
  // every request handles the same set without re-reading options.
2159
+ //
2160
+ // This route serves the pages and the dashboard widget, and also the
2161
+ // refresh_findings action, which handleAdminRoute gates itself.
1753
2162
  admin: {
1754
- handler: (ctx) => handleAdminRoute(ctx, collections, suppressionFilter)
2163
+ permission: "plugins:read",
2164
+ handler: (ctx) => handleAdminRoute(ctx, collections, suppressionFilter, evaluator, options.blockMappings, options.contentFieldsByCollection)
1755
2165
  },
1756
2166
  // JSON data endpoint for the React adminEntry. Two routes:
1757
2167
  // POST /_emdash/api/plugins/aexeo-emdash/data — read current findings
@@ -1761,19 +2171,25 @@ function createPlugin(options = {}) {
1761
2171
  // emdash edit page and the public site, which Block Kit can't
1762
2172
  // express in any element type as of emdash 0.8.0.
1763
2173
  data: {
2174
+ permission: "plugins:read",
1764
2175
  handler: async (ctx) => (await handleDataRoute(ctx, {
1765
2176
  collections,
1766
- evaluator: inProcessEvaluator,
2177
+ evaluator,
1767
2178
  refresh: false,
1768
- suppressionFilter
2179
+ suppressionFilter,
2180
+ blockMappings: options.blockMappings,
2181
+ contentFieldsByCollection: options.contentFieldsByCollection
1769
2182
  })).payload
1770
2183
  },
1771
2184
  refresh: {
2185
+ permission: "plugins:manage",
1772
2186
  handler: (ctx) => handleDataRoute(ctx, {
1773
2187
  collections,
1774
- evaluator: inProcessEvaluator,
2188
+ evaluator,
1775
2189
  refresh: true,
1776
- suppressionFilter
2190
+ suppressionFilter,
2191
+ blockMappings: options.blockMappings,
2192
+ contentFieldsByCollection: options.contentFieldsByCollection
1777
2193
  })
1778
2194
  },
1779
2195
  // /facts route serves the truth-manifest authoring UI. It
@@ -1781,6 +2197,7 @@ function createPlugin(options = {}) {
1781
2197
  // onto one POST endpoint via a "kind" body field — see
1782
2198
  // facts-route.ts for the full contract.
1783
2199
  facts: {
2200
+ permission: "plugins:manage",
1784
2201
  handler: (ctx) => handleFactsRoute(ctx)
1785
2202
  },
1786
2203
  // /presence route serves the layer-4 entity-presence
@@ -1790,6 +2207,7 @@ function createPlugin(options = {}) {
1790
2207
  // results in KV for 24h. Multiplexes data / refresh kinds —
1791
2208
  // see presence-route.ts.
1792
2209
  presence: {
2210
+ permission: "plugins:manage",
1793
2211
  handler: (ctx) => handlePresenceRoute(ctx)
1794
2212
  }
1795
2213
  },
@@ -1821,6 +2239,7 @@ function createPlugin(options = {}) {
1821
2239
  });
1822
2240
  }
1823
2241
  var configured_default = createPlugin;
2242
+ var ADMIN_ROLE = 50;
1824
2243
  var adminViewTuning = {
1825
2244
  emptyFindingsText: "Once a Refresh runs, findings will list here.",
1826
2245
  emptyScoreText: "No documents indexed \u2014 click Refresh on the Aexeo findings page.",
@@ -1828,7 +2247,7 @@ var adminViewTuning = {
1828
2247
  score: async (documents, kv) => scoreLocally(documents, await readStoredFacts(kv)),
1829
2248
  truthLabel: formatTruthLabel
1830
2249
  };
1831
- async function handleAdminRoute(ctx, collections, suppressionFilter) {
2250
+ async function handleAdminRoute(ctx, collections, suppressionFilter, evaluator, blockMappings, contentFieldsByCollection) {
1832
2251
  const body = normalizeInteraction(ctx.input);
1833
2252
  if (body.type === "page_load") {
1834
2253
  return handlePageLoad(ctx, body.page);
@@ -1838,12 +2257,28 @@ async function handleAdminRoute(ctx, collections, suppressionFilter) {
1838
2257
  return renderDocumentPanel(ctx.kv, body.value);
1839
2258
  }
1840
2259
  if (body.action_id === "refresh_findings") {
2260
+ if ((ctx.user?.role ?? 0) < ADMIN_ROLE) {
2261
+ const page = await renderFindingsPage(ctx.kv, adminViewTuning);
2262
+ return {
2263
+ ...page,
2264
+ blocks: [
2265
+ {
2266
+ type: "banner",
2267
+ title: "Only administrators can run Refresh.",
2268
+ variant: "error"
2269
+ },
2270
+ ...page.blocks
2271
+ ]
2272
+ };
2273
+ }
1841
2274
  return handleRefresh({
1842
2275
  ctx,
1843
2276
  collections,
1844
- evaluator: inProcessEvaluator,
2277
+ evaluator,
1845
2278
  suppressionFilter,
1846
- tuning: adminViewTuning
2279
+ tuning: adminViewTuning,
2280
+ blockMappings,
2281
+ contentFieldsByCollection
1847
2282
  });
1848
2283
  }
1849
2284
  if (body.action_id.startsWith("filter:")) {
@@ -1868,7 +2303,7 @@ async function handlePageLoad(ctx, page) {
1868
2303
  return renderFindingsPage(ctx.kv, adminViewTuning);
1869
2304
  }
1870
2305
  if (normalized === "widget:aexeo-score") {
1871
- return renderScoreWidget(ctx.kv, adminViewTuning);
2306
+ return renderScoreWidget(ctx.kv, adminViewTuning, ctx.log);
1872
2307
  }
1873
2308
  if (normalized === "document") {
1874
2309
  return renderDocumentPanel(ctx.kv);