@aeptus/aexeo-emdash 0.8.17 → 0.8.19

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.
@@ -5,15 +5,42 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/adapter.ts
8
- function adaptContentItem(content) {
9
- const route = deriveRoute(content);
8
+ async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
9
+ const publicUrl = content.status === "published" ? await contentApi.getPublicUrl(content.type, content.id) : null;
10
+ const route = publicUrl === null ? deriveRoute(content) : routeFromPublicUrl(publicUrl);
11
+ const adapted = adaptContentItem(content, {
12
+ route,
13
+ publicUrl,
14
+ ...defaultLocale === void 0 ? {} : { defaultLocale }
15
+ });
16
+ const translationSet = await contentApi.getTranslations(
17
+ content.type,
18
+ content.id
19
+ );
20
+ const alternates = await Promise.all(
21
+ translationSet.translations.filter(
22
+ (translation) => translation.status === "published" && typeof translation.locale === "string" && translation.locale.length > 0
23
+ ).map(async (translation) => {
24
+ const href = translation.id === content.id ? publicUrl : await contentApi.getPublicUrl(content.type, translation.id);
25
+ if (href === null) return null;
26
+ return { lang: translation.locale, href };
27
+ })
28
+ );
29
+ const uniqueAlternates = /* @__PURE__ */ new Map();
30
+ for (const alternate of alternates) {
31
+ if (alternate !== null) uniqueAlternates.set(alternate.lang, alternate);
32
+ }
33
+ if (uniqueAlternates.size > 0) {
34
+ adapted.document.alternates = [...uniqueAlternates.values()];
35
+ }
36
+ return adapted;
37
+ }
38
+ function adaptContentItem(content, options = {}) {
39
+ const route = options.route ?? deriveRoute(content);
10
40
  const title = stringOrEmpty(
11
41
  content.seo?.title ?? content.data["title"] ?? content.slug ?? content.id
12
42
  );
13
- const document = {
14
- route,
15
- title
16
- };
43
+ const document = { route, title };
17
44
  const description = content.seo?.description ?? content.data["description"];
18
45
  if (typeof description === "string" && description.length > 0) {
19
46
  document.description = description;
@@ -22,13 +49,12 @@ function adaptContentItem(content) {
22
49
  if (typeof canonical === "string" && canonical.length > 0) {
23
50
  document.canonical = canonical;
24
51
  }
25
- if (content.locale !== null) {
26
- document.lang = content.locale;
52
+ const locale = content.locale || options.defaultLocale;
53
+ if (typeof locale === "string" && locale.length > 0) {
54
+ document.lang = locale;
27
55
  }
28
56
  const body = extractPortableText(content.data);
29
- if (body !== null) {
30
- document.body = body;
31
- }
57
+ if (body !== null) document.body = body;
32
58
  return {
33
59
  document,
34
60
  meta: {
@@ -36,49 +62,160 @@ function adaptContentItem(content) {
36
62
  collection: content.type,
37
63
  slug: content.slug,
38
64
  status: content.status,
39
- title
65
+ title,
66
+ publicUrl: options.publicUrl ?? null
40
67
  }
41
68
  };
42
69
  }
43
70
  function deriveRoute(content) {
44
- if (typeof content.slug === "string" && content.slug.length > 0) {
45
- return content.slug.startsWith("/") ? content.slug : `/${content.slug}`;
71
+ return `/${encodeURIComponent(content.type)}/${encodeURIComponent(content.id)}`;
72
+ }
73
+ function routeFromPublicUrl(publicUrl) {
74
+ try {
75
+ return new URL(publicUrl).pathname || "/";
76
+ } catch (cause) {
77
+ throw new Error(`EmDash returned an invalid public URL: ${publicUrl}`, {
78
+ cause
79
+ });
46
80
  }
47
- return `/${content.type}/${content.id}`;
48
81
  }
49
82
  function stringOrEmpty(value) {
50
83
  return typeof value === "string" ? value : "";
51
84
  }
52
85
  function extractPortableText(data) {
53
- const candidates = ["body", "content"];
54
- for (const slug of candidates) {
86
+ for (const slug of ["body", "content"]) {
55
87
  const value = data[slug];
56
- if (isPortableTextArray(value)) {
57
- return value;
88
+ if (isPortableTextArray(value)) return value;
89
+ if (isEmdashBlocksArray(value, slug)) return blocksToPortableText(value);
90
+ if (typeof value === "string" && value.trim().length > 0) {
91
+ return [portableTextBlock(value.trim(), `${slug}-text`)];
58
92
  }
59
93
  }
60
- for (const value of Object.values(data)) {
61
- if (isPortableTextArray(value)) {
62
- return value;
63
- }
94
+ const blocks = data["blocks"];
95
+ if (isPortableTextArray(blocks)) return blocks;
96
+ if (isEmdashBlocksArray(blocks, "blocks")) return blocksToPortableText(blocks);
97
+ for (const [field, value] of Object.entries(data)) {
98
+ if (field === "body" || field === "content" || field === "blocks") continue;
99
+ if (isPortableTextArray(value)) return value;
100
+ if (isEmdashBlocksArray(value, field)) return blocksToPortableText(value);
64
101
  }
65
102
  return null;
66
103
  }
67
104
  function isPortableTextArray(value) {
68
- if (!Array.isArray(value) || value.length === 0) {
69
- return false;
105
+ return Array.isArray(value) && value.length > 0 && value.every(
106
+ (item) => isRecord(item) && item["_type"] === "block" && Array.isArray(item["children"]) && item["children"].every(
107
+ (child) => isRecord(child) && typeof child["text"] === "string"
108
+ )
109
+ );
110
+ }
111
+ function isEmdashBlocksArray(value, field) {
112
+ if (!Array.isArray(value) || value.length === 0) return false;
113
+ return value.every(
114
+ (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks")
115
+ );
116
+ }
117
+ function blocksToPortableText(blocks) {
118
+ const paragraphs = [];
119
+ for (const block of blocks) {
120
+ const segments = [];
121
+ collectText(block, "", segments);
122
+ const text = segments.map((segment) => segment.trim()).filter(Boolean).join(" ");
123
+ if (text.length > 0) {
124
+ paragraphs.push(portableTextBlock(text, stringOrEmpty(block["_key"])));
125
+ }
70
126
  }
71
- const first = value[0];
72
- return typeof first === "object" && first !== null && "_type" in first && typeof first._type === "string";
127
+ return paragraphs;
128
+ }
129
+ function collectText(value, key, out) {
130
+ if (typeof value === "string") {
131
+ if (!isStructuralField(key) && value.trim().length > 0) out.push(value);
132
+ return;
133
+ }
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) collectText(item, key, out);
136
+ return;
137
+ }
138
+ if (!isRecord(value)) return;
139
+ for (const [childKey, childValue] of Object.entries(value)) {
140
+ if (isStructuralField(childKey)) continue;
141
+ collectText(childValue, childKey, out);
142
+ }
143
+ }
144
+ var STRUCTURAL_FIELDS = /* @__PURE__ */ new Set([
145
+ "_key",
146
+ "_ref",
147
+ "_type",
148
+ "_version",
149
+ "asset",
150
+ "align",
151
+ "alignment",
152
+ "autoplay",
153
+ "color",
154
+ "controls",
155
+ "focalpoint",
156
+ "height",
157
+ "href",
158
+ "id",
159
+ "layout",
160
+ "level",
161
+ "loop",
162
+ "markdefs",
163
+ "marks",
164
+ "mime_type",
165
+ "mimetype",
166
+ "position",
167
+ "rel",
168
+ "size",
169
+ "src",
170
+ "style",
171
+ "target",
172
+ "theme",
173
+ "tone",
174
+ "url",
175
+ "variant",
176
+ "width"
177
+ ]);
178
+ function isStructuralField(key) {
179
+ const normalized = key.toLowerCase();
180
+ return normalized.startsWith("_") || STRUCTURAL_FIELDS.has(normalized);
181
+ }
182
+ function portableTextBlock(text, key) {
183
+ return {
184
+ _type: "block",
185
+ ...key.length === 0 ? {} : { _key: key },
186
+ children: [
187
+ {
188
+ _type: "span",
189
+ ...key.length === 0 ? {} : { _key: `${key}-text` },
190
+ text,
191
+ marks: []
192
+ }
193
+ ]
194
+ };
195
+ }
196
+ function isRecord(value) {
197
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
198
  }
74
199
 
75
200
  // src/plugin.ts
201
+ var SETTINGS_TOKEN_KEY = "eval_token";
202
+ var PLUGIN_SETTINGS_SCHEMA = {
203
+ [SETTINGS_TOKEN_KEY]: {
204
+ type: "secret",
205
+ label: "EVAL_TOKEN",
206
+ description: "Bearer token for your aexeo-crawl-worker Worker. Encrypted at rest by the emdash host."
207
+ }
208
+ };
76
209
  async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter) {
77
- const adapted = adaptContentItem(event.content);
210
+ const adapted = await adaptContentItemWithContext(
211
+ event.content,
212
+ ctx.content,
213
+ ctx.site?.locale
214
+ );
78
215
  const document = adapted.document;
79
216
  const { kv, log } = ctx;
80
217
  const stored = { document, meta: adapted.meta };
81
- await kv.set(documentKey(document.route), stored);
218
+ await persistDocument(ctx, stored);
82
219
  const result = await evaluator([document]);
83
220
  if (!result.ok) {
84
221
  log?.error?.(`afterSave evaluator failure (${result.reason})`, {
@@ -125,7 +262,11 @@ async function evaluateAndPersistAll(ctx, options) {
125
262
  ...cursor === void 0 ? {} : { cursor }
126
263
  });
127
264
  for (const item of page.items) {
128
- const adapted = adaptContentItem(item);
265
+ const adapted = await adaptContentItemWithContext(
266
+ item,
267
+ ctx.content,
268
+ ctx.site?.locale
269
+ );
129
270
  documents.push(adapted.document);
130
271
  documentRoutes.add(adapted.document.route);
131
272
  adaptedByRoute.set(adapted.document.route, {
@@ -177,8 +318,57 @@ async function evaluateAndPersistAll(ctx, options) {
177
318
  totalAfterSuppression += filtered.length;
178
319
  }
179
320
  summary.totalFindings = totalAfterSuppression;
321
+ if (summary.errors.length === 0) {
322
+ await removeStaleDocuments(ctx, collections, documentRoutes);
323
+ }
180
324
  return summary;
181
325
  }
326
+ async function persistDocument(ctx, stored) {
327
+ const route = stored.document.route;
328
+ const entries = await ctx.kv.list("document:");
329
+ for (const entry of entries) {
330
+ 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);
335
+ await ctx.kv.delete(findingsKey(previous.document.route));
336
+ }
337
+ await ctx.kv.set(documentKey(route), stored);
338
+ }
339
+ async function removeStaleDocuments(ctx, collections, currentRoutes) {
340
+ const scannedCollections = new Set(collections);
341
+ const entries = await ctx.kv.list("document:");
342
+ for (const entry of entries) {
343
+ const stored = toStoredDocument(entry.value);
344
+ if (stored === null || !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route)) {
345
+ continue;
346
+ }
347
+ await ctx.kv.delete(entry.key);
348
+ await ctx.kv.delete(findingsKey(stored.document.route));
349
+ }
350
+ }
351
+ function toStoredDocument(value) {
352
+ if (value === null || typeof value !== "object") return null;
353
+ if ("document" in value && "meta" in value && value.document !== null && typeof value.document === "object" && "route" in value.document && typeof value.document.route === "string" && value.meta !== null && typeof value.meta === "object") {
354
+ return value;
355
+ }
356
+ if ("route" in value && typeof value.route === "string") {
357
+ const document = value;
358
+ return {
359
+ document,
360
+ meta: {
361
+ id: document.route,
362
+ collection: "",
363
+ slug: null,
364
+ status: "",
365
+ title: document.title ?? "",
366
+ publicUrl: null
367
+ }
368
+ };
369
+ }
370
+ return null;
371
+ }
182
372
  function findingsKey(route) {
183
373
  const normalized = route === "" || route === "/" ? "/" : route;
184
374
  return `findings:${normalized}`;
@@ -199,34 +389,301 @@ async function readAllStoredDocuments(kv) {
199
389
  const entries = await kv.list("document:");
200
390
  const out = [];
201
391
  for (const entry of entries) {
202
- const value = entry.value;
203
- if (value === null || typeof value !== "object") continue;
204
- if ("document" in value && "meta" in value) {
205
- out.push(value);
392
+ const stored = toStoredDocument(entry.value);
393
+ if (stored !== null) out.push(stored);
394
+ }
395
+ return out;
396
+ }
397
+ async function readFindings(kv, route) {
398
+ const stored = await kv.get(findingsKey(route));
399
+ if (stored === null) {
400
+ return [];
401
+ }
402
+ return stored.findings;
403
+ }
404
+
405
+ // src/findings-view.ts
406
+ function normalizeInteraction(input) {
407
+ if (!input || typeof input !== "object") {
408
+ return { type: "page_load", page: "/findings" };
409
+ }
410
+ const raw = input;
411
+ if (raw.type === "page_load") {
412
+ return {
413
+ type: "page_load",
414
+ page: typeof raw.page === "string" && raw.page.length > 0 ? raw.page : "/findings"
415
+ };
416
+ }
417
+ if (raw.type === "block_action") {
418
+ return {
419
+ type: "block_action",
420
+ action_id: typeof raw.action_id === "string" ? raw.action_id : "",
421
+ ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
422
+ value: raw.value
423
+ };
424
+ }
425
+ if (raw.type === "form_submit") {
426
+ return {
427
+ type: "form_submit",
428
+ action_id: typeof raw.action_id === "string" ? raw.action_id : "",
429
+ ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
430
+ values: raw.values && typeof raw.values === "object" ? raw.values : {}
431
+ };
432
+ }
433
+ return { type: "page_load", page: "/findings" };
434
+ }
435
+ async function handleRefresh(options) {
436
+ let summary;
437
+ try {
438
+ summary = await evaluateAndPersistAll(options.ctx, {
439
+ collections: options.collections,
440
+ evaluator: options.evaluator,
441
+ // Always forwarded. See RefreshOptions.suppressionFilter.
442
+ suppressionFilter: options.suppressionFilter
443
+ });
444
+ } catch (err) {
445
+ const detail = err instanceof Error ? err.message : String(err);
446
+ const message = `Refresh failed: ${detail}`;
447
+ return {
448
+ blocks: [
449
+ { type: "header", text: "SEO findings" },
450
+ {
451
+ type: "banner",
452
+ title: message,
453
+ variant: options.tuning.refreshFailureVariant
454
+ }
455
+ ],
456
+ toast: { message, type: "error" }
457
+ };
458
+ }
459
+ const refreshed = await renderFindingsPage(options.ctx.kv, options.tuning);
460
+ 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`;
461
+ if (summary.errors.length > 0) {
462
+ refreshed.blocks.unshift({
463
+ type: "banner",
464
+ title: `Refresh issues: ${summary.errors.join(" \u2022 ")}`,
465
+ variant: "alert"
466
+ });
467
+ }
468
+ return {
469
+ ...refreshed,
470
+ toast: {
471
+ message: toastMessage,
472
+ type: summary.errors.length === 0 ? "success" : "info"
473
+ }
474
+ };
475
+ }
476
+ async function readAllFindings(kv) {
477
+ const entries = await kv.list(
478
+ "findings:"
479
+ );
480
+ const out = [];
481
+ for (const entry of entries) {
482
+ if (entry.value === null) {
206
483
  continue;
207
484
  }
208
- if ("route" in value) {
209
- const document = value;
210
- out.push({
211
- document,
212
- meta: {
213
- id: document.route,
214
- collection: "",
215
- slug: null,
216
- status: "",
217
- title: document.title ?? ""
218
- }
219
- });
485
+ const route = entry.key.replace(/^findings:/, "");
486
+ for (const finding of entry.value.findings) {
487
+ out.push({ ...finding, document_route: route });
220
488
  }
221
489
  }
222
490
  return out;
223
491
  }
224
- async function readFindings(kv, route) {
225
- const stored = await kv.get(findingsKey(route));
226
- if (stored === null) {
227
- return [];
228
- }
229
- return stored.findings;
492
+ function severityFirst(a, b) {
493
+ const rank = (severity) => severity === "error" ? 0 : 1;
494
+ const diff = rank(a.severity) - rank(b.severity);
495
+ if (diff !== 0) {
496
+ return diff;
497
+ }
498
+ return a.rule_id.localeCompare(b.rule_id);
499
+ }
500
+ function uniqueRoutes(rows) {
501
+ const routes = /* @__PURE__ */ new Set();
502
+ for (const row of rows) {
503
+ routes.add(row.document_route);
504
+ }
505
+ return [...routes].sort();
506
+ }
507
+ async function renderFindingsPage(kv, tuning) {
508
+ const findings = await readAllFindings(kv);
509
+ const errors = findings.filter((finding) => finding.severity === "error");
510
+ const warnings = findings.filter(
511
+ (finding) => finding.severity === "warning"
512
+ );
513
+ const sorted = [...findings].sort(severityFirst);
514
+ const routes = uniqueRoutes(findings);
515
+ const blocks = [
516
+ { type: "header", text: "SEO findings" },
517
+ {
518
+ type: "context",
519
+ text: findings.length === 0 ? "No findings yet \u2014 click Refresh to evaluate the site." : `${findings.length} findings across ${routes.length} routes \u2014 ${errors.length} errors, ${warnings.length} warnings.`
520
+ },
521
+ { type: "divider" },
522
+ {
523
+ type: "actions",
524
+ elements: [
525
+ // Refresh evaluates every configured entry. Pressing it lists
526
+ // content via the live in-request bridge, evaluates it, and
527
+ // writes findings back to KV (with suppressions applied).
528
+ {
529
+ type: "button",
530
+ label: "Refresh",
531
+ action_id: "refresh_findings",
532
+ style: "primary"
533
+ },
534
+ { type: "button", label: "All", action_id: "filter:all" },
535
+ { type: "button", label: "Errors only", action_id: "filter:errors" },
536
+ {
537
+ type: "button",
538
+ label: "Warnings only",
539
+ action_id: "filter:warnings"
540
+ }
541
+ ]
542
+ },
543
+ sorted.length === 0 ? {
544
+ type: "context",
545
+ text: tuning.emptyFindingsText
546
+ } : findingsTable(sorted)
547
+ ];
548
+ if (routes.length > 0) {
549
+ blocks.push({
550
+ type: "form",
551
+ fields: [
552
+ {
553
+ type: "select",
554
+ action_id: "route_picker",
555
+ label: "Document to inspect",
556
+ options: routes.map((route) => ({ label: route, value: route }))
557
+ }
558
+ ],
559
+ submit: { label: "View document SEO", action_id: "view_document" }
560
+ });
561
+ }
562
+ return { blocks };
563
+ }
564
+ function findingsTable(rows) {
565
+ return {
566
+ type: "table",
567
+ columns: [
568
+ { key: "route", label: "Route" },
569
+ { key: "rule", label: "Rule", format: "code" },
570
+ { key: "severity", label: "Severity", format: "badge" },
571
+ { key: "message", label: "Message" }
572
+ ],
573
+ rows: rows.map((row) => ({
574
+ route: row.document_route,
575
+ rule: row.rule_id,
576
+ severity: row.severity,
577
+ message: row.message
578
+ }))
579
+ };
580
+ }
581
+ async function renderScoreWidget(kv, tuning) {
582
+ const documents = await readAllDocuments(kv);
583
+ if (documents.length === 0) {
584
+ return {
585
+ blocks: [
586
+ { type: "header", text: "SEO score" },
587
+ {
588
+ type: "context",
589
+ text: tuning.emptyScoreText
590
+ }
591
+ ]
592
+ };
593
+ }
594
+ const score = await tuning.score(documents, kv);
595
+ const blocks = [
596
+ {
597
+ type: "stats",
598
+ items: [
599
+ { label: "Overall", value: `${score.overall_score}` },
600
+ { label: "Citation", value: `${score.citation_readiness_score}` },
601
+ {
602
+ label: tuning.truthLabel(score),
603
+ value: `${score.truth_consistency_score}`
604
+ },
605
+ { label: "Answers", value: `${score.answer_pack_score}` }
606
+ ]
607
+ }
608
+ ];
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
+ if (score.blockers.length > 0) {
617
+ blocks.push({
618
+ type: "context",
619
+ text: topBlockersLine(score)
620
+ });
621
+ }
622
+ return { blocks };
623
+ }
624
+ function topBlockersLine(score) {
625
+ const top = score.blockers.slice(0, 3).map((blocker) => blocker.message);
626
+ if (top.length === 0) {
627
+ return "No blockers identified.";
628
+ }
629
+ return `Top blockers: ${top.join(" \u2022 ")}`;
630
+ }
631
+ async function renderDocumentPanel(kv, route) {
632
+ if (route === void 0) {
633
+ return {
634
+ blocks: [
635
+ { type: "header", text: "Document SEO" },
636
+ {
637
+ type: "context",
638
+ text: "Pick a document from the SEO findings list to see its rule findings here."
639
+ }
640
+ ]
641
+ };
642
+ }
643
+ const findings = await readFindings(kv, route);
644
+ const errors = findings.filter((finding) => finding.severity === "error");
645
+ const warnings = findings.filter(
646
+ (finding) => finding.severity === "warning"
647
+ );
648
+ const sorted = [...findings].sort(severityFirst);
649
+ return {
650
+ blocks: [
651
+ { type: "header", text: `Document SEO \u2014 ${route}` },
652
+ {
653
+ type: "context",
654
+ text: findings.length === 0 ? `No findings for ${route} \u2014 the document is currently clean.` : `${findings.length} findings \u2014 ${errors.length} errors, ${warnings.length} warnings.`
655
+ },
656
+ ...findings.length === 0 ? [] : [{ type: "divider" }, documentFindingsTable(sorted)]
657
+ ]
658
+ };
659
+ }
660
+ function documentFindingsTable(findings) {
661
+ return {
662
+ type: "table",
663
+ columns: [
664
+ { key: "rule", label: "Rule", format: "code" },
665
+ { key: "severity", label: "Severity", format: "badge" },
666
+ { key: "message", label: "Message" },
667
+ { key: "block", label: "Block", format: "code" }
668
+ ],
669
+ rows: findings.map((finding) => ({
670
+ rule: finding.rule_id,
671
+ severity: finding.severity,
672
+ message: finding.message,
673
+ // The bridge stamps every Portable Text block with id + data-pt-key
674
+ // when rendering; surfacing the path:line locator here lets authors
675
+ // locate the failing block via the editor's ⌘-F.
676
+ block: `${finding.path}:${finding.line}`
677
+ }))
678
+ };
679
+ }
680
+ function notFound(page) {
681
+ return {
682
+ blocks: [
683
+ { type: "header", text: "Not found" },
684
+ { type: "context", text: `unknown page: ${page}` }
685
+ ]
686
+ };
230
687
  }
231
688
 
232
689
  // wasm/aexeo_emdash_bridge_bg.js
@@ -559,7 +1016,6 @@ async function buildFindingsPayload(ctx) {
559
1016
  }
560
1017
  }
561
1018
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
562
- const siteUrl = ctx.site?.url ?? "";
563
1019
  const routes = [];
564
1020
  for (const [route, bucket] of perRoute) {
565
1021
  const stored2 = documents.get(route);
@@ -570,7 +1026,7 @@ async function buildFindingsPayload(ctx) {
570
1026
  collection: stored2?.meta.collection ?? "",
571
1027
  id: stored2?.meta.id ?? "",
572
1028
  editUrl: stored2 ? buildEditUrl(stored2.meta) : "",
573
- publishedUrl: stored2 ? buildPublishedUrl(stored2.meta, route, siteUrl) : "",
1029
+ publishedUrl: stored2?.meta.publicUrl ?? "",
574
1030
  findingCount: bucket.findings.length,
575
1031
  errorCount: bucket.errors,
576
1032
  warningCount: bucket.warnings
@@ -696,12 +1152,6 @@ function buildEditUrl(meta) {
696
1152
  if (meta.collection === "" || meta.id === "") return "";
697
1153
  return `/_emdash/admin/content/${encodeURIComponent(meta.collection)}/${encodeURIComponent(meta.id)}`;
698
1154
  }
699
- function buildPublishedUrl(meta, route, siteUrl) {
700
- if (meta.status !== "published") return "";
701
- if (siteUrl === "") return "";
702
- const trimmed = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
703
- return `${trimmed}${route}`;
704
- }
705
1155
 
706
1156
  // src/facts-route.ts
707
1157
  import { PluginRouteError } from "emdash";
@@ -854,11 +1304,11 @@ async function checkWikipedia(input, fetcher) {
854
1304
  const descriptions = body[2];
855
1305
  const urls = body[3];
856
1306
  if (!Array.isArray(titles) || !Array.isArray(urls) || titles.length === 0) {
857
- return notFound("wikipedia");
1307
+ return notFound2("wikipedia");
858
1308
  }
859
1309
  const title = String(titles[0]);
860
1310
  if (!fuzzyMatch(title, input.name)) {
861
- return notFound("wikipedia");
1311
+ return notFound2("wikipedia");
862
1312
  }
863
1313
  const articleUrl = String(urls[0] ?? "");
864
1314
  const description = Array.isArray(descriptions) ? String(descriptions[0] ?? "") : "";
@@ -884,11 +1334,11 @@ async function checkWikidata(input, fetcher) {
884
1334
  const body = await res.json();
885
1335
  const top = body.search?.[0];
886
1336
  if (!top || typeof top.id !== "string") {
887
- return notFound("wikidata");
1337
+ return notFound2("wikidata");
888
1338
  }
889
1339
  const label = top.label ?? input.name;
890
1340
  if (!fuzzyMatch(label, input.name)) {
891
- return notFound("wikidata");
1341
+ return notFound2("wikidata");
892
1342
  }
893
1343
  return found("wikidata", {
894
1344
  label: `${top.id}${top.label ? ` \u2014 ${top.label}` : ""}`,
@@ -914,7 +1364,7 @@ async function checkGitHub(input, fetcher) {
914
1364
  // it the API can return either v3 or experimental shapes.
915
1365
  headers: { Accept: "application/vnd.github+json" }
916
1366
  });
917
- if (res.status === 404) return notFound("github");
1367
+ if (res.status === 404) return notFound2("github");
918
1368
  if (res.status === 403 || res.status === 429) {
919
1369
  return unreachable("github", "rate-limited (try Refresh later)");
920
1370
  }
@@ -946,7 +1396,7 @@ async function checkRdap(input, fetcher) {
946
1396
  const url = `https://rdap.org/domain/${encodeURIComponent(host)}`;
947
1397
  try {
948
1398
  const res = await timedFetch(fetcher, url);
949
- if (res.status === 404) return notFound("rdap");
1399
+ if (res.status === 404) return notFound2("rdap");
950
1400
  if (!res.ok) return unreachable("rdap", `HTTP ${res.status}`);
951
1401
  const body = await res.json();
952
1402
  const reg = (body.events ?? []).find(
@@ -983,13 +1433,13 @@ async function checkCommonCrawl(input, fetcher) {
983
1433
  url.searchParams.set("limit", "1");
984
1434
  try {
985
1435
  const res = await timedFetch(fetcher, url.toString());
986
- if (res.status === 404) return notFound("common_crawl");
1436
+ if (res.status === 404) return notFound2("common_crawl");
987
1437
  if (!res.ok) return unreachable("common_crawl", `HTTP ${res.status}`);
988
1438
  const text = (await res.text()).trim();
989
- if (text.length === 0) return notFound("common_crawl");
1439
+ if (text.length === 0) return notFound2("common_crawl");
990
1440
  let firstLine = text.split("\n")[0];
991
1441
  if (firstLine === void 0 || firstLine.length === 0) {
992
- return notFound("common_crawl");
1442
+ return notFound2("common_crawl");
993
1443
  }
994
1444
  let entry;
995
1445
  try {
@@ -1031,7 +1481,7 @@ function found(source, details) {
1031
1481
  if (details.extra !== void 0) result.extra = details.extra;
1032
1482
  return result;
1033
1483
  }
1034
- function notFound(source) {
1484
+ function notFound2(source) {
1035
1485
  return { source, status: "not_found", checkedAt: nowIso() };
1036
1486
  }
1037
1487
  function unreachable(source, error) {
@@ -1100,7 +1550,7 @@ async function handlePresenceRoute(ctx) {
1100
1550
  case "data":
1101
1551
  return await handleData2(ctx);
1102
1552
  case "refresh":
1103
- return await handleRefresh(ctx);
1553
+ return await handleRefresh2(ctx);
1104
1554
  default:
1105
1555
  throw PluginRouteError2.badRequest(
1106
1556
  `unknown presence route kind: ${String(body.kind)}`
@@ -1127,7 +1577,7 @@ async function handleData2(ctx) {
1127
1577
  const state = ageMs !== null && ageMs > CACHE_TTL_MS ? "stale" : "fresh";
1128
1578
  return payload(state, cached.results, cached.generatedAt, entity.name);
1129
1579
  }
1130
- async function handleRefresh(ctx) {
1580
+ async function handleRefresh2(ctx) {
1131
1581
  const manifest = await readStoredFacts(ctx.kv);
1132
1582
  if (manifest === null) {
1133
1583
  return payload("no_manifest", [], null, null);
@@ -1215,7 +1665,7 @@ function globToMatcher(pattern) {
1215
1665
  }
1216
1666
 
1217
1667
  // src/version.ts
1218
- var PACKAGE_VERSION = "0.8.17";
1668
+ var PACKAGE_VERSION = "0.8.19";
1219
1669
 
1220
1670
  // src/configured.ts
1221
1671
  import { definePlugin } from "emdash";
@@ -1284,7 +1734,7 @@ function createPlugin(options = {}) {
1284
1734
  return definePlugin({
1285
1735
  id: "aexeo-emdash",
1286
1736
  version: PACKAGE_VERSION,
1287
- capabilities: ["read:content"],
1737
+ capabilities: ["content:read"],
1288
1738
  hooks: {
1289
1739
  // afterSave processes one saved document at a time — collections
1290
1740
  // list isn't needed here. The hook always runs regardless of
@@ -1301,7 +1751,7 @@ function createPlugin(options = {}) {
1301
1751
  // one consumer of `collections`. Bind the resolved list here so
1302
1752
  // every request handles the same set without re-reading options.
1303
1753
  admin: {
1304
- handler: (ctx) => handleAdminRoute(ctx, collections)
1754
+ handler: (ctx) => handleAdminRoute(ctx, collections, suppressionFilter)
1305
1755
  },
1306
1756
  // JSON data endpoint for the React adminEntry. Two routes:
1307
1757
  // POST /_emdash/api/plugins/aexeo-emdash/data — read current findings
@@ -1371,246 +1821,59 @@ function createPlugin(options = {}) {
1371
1821
  });
1372
1822
  }
1373
1823
  var configured_default = createPlugin;
1374
- function normalizeInteraction(input) {
1375
- if (!input || typeof input !== "object") {
1376
- return { type: "page_load", page: "/findings" };
1377
- }
1378
- const raw = input;
1379
- if (raw.type === "page_load") {
1380
- return {
1381
- type: "page_load",
1382
- page: typeof raw.page === "string" && raw.page.length > 0 ? raw.page : "/findings"
1383
- };
1384
- }
1385
- if (raw.type === "block_action") {
1386
- return {
1387
- type: "block_action",
1388
- action_id: typeof raw.action_id === "string" ? raw.action_id : "",
1389
- ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
1390
- value: raw.value
1391
- };
1392
- }
1393
- if (raw.type === "form_submit") {
1394
- return {
1395
- type: "form_submit",
1396
- action_id: typeof raw.action_id === "string" ? raw.action_id : "",
1397
- ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
1398
- values: raw.values && typeof raw.values === "object" ? raw.values : {}
1399
- };
1400
- }
1401
- return { type: "page_load", page: "/findings" };
1402
- }
1403
- async function handleAdminRoute(ctx, collections) {
1824
+ var adminViewTuning = {
1825
+ emptyFindingsText: "Once a Refresh runs, findings will list here.",
1826
+ emptyScoreText: "No documents indexed \u2014 click Refresh on the Aexeo findings page.",
1827
+ refreshFailureVariant: "error",
1828
+ score: async (documents, kv) => scoreLocally(documents, await readStoredFacts(kv)),
1829
+ truthLabel: formatTruthLabel
1830
+ };
1831
+ async function handleAdminRoute(ctx, collections, suppressionFilter) {
1404
1832
  const body = normalizeInteraction(ctx.input);
1405
1833
  if (body.type === "page_load") {
1406
1834
  return handlePageLoad(ctx, body.page);
1407
1835
  }
1408
1836
  if (body.type === "block_action") {
1409
1837
  if (body.action_id === "view_document" && typeof body.value === "string") {
1410
- return renderDocumentPanel(ctx, body.value);
1838
+ return renderDocumentPanel(ctx.kv, body.value);
1411
1839
  }
1412
1840
  if (body.action_id === "refresh_findings") {
1413
- return handleRefresh2(ctx, collections);
1841
+ return handleRefresh({
1842
+ ctx,
1843
+ collections,
1844
+ evaluator: inProcessEvaluator,
1845
+ suppressionFilter,
1846
+ tuning: adminViewTuning
1847
+ });
1414
1848
  }
1415
1849
  if (body.action_id.startsWith("filter:")) {
1416
- return renderFindingsPage(ctx);
1850
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1417
1851
  }
1418
- return notFound2(body.action_id);
1852
+ return notFound(body.action_id);
1419
1853
  }
1420
1854
  if (body.type === "form_submit") {
1421
1855
  if (body.action_id === "view_document") {
1422
1856
  const picked = body.values["route_picker"];
1423
1857
  if (typeof picked === "string" && picked.length > 0) {
1424
- return renderDocumentPanel(ctx, picked);
1858
+ return renderDocumentPanel(ctx.kv, picked);
1425
1859
  }
1426
1860
  }
1427
- return renderFindingsPage(ctx);
1861
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1428
1862
  }
1429
- return renderFindingsPage(ctx);
1863
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1430
1864
  }
1431
1865
  async function handlePageLoad(ctx, page) {
1432
1866
  const normalized = page.startsWith("/") ? page.slice(1) : page;
1433
1867
  if (normalized === "" || normalized === "findings") {
1434
- return renderFindingsPage(ctx);
1868
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1435
1869
  }
1436
1870
  if (normalized === "widget:aexeo-score") {
1437
- return renderScoreWidget(ctx);
1871
+ return renderScoreWidget(ctx.kv, adminViewTuning);
1438
1872
  }
1439
1873
  if (normalized === "document") {
1440
- return renderDocumentPanel(ctx);
1441
- }
1442
- return notFound2(page);
1443
- }
1444
- async function handleRefresh2(ctx, collections) {
1445
- let summary;
1446
- try {
1447
- summary = await evaluateAndPersistAll(ctx, {
1448
- evaluator: inProcessEvaluator,
1449
- collections
1450
- });
1451
- } catch (err) {
1452
- const detail = err instanceof Error ? err.message : String(err);
1453
- return {
1454
- blocks: [
1455
- { type: "header", text: "SEO findings" },
1456
- {
1457
- type: "banner",
1458
- title: `Refresh failed: ${detail}`,
1459
- variant: "error"
1460
- }
1461
- ],
1462
- toast: { message: `Refresh failed: ${detail}`, type: "error" }
1463
- };
1464
- }
1465
- const refreshed = await renderFindingsPage(ctx);
1466
- 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`;
1467
- if (summary.errors.length > 0) {
1468
- refreshed.blocks.unshift({
1469
- type: "banner",
1470
- title: `Refresh issues: ${summary.errors.join(" \u2022 ")}`,
1471
- variant: "alert"
1472
- });
1473
- }
1474
- return {
1475
- ...refreshed,
1476
- toast: {
1477
- message: toastMessage,
1478
- type: summary.errors.length === 0 ? "success" : "info"
1479
- }
1480
- };
1481
- }
1482
- async function readAllFindings(ctx) {
1483
- const entries = await ctx.kv.list(
1484
- "findings:"
1485
- );
1486
- const out = [];
1487
- for (const entry of entries) {
1488
- if (entry.value === null) continue;
1489
- const route = entry.key.replace(/^findings:/, "");
1490
- for (const finding of entry.value.findings) {
1491
- out.push({ ...finding, document_route: route });
1492
- }
1493
- }
1494
- return out;
1495
- }
1496
- function severityFirst(a, b) {
1497
- const rank = (severity) => severity === "error" ? 0 : 1;
1498
- const diff = rank(a.severity) - rank(b.severity);
1499
- if (diff !== 0) return diff;
1500
- return a.rule_id.localeCompare(b.rule_id);
1501
- }
1502
- function uniqueRoutes(rows) {
1503
- const set = /* @__PURE__ */ new Set();
1504
- for (const row of rows) set.add(row.document_route);
1505
- return [...set].sort();
1506
- }
1507
- async function renderFindingsPage(ctx) {
1508
- const findings = await readAllFindings(ctx);
1509
- const errors = findings.filter((f) => f.severity === "error");
1510
- const warnings = findings.filter((f) => f.severity === "warning");
1511
- const sorted = [...findings].sort(severityFirst);
1512
- const routes = uniqueRoutes(findings);
1513
- const blocks = [
1514
- { type: "header", text: "SEO findings" },
1515
- {
1516
- type: "context",
1517
- text: findings.length === 0 ? "No findings yet \u2014 click Refresh to evaluate the site." : `${findings.length} findings across ${routes.length} routes \u2014 ${errors.length} errors, ${warnings.length} warnings.`
1518
- },
1519
- { type: "divider" },
1520
- {
1521
- type: "actions",
1522
- elements: [
1523
- {
1524
- type: "button",
1525
- label: "Refresh",
1526
- action_id: "refresh_findings",
1527
- style: "primary"
1528
- },
1529
- { type: "button", label: "All", action_id: "filter:all" },
1530
- { type: "button", label: "Errors only", action_id: "filter:errors" },
1531
- {
1532
- type: "button",
1533
- label: "Warnings only",
1534
- action_id: "filter:warnings"
1535
- }
1536
- ]
1537
- },
1538
- sorted.length === 0 ? {
1539
- type: "context",
1540
- text: "Once a Refresh runs, findings will list here."
1541
- } : findingsTable(sorted)
1542
- ];
1543
- if (routes.length > 0) {
1544
- blocks.push({
1545
- type: "form",
1546
- fields: [
1547
- {
1548
- type: "select",
1549
- action_id: "route_picker",
1550
- label: "Document to inspect",
1551
- options: routes.map((route) => ({ label: route, value: route }))
1552
- }
1553
- ],
1554
- submit: { label: "View document SEO", action_id: "view_document" }
1555
- });
1556
- }
1557
- return { blocks };
1558
- }
1559
- function findingsTable(rows) {
1560
- return {
1561
- type: "table",
1562
- columns: [
1563
- { key: "route", label: "Route" },
1564
- { key: "rule", label: "Rule", format: "code" },
1565
- { key: "severity", label: "Severity", format: "badge" },
1566
- { key: "message", label: "Message" }
1567
- ],
1568
- rows: rows.map((row) => ({
1569
- route: row.document_route,
1570
- rule: row.rule_id,
1571
- severity: row.severity,
1572
- message: row.message
1573
- }))
1574
- };
1575
- }
1576
- async function renderScoreWidget(ctx) {
1577
- const documents = await readAllDocuments(ctx.kv);
1578
- if (documents.length === 0) {
1579
- return {
1580
- blocks: [
1581
- { type: "header", text: "SEO score" },
1582
- {
1583
- type: "context",
1584
- text: "No documents indexed \u2014 click Refresh on the Aexeo findings page."
1585
- }
1586
- ]
1587
- };
1588
- }
1589
- const facts = await readStoredFacts(ctx.kv);
1590
- const score = await scoreLocally(documents, facts);
1591
- const truthLabel = formatTruthLabel(score);
1592
- const blocks = [
1593
- {
1594
- type: "stats",
1595
- items: [
1596
- { label: "Overall", value: `${score.overall_score}` },
1597
- { label: "Citation", value: `${score.citation_readiness_score}` },
1598
- { label: truthLabel, value: `${score.truth_consistency_score}` },
1599
- { label: "Answers", value: `${score.answer_pack_score}` }
1600
- ]
1601
- }
1602
- ];
1603
- if (score.overall_score < 60) {
1604
- blocks.unshift({
1605
- type: "banner",
1606
- title: `Site score is ${score.overall_score} \u2014 below the 60 quality threshold`,
1607
- variant: "alert"
1608
- });
1609
- }
1610
- if (score.blockers.length > 0) {
1611
- blocks.push({ type: "context", text: topBlockersLine(score) });
1874
+ return renderDocumentPanel(ctx.kv);
1612
1875
  }
1613
- return { blocks };
1876
+ return notFound(page);
1614
1877
  }
1615
1878
  async function scoreLocally(documents, manifest = null) {
1616
1879
  const manifestJson = manifest === null ? null : JSON.stringify(manifest);
@@ -1631,63 +1894,6 @@ function formatTruthLabel(score) {
1631
1894
  return "Truth";
1632
1895
  }
1633
1896
  }
1634
- function topBlockersLine(score) {
1635
- const top = score.blockers.slice(0, 3).map((b) => b.message);
1636
- if (top.length === 0) return "No blockers identified.";
1637
- return `Top blockers: ${top.join(" \u2022 ")}`;
1638
- }
1639
- async function renderDocumentPanel(ctx, route) {
1640
- if (route === void 0) {
1641
- return {
1642
- blocks: [
1643
- { type: "header", text: "Document SEO" },
1644
- {
1645
- type: "context",
1646
- text: "Pick a document from the SEO findings list to see its rule findings here."
1647
- }
1648
- ]
1649
- };
1650
- }
1651
- const findings = await readFindings(ctx.kv, route);
1652
- const errors = findings.filter((f) => f.severity === "error");
1653
- const warnings = findings.filter((f) => f.severity === "warning");
1654
- const sorted = [...findings].sort(severityFirst);
1655
- return {
1656
- blocks: [
1657
- { type: "header", text: `Document SEO \u2014 ${route}` },
1658
- {
1659
- type: "context",
1660
- text: findings.length === 0 ? `No findings for ${route} \u2014 the document is currently clean.` : `${findings.length} findings \u2014 ${errors.length} errors, ${warnings.length} warnings.`
1661
- },
1662
- ...findings.length === 0 ? [] : [{ type: "divider" }, documentFindingsTable(sorted)]
1663
- ]
1664
- };
1665
- }
1666
- function documentFindingsTable(findings) {
1667
- return {
1668
- type: "table",
1669
- columns: [
1670
- { key: "rule", label: "Rule", format: "code" },
1671
- { key: "severity", label: "Severity", format: "badge" },
1672
- { key: "message", label: "Message" },
1673
- { key: "block", label: "Block", format: "code" }
1674
- ],
1675
- rows: findings.map((finding) => ({
1676
- rule: finding.rule_id,
1677
- severity: finding.severity,
1678
- message: finding.message,
1679
- block: `${finding.path}:${finding.line}`
1680
- }))
1681
- };
1682
- }
1683
- function notFound2(page) {
1684
- return {
1685
- blocks: [
1686
- { type: "header", text: "Not found" },
1687
- { type: "context", text: `unknown page: ${page}` }
1688
- ]
1689
- };
1690
- }
1691
1897
  export {
1692
1898
  createPlugin,
1693
1899
  configured_default as default