@aeptus/aexeo-emdash 0.1.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.
- package/CHANGELOG.md +45 -0
- package/INSTALL.md +289 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/adapter.d.ts +16 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/configured.d.ts +5 -0
- package/dist/configured.d.ts.map +1 -0
- package/dist/configured.js +661 -0
- package/dist/evaluator-sandbox.d.ts +6 -0
- package/dist/evaluator-sandbox.d.ts.map +1 -0
- package/dist/evaluator.d.ts +9 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/indexnow.d.ts +15 -0
- package/dist/indexnow.d.ts.map +1 -0
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/plugin.d.ts +77 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/sandbox-entry.d.ts +47 -0
- package/dist/sandbox-entry.d.ts.map +1 -0
- package/dist/sandbox-entry.js +846 -0
- package/dist/sandbox.d.ts +25 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sidecar.d.ts +27 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/wasm-init.d.ts +3 -0
- package/dist/wasm-init.d.ts.map +1 -0
- package/package.json +78 -0
- package/wasm/aexeo_emdash_bridge.d.ts +6 -0
- package/wasm/aexeo_emdash_bridge.js +9 -0
- package/wasm/aexeo_emdash_bridge_bg.d.ts +17 -0
- package/wasm/aexeo_emdash_bridge_bg.js +159 -0
- package/wasm/aexeo_emdash_bridge_bg.wasm +0 -0
- package/wasm/aexeo_emdash_bridge_bg.wasm.d.ts +11 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
function contentItemToEmdashDocument(content) {
|
|
3
|
+
const route = deriveRoute(content);
|
|
4
|
+
const title = stringOrEmpty(
|
|
5
|
+
content.seo?.title ?? content.data["title"] ?? content.slug ?? content.id
|
|
6
|
+
);
|
|
7
|
+
const document = {
|
|
8
|
+
route,
|
|
9
|
+
title
|
|
10
|
+
};
|
|
11
|
+
const description = content.seo?.description ?? content.data["description"];
|
|
12
|
+
if (typeof description === "string" && description.length > 0) {
|
|
13
|
+
document.description = description;
|
|
14
|
+
}
|
|
15
|
+
const canonical = content.seo?.canonical;
|
|
16
|
+
if (typeof canonical === "string" && canonical.length > 0) {
|
|
17
|
+
document.canonical = canonical;
|
|
18
|
+
}
|
|
19
|
+
if (content.locale !== null) {
|
|
20
|
+
document.lang = content.locale;
|
|
21
|
+
}
|
|
22
|
+
const body = extractPortableText(content.data);
|
|
23
|
+
if (body !== null) {
|
|
24
|
+
document.body = body;
|
|
25
|
+
}
|
|
26
|
+
return document;
|
|
27
|
+
}
|
|
28
|
+
function deriveRoute(content) {
|
|
29
|
+
if (typeof content.slug === "string" && content.slug.length > 0) {
|
|
30
|
+
return content.slug.startsWith("/") ? content.slug : `/${content.slug}`;
|
|
31
|
+
}
|
|
32
|
+
return `/${content.type}/${content.id}`;
|
|
33
|
+
}
|
|
34
|
+
function stringOrEmpty(value) {
|
|
35
|
+
return typeof value === "string" ? value : "";
|
|
36
|
+
}
|
|
37
|
+
function extractPortableText(data) {
|
|
38
|
+
const candidates = ["body", "content"];
|
|
39
|
+
for (const slug of candidates) {
|
|
40
|
+
const value = data[slug];
|
|
41
|
+
if (isPortableTextArray(value)) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const value of Object.values(data)) {
|
|
46
|
+
if (isPortableTextArray(value)) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function isPortableTextArray(value) {
|
|
53
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const first = value[0];
|
|
57
|
+
return typeof first === "object" && first !== null && "_type" in first && typeof first._type === "string";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/plugin.ts
|
|
61
|
+
var CONFIG_URL_KEY = "config:evaluator_url";
|
|
62
|
+
var CONFIG_TOKEN_KEY = "config:eval_token";
|
|
63
|
+
async function readSidecarConfig(kv) {
|
|
64
|
+
const url = await kv.get(CONFIG_URL_KEY);
|
|
65
|
+
const token = await kv.get(CONFIG_TOKEN_KEY);
|
|
66
|
+
if (typeof url !== "string" || typeof token !== "string") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (url.length === 0 || token.length === 0) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return { url, token };
|
|
73
|
+
}
|
|
74
|
+
async function writeSidecarConfig(kv, config) {
|
|
75
|
+
await kv.set(CONFIG_URL_KEY, config.url);
|
|
76
|
+
await kv.set(CONFIG_TOKEN_KEY, config.token);
|
|
77
|
+
}
|
|
78
|
+
async function handleAfterSave(_event, _ctx) {
|
|
79
|
+
}
|
|
80
|
+
var DEFAULT_COLLECTIONS = ["posts", "pages"];
|
|
81
|
+
async function evaluateAndPersistAll(ctx, options) {
|
|
82
|
+
const collections = options.collections ?? DEFAULT_COLLECTIONS;
|
|
83
|
+
const { kv, log } = ctx;
|
|
84
|
+
const summary = {
|
|
85
|
+
documentsScanned: 0,
|
|
86
|
+
routesUpdated: 0,
|
|
87
|
+
totalFindings: 0,
|
|
88
|
+
errors: []
|
|
89
|
+
};
|
|
90
|
+
const documents = [];
|
|
91
|
+
const documentRoutes = /* @__PURE__ */ new Set();
|
|
92
|
+
for (const collection of collections) {
|
|
93
|
+
let cursor;
|
|
94
|
+
do {
|
|
95
|
+
try {
|
|
96
|
+
const page = await ctx.content.list(collection, {
|
|
97
|
+
limit: 100,
|
|
98
|
+
...cursor === void 0 ? {} : { cursor }
|
|
99
|
+
});
|
|
100
|
+
for (const item of page.items) {
|
|
101
|
+
const document = contentItemToEmdashDocument(item);
|
|
102
|
+
documents.push(document);
|
|
103
|
+
documentRoutes.add(document.route);
|
|
104
|
+
}
|
|
105
|
+
cursor = page.hasMore ? page.cursor : void 0;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
summary.errors.push(`${collection}: ${message}`);
|
|
109
|
+
cursor = void 0;
|
|
110
|
+
}
|
|
111
|
+
} while (cursor !== void 0);
|
|
112
|
+
}
|
|
113
|
+
summary.documentsScanned = documents.length;
|
|
114
|
+
for (const document of documents) {
|
|
115
|
+
await kv.set(documentKey(document.route), document);
|
|
116
|
+
}
|
|
117
|
+
const result = await options.evaluator(documents);
|
|
118
|
+
if (!result.ok) {
|
|
119
|
+
log?.error?.(`seogeo evaluator failure (${result.reason})`, {
|
|
120
|
+
detail: result.detail
|
|
121
|
+
});
|
|
122
|
+
summary.errors.push(`${result.reason}: ${result.detail}`);
|
|
123
|
+
return summary;
|
|
124
|
+
}
|
|
125
|
+
const findingsByRoute2 = /* @__PURE__ */ new Map();
|
|
126
|
+
for (const route of documentRoutes) {
|
|
127
|
+
findingsByRoute2.set(route, []);
|
|
128
|
+
}
|
|
129
|
+
for (const finding of result.findings) {
|
|
130
|
+
const bucket = finding.scope === "page" ? finding.path : "*";
|
|
131
|
+
const list = findingsByRoute2.get(bucket) ?? [];
|
|
132
|
+
list.push(finding);
|
|
133
|
+
findingsByRoute2.set(bucket, list);
|
|
134
|
+
}
|
|
135
|
+
for (const [route, findings] of findingsByRoute2) {
|
|
136
|
+
await kv.set(findingsKey(route), { route, findings });
|
|
137
|
+
summary.routesUpdated += 1;
|
|
138
|
+
}
|
|
139
|
+
summary.totalFindings = result.findings.length;
|
|
140
|
+
return summary;
|
|
141
|
+
}
|
|
142
|
+
function findingsKey(route) {
|
|
143
|
+
const normalized = route === "" || route === "/" ? "/" : route;
|
|
144
|
+
return `findings:${normalized}`;
|
|
145
|
+
}
|
|
146
|
+
function documentKey(route) {
|
|
147
|
+
const normalized = route === "" || route === "/" ? "/" : route;
|
|
148
|
+
return `document:${normalized}`;
|
|
149
|
+
}
|
|
150
|
+
async function readAllDocuments(kv) {
|
|
151
|
+
const entries = await kv.list("document:");
|
|
152
|
+
return entries.map((entry) => entry.value).filter((value) => value !== null);
|
|
153
|
+
}
|
|
154
|
+
async function readFindings(kv, route) {
|
|
155
|
+
const stored = await kv.get(findingsKey(route));
|
|
156
|
+
if (stored === null) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return stored.findings;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/sidecar.ts
|
|
163
|
+
async function evaluateViaSidecar(http, config, documents) {
|
|
164
|
+
const body = {
|
|
165
|
+
documents
|
|
166
|
+
};
|
|
167
|
+
if (config.configJson !== void 0) {
|
|
168
|
+
body.configJson = config.configJson;
|
|
169
|
+
}
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await http.fetch(`${trimTrailingSlash(config.url)}/evaluate`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
authorization: `Bearer ${config.authToken}`,
|
|
176
|
+
"content-type": "application/json"
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(body)
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
182
|
+
return { ok: false, reason: "network_error", detail };
|
|
183
|
+
}
|
|
184
|
+
if (response.status === 401 || response.status === 403) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "auth_error",
|
|
188
|
+
detail: `sidecar rejected token (HTTP ${response.status})`
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
let detail = `sidecar HTTP ${response.status}`;
|
|
193
|
+
try {
|
|
194
|
+
detail = `${detail}: ${await response.text()}`;
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
return { ok: false, reason: "server_error", detail };
|
|
198
|
+
}
|
|
199
|
+
let raw;
|
|
200
|
+
try {
|
|
201
|
+
raw = await response.text();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
204
|
+
return { ok: false, reason: "invalid_response", detail };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const findings = JSON.parse(raw);
|
|
208
|
+
if (!Array.isArray(findings)) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: "invalid_response",
|
|
212
|
+
detail: "sidecar returned non-array body"
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, findings };
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
218
|
+
return { ok: false, reason: "invalid_response", detail };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function trimTrailingSlash(url) {
|
|
222
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/evaluator-sandbox.ts
|
|
226
|
+
async function evaluate(_documents) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
async function scoreSite(_documents) {
|
|
230
|
+
return {
|
|
231
|
+
overall_score: 0,
|
|
232
|
+
citation_readiness_score: 0,
|
|
233
|
+
truth_consistency_score: 0,
|
|
234
|
+
answer_pack_score: 0,
|
|
235
|
+
external_trust_alignment_score: null,
|
|
236
|
+
route_scores: [],
|
|
237
|
+
blockers: []
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function errorFindings(findings) {
|
|
241
|
+
return findings.filter((finding) => finding.severity === "error");
|
|
242
|
+
}
|
|
243
|
+
function findingsByRoute(findings) {
|
|
244
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const finding of findings) {
|
|
246
|
+
const bucket = grouped.get(finding.path);
|
|
247
|
+
if (bucket) {
|
|
248
|
+
bucket.push(finding);
|
|
249
|
+
} else {
|
|
250
|
+
grouped.set(finding.path, [finding]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return grouped;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/indexnow.ts
|
|
257
|
+
var INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
258
|
+
async function submitIndexNow(config, urls) {
|
|
259
|
+
const host = hostOf(config.siteUrl);
|
|
260
|
+
if (host === null) {
|
|
261
|
+
return failure("siteUrl is not a valid URL", []);
|
|
262
|
+
}
|
|
263
|
+
const keyLocation = config.keyLocation ?? defaultKeyLocation(config);
|
|
264
|
+
const { submitted, rejected } = partitionByHost(urls, host);
|
|
265
|
+
if (submitted.length === 0) {
|
|
266
|
+
return failure("no submittable URLs after host check", rejected);
|
|
267
|
+
}
|
|
268
|
+
const payload = {
|
|
269
|
+
host,
|
|
270
|
+
key: config.key,
|
|
271
|
+
keyLocation,
|
|
272
|
+
urlList: submitted
|
|
273
|
+
};
|
|
274
|
+
const response = await fetch(INDEXNOW_ENDPOINT, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "content-type": "application/json" },
|
|
277
|
+
body: JSON.stringify(payload)
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
ok: response.ok,
|
|
281
|
+
status: response.status,
|
|
282
|
+
endpoint: INDEXNOW_ENDPOINT,
|
|
283
|
+
submitted,
|
|
284
|
+
rejected
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function hostOf(rawUrl) {
|
|
288
|
+
try {
|
|
289
|
+
return new URL(rawUrl).host;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function defaultKeyLocation(config) {
|
|
295
|
+
const siteUrl = config.siteUrl.endsWith("/") ? config.siteUrl.slice(0, -1) : config.siteUrl;
|
|
296
|
+
return `${siteUrl}/${config.key}.txt`;
|
|
297
|
+
}
|
|
298
|
+
function partitionByHost(urls, expectedHost) {
|
|
299
|
+
const submitted = [];
|
|
300
|
+
const rejected = [];
|
|
301
|
+
for (const candidate of urls) {
|
|
302
|
+
const host = hostOf(candidate);
|
|
303
|
+
if (host === expectedHost) {
|
|
304
|
+
submitted.push(candidate);
|
|
305
|
+
} else {
|
|
306
|
+
rejected.push(candidate);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { submitted, rejected };
|
|
310
|
+
}
|
|
311
|
+
function failure(reason, rejected) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
status: 0,
|
|
315
|
+
endpoint: INDEXNOW_ENDPOINT,
|
|
316
|
+
submitted: [],
|
|
317
|
+
rejected,
|
|
318
|
+
reason
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/mcp.ts
|
|
323
|
+
var checkTool = {
|
|
324
|
+
name: "seogeo.check",
|
|
325
|
+
description: "Evaluate a list of EmdashDocument values against the seogeo rule engine and return the stable Finding contract. Mirrors the CLI's `seogeo-cli check` surface so agents, the admin UI, and the CI gate all see the same rule ids.",
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: "object",
|
|
328
|
+
properties: {
|
|
329
|
+
documents: {
|
|
330
|
+
type: "array",
|
|
331
|
+
description: "Array of EmdashDocument values to evaluate. Each entry carries route, title, optional description/canonical/lang, alternates, meta map, schema, and Portable Text body."
|
|
332
|
+
},
|
|
333
|
+
configJson: {
|
|
334
|
+
type: "string",
|
|
335
|
+
description: "Optional JSON-serialized seogeo Config. When absent the evaluator uses Config::default(), matching a cold CLI run."
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
required: ["documents"]
|
|
339
|
+
},
|
|
340
|
+
handler: async ({ documents, configJson }) => {
|
|
341
|
+
const options = configJson === void 0 ? {} : { configJson };
|
|
342
|
+
const findings = await evaluate(documents, options);
|
|
343
|
+
const errors = errorFindings(findings);
|
|
344
|
+
const grouped = findingsByRoute(findings);
|
|
345
|
+
const byRoute = {};
|
|
346
|
+
for (const [route, items] of grouped.entries()) {
|
|
347
|
+
byRoute[route] = items;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
findings,
|
|
351
|
+
errors,
|
|
352
|
+
byRoute,
|
|
353
|
+
totals: {
|
|
354
|
+
findings: findings.length,
|
|
355
|
+
errors: errors.length,
|
|
356
|
+
warnings: findings.length - errors.length
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var indexNowSubmitTool = {
|
|
362
|
+
name: "seogeo.indexnow.submit",
|
|
363
|
+
description: "Submit a list of URLs to the IndexNow freshness endpoint. Mirrors the seogeo-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.",
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
config: {
|
|
368
|
+
type: "object",
|
|
369
|
+
description: "IndexNow configuration: siteUrl, key, and optional keyLocation override.",
|
|
370
|
+
properties: {
|
|
371
|
+
siteUrl: { type: "string" },
|
|
372
|
+
key: { type: "string" },
|
|
373
|
+
keyLocation: { type: "string" }
|
|
374
|
+
},
|
|
375
|
+
required: ["siteUrl", "key"]
|
|
376
|
+
},
|
|
377
|
+
urls: {
|
|
378
|
+
type: "array",
|
|
379
|
+
items: { type: "string" },
|
|
380
|
+
description: "Absolute URLs to submit. Must share siteUrl's host."
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
required: ["config", "urls"]
|
|
384
|
+
},
|
|
385
|
+
handler: async ({ config, urls }) => submitIndexNow(config, urls)
|
|
386
|
+
};
|
|
387
|
+
var tools = [checkTool, indexNowSubmitTool];
|
|
388
|
+
|
|
389
|
+
// src/sandbox-entry.ts
|
|
390
|
+
function definePlugin(plugin) {
|
|
391
|
+
return plugin;
|
|
392
|
+
}
|
|
393
|
+
async function handleAdminRoute(input, ctx) {
|
|
394
|
+
ctx.log?.info?.(
|
|
395
|
+
`seogeo route: type=${input.input?.type} page=${input.input?.page ?? ""}`
|
|
396
|
+
);
|
|
397
|
+
const dispatch = { body: input.input, kv: ctx.kv, ctx };
|
|
398
|
+
if (dispatch.body.type === "page_load") {
|
|
399
|
+
return handlePageLoad(dispatch, dispatch.body.page);
|
|
400
|
+
}
|
|
401
|
+
if (dispatch.body.type === "block_action") {
|
|
402
|
+
return handleBlockAction(
|
|
403
|
+
dispatch,
|
|
404
|
+
dispatch.body.action_id,
|
|
405
|
+
dispatch.body.value
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (dispatch.body.type === "form_submit") {
|
|
409
|
+
return handleFormSubmit(
|
|
410
|
+
dispatch,
|
|
411
|
+
dispatch.body.action_id,
|
|
412
|
+
dispatch.body.values
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return handlePageLoad(dispatch, "findings");
|
|
416
|
+
}
|
|
417
|
+
async function handleFormSubmit(ctx, actionId, values) {
|
|
418
|
+
if (actionId === "save_setup") {
|
|
419
|
+
return handleSetupSubmit(ctx, values);
|
|
420
|
+
}
|
|
421
|
+
if (actionId === "view_document") {
|
|
422
|
+
const picked = values["route_picker"];
|
|
423
|
+
if (typeof picked === "string" && picked.length > 0) {
|
|
424
|
+
return renderDocumentPanel(ctx, picked);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return handlePageLoad(ctx, "findings");
|
|
428
|
+
}
|
|
429
|
+
async function handlePageLoad(ctx, page) {
|
|
430
|
+
const normalized = page.startsWith("/") ? page.slice(1) : page;
|
|
431
|
+
if (normalized === "findings") {
|
|
432
|
+
return renderFindingsPage(ctx);
|
|
433
|
+
}
|
|
434
|
+
if (normalized === "widget:seogeo-score") {
|
|
435
|
+
return renderScoreWidget(ctx);
|
|
436
|
+
}
|
|
437
|
+
if (normalized === "document") {
|
|
438
|
+
return renderDocumentPanel(ctx);
|
|
439
|
+
}
|
|
440
|
+
if (normalized === "setup") {
|
|
441
|
+
return renderSetupPage(ctx);
|
|
442
|
+
}
|
|
443
|
+
return notFound(page);
|
|
444
|
+
}
|
|
445
|
+
async function handleBlockAction(ctx, actionId, value) {
|
|
446
|
+
if (actionId === "view_document" && typeof value === "string") {
|
|
447
|
+
return renderDocumentPanel(ctx, value);
|
|
448
|
+
}
|
|
449
|
+
if (actionId === "refresh_findings") {
|
|
450
|
+
return handleRefresh(ctx);
|
|
451
|
+
}
|
|
452
|
+
if (actionId.startsWith("filter:")) {
|
|
453
|
+
return renderFindingsPage(ctx);
|
|
454
|
+
}
|
|
455
|
+
return notFound(actionId);
|
|
456
|
+
}
|
|
457
|
+
async function handleRefresh(ctx) {
|
|
458
|
+
const sandboxEvaluator = async (documents) => {
|
|
459
|
+
const runtime = await readSidecarConfig(ctx.kv);
|
|
460
|
+
if (runtime === null) {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
reason: "config_missing",
|
|
464
|
+
detail: "sidecar not configured \u2014 open the seogeo Setup page and enter your evaluator URL and token"
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return evaluateViaSidecar(
|
|
468
|
+
ctx.ctx.http,
|
|
469
|
+
{ url: runtime.url, authToken: runtime.token },
|
|
470
|
+
documents
|
|
471
|
+
);
|
|
472
|
+
};
|
|
473
|
+
let summary;
|
|
474
|
+
try {
|
|
475
|
+
summary = await evaluateAndPersistAll(ctx.ctx, {
|
|
476
|
+
evaluator: sandboxEvaluator
|
|
477
|
+
});
|
|
478
|
+
} catch (err) {
|
|
479
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
480
|
+
return {
|
|
481
|
+
blocks: [
|
|
482
|
+
{ type: "header", text: "SEO findings" },
|
|
483
|
+
{
|
|
484
|
+
type: "banner",
|
|
485
|
+
title: `Refresh failed: ${detail}`,
|
|
486
|
+
variant: "alert"
|
|
487
|
+
}
|
|
488
|
+
],
|
|
489
|
+
toast: { message: `Refresh failed: ${detail}`, type: "error" }
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const refreshed = await renderFindingsPage(ctx);
|
|
493
|
+
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`;
|
|
494
|
+
if (summary.errors.length > 0) {
|
|
495
|
+
refreshed.blocks.unshift({
|
|
496
|
+
type: "banner",
|
|
497
|
+
title: `Refresh issues: ${summary.errors.join(" \u2022 ")}`,
|
|
498
|
+
variant: "alert"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
...refreshed,
|
|
503
|
+
toast: {
|
|
504
|
+
message: toastMessage,
|
|
505
|
+
type: summary.errors.length === 0 ? "success" : "info"
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function renderFindingsPage(ctx) {
|
|
510
|
+
const findings = await readAllFindings(ctx.kv);
|
|
511
|
+
const errors = findings.filter((finding) => finding.severity === "error");
|
|
512
|
+
const warnings = findings.filter((finding) => finding.severity === "warning");
|
|
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 is the primary action: emdash 0.7.0's afterSave hook
|
|
526
|
+
// can't reliably do I/O (post-response bridge invalidation), so
|
|
527
|
+
// re-evaluation is admin-triggered rather than save-triggered.
|
|
528
|
+
// Pressing this lists all content via the live in-request bridge,
|
|
529
|
+
// calls the sidecar /evaluate, and writes findings back to KV.
|
|
530
|
+
{
|
|
531
|
+
type: "button",
|
|
532
|
+
label: "Refresh",
|
|
533
|
+
action_id: "refresh_findings",
|
|
534
|
+
style: "primary"
|
|
535
|
+
},
|
|
536
|
+
{ type: "button", label: "All", action_id: "filter:all" },
|
|
537
|
+
{ type: "button", label: "Errors only", action_id: "filter:errors" },
|
|
538
|
+
{
|
|
539
|
+
type: "button",
|
|
540
|
+
label: "Warnings only",
|
|
541
|
+
action_id: "filter:warnings"
|
|
542
|
+
}
|
|
543
|
+
]
|
|
544
|
+
},
|
|
545
|
+
sorted.length === 0 ? {
|
|
546
|
+
type: "context",
|
|
547
|
+
text: "Once a document publishes, its rule findings list here."
|
|
548
|
+
} : findingsTable(sorted)
|
|
549
|
+
];
|
|
550
|
+
if (routes.length > 0) {
|
|
551
|
+
blocks.push({
|
|
552
|
+
type: "form",
|
|
553
|
+
fields: [
|
|
554
|
+
{
|
|
555
|
+
type: "select",
|
|
556
|
+
action_id: "route_picker",
|
|
557
|
+
label: "Document to inspect",
|
|
558
|
+
options: routes.map((route) => ({ label: route, value: route }))
|
|
559
|
+
}
|
|
560
|
+
],
|
|
561
|
+
submit: { label: "View document SEO", action_id: "view_document" }
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return { blocks };
|
|
565
|
+
}
|
|
566
|
+
async function readAllFindings(kv) {
|
|
567
|
+
const entries = await kv.list(
|
|
568
|
+
"findings:"
|
|
569
|
+
);
|
|
570
|
+
const out = [];
|
|
571
|
+
for (const entry of entries) {
|
|
572
|
+
if (entry.value === null) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const route = entry.key.replace(/^findings:/, "");
|
|
576
|
+
for (const finding of entry.value.findings) {
|
|
577
|
+
out.push({ ...finding, document_route: route });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
function uniqueRoutes(rows) {
|
|
583
|
+
const routes = /* @__PURE__ */ new Set();
|
|
584
|
+
for (const row of rows) {
|
|
585
|
+
routes.add(row.document_route);
|
|
586
|
+
}
|
|
587
|
+
return [...routes].sort();
|
|
588
|
+
}
|
|
589
|
+
function severityFirst(a, b) {
|
|
590
|
+
const rank = (severity) => severity === "error" ? 0 : 1;
|
|
591
|
+
const diff = rank(a.severity) - rank(b.severity);
|
|
592
|
+
if (diff !== 0) {
|
|
593
|
+
return diff;
|
|
594
|
+
}
|
|
595
|
+
return a.rule_id.localeCompare(b.rule_id);
|
|
596
|
+
}
|
|
597
|
+
function findingsTable(rows) {
|
|
598
|
+
return {
|
|
599
|
+
type: "table",
|
|
600
|
+
columns: [
|
|
601
|
+
{ key: "route", label: "Route" },
|
|
602
|
+
{ key: "rule", label: "Rule", format: "code" },
|
|
603
|
+
{ key: "severity", label: "Severity", format: "badge" },
|
|
604
|
+
{ key: "message", label: "Message" }
|
|
605
|
+
],
|
|
606
|
+
rows: rows.map((row) => ({
|
|
607
|
+
route: row.document_route,
|
|
608
|
+
rule: row.rule_id,
|
|
609
|
+
severity: row.severity,
|
|
610
|
+
message: row.message
|
|
611
|
+
}))
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
async function renderScoreWidget(ctx) {
|
|
615
|
+
const documents = await readAllDocuments(ctx.kv);
|
|
616
|
+
if (documents.length === 0) {
|
|
617
|
+
return {
|
|
618
|
+
blocks: [
|
|
619
|
+
{ type: "header", text: "SEO score" },
|
|
620
|
+
{
|
|
621
|
+
type: "context",
|
|
622
|
+
text: "No documents saved yet \u2014 score appears after the first emdash save."
|
|
623
|
+
}
|
|
624
|
+
]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const score = await scoreSite(documents);
|
|
628
|
+
const blocks = [
|
|
629
|
+
{
|
|
630
|
+
type: "stats",
|
|
631
|
+
items: [
|
|
632
|
+
{
|
|
633
|
+
label: "Overall",
|
|
634
|
+
value: `${score.overall_score}`
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
label: "Citation",
|
|
638
|
+
value: `${score.citation_readiness_score}`
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
label: "Truth",
|
|
642
|
+
value: `${score.truth_consistency_score}`
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
label: "Answers",
|
|
646
|
+
value: `${score.answer_pack_score}`
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
}
|
|
650
|
+
];
|
|
651
|
+
if (score.overall_score < 60) {
|
|
652
|
+
blocks.unshift({
|
|
653
|
+
type: "banner",
|
|
654
|
+
title: `Site score is ${score.overall_score} \u2014 below the 60 quality threshold`,
|
|
655
|
+
variant: "alert"
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (score.blockers.length > 0) {
|
|
659
|
+
blocks.push({
|
|
660
|
+
type: "context",
|
|
661
|
+
text: topBlockersLine(score)
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return { blocks };
|
|
665
|
+
}
|
|
666
|
+
function topBlockersLine(score) {
|
|
667
|
+
const top = score.blockers.slice(0, 3).map((blocker) => blocker.message);
|
|
668
|
+
if (top.length === 0) {
|
|
669
|
+
return "No blockers identified.";
|
|
670
|
+
}
|
|
671
|
+
return `Top blockers: ${top.join(" \u2022 ")}`;
|
|
672
|
+
}
|
|
673
|
+
async function renderDocumentPanel(ctx, route) {
|
|
674
|
+
if (route === void 0) {
|
|
675
|
+
return {
|
|
676
|
+
blocks: [
|
|
677
|
+
{ type: "header", text: "Document SEO" },
|
|
678
|
+
{
|
|
679
|
+
type: "context",
|
|
680
|
+
text: "Pick a document from the SEO findings list to see its rule findings here."
|
|
681
|
+
}
|
|
682
|
+
]
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const findings = await readFindings(ctx.kv, route);
|
|
686
|
+
const errors = findings.filter((finding) => finding.severity === "error");
|
|
687
|
+
const warnings = findings.filter((finding) => finding.severity === "warning");
|
|
688
|
+
const sorted = [...findings].sort(severityFirst);
|
|
689
|
+
return {
|
|
690
|
+
blocks: [
|
|
691
|
+
{ type: "header", text: `Document SEO \u2014 ${route}` },
|
|
692
|
+
{
|
|
693
|
+
type: "context",
|
|
694
|
+
text: findings.length === 0 ? `No findings for ${route} \u2014 the document is currently clean.` : `${findings.length} findings \u2014 ${errors.length} errors, ${warnings.length} warnings.`
|
|
695
|
+
},
|
|
696
|
+
...findings.length === 0 ? [] : [{ type: "divider" }, documentFindingsTable(sorted)]
|
|
697
|
+
]
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function documentFindingsTable(findings) {
|
|
701
|
+
return {
|
|
702
|
+
type: "table",
|
|
703
|
+
columns: [
|
|
704
|
+
{ key: "rule", label: "Rule", format: "code" },
|
|
705
|
+
{ key: "severity", label: "Severity", format: "badge" },
|
|
706
|
+
{ key: "message", label: "Message" },
|
|
707
|
+
{ key: "block", label: "Block", format: "code" }
|
|
708
|
+
],
|
|
709
|
+
rows: findings.map((finding) => ({
|
|
710
|
+
rule: finding.rule_id,
|
|
711
|
+
severity: finding.severity,
|
|
712
|
+
message: finding.message,
|
|
713
|
+
// The bridge stamps every Portable Text block with id + data-pt-key
|
|
714
|
+
// when rendering; surfacing the path:line locator here lets authors
|
|
715
|
+
// locate the failing block via the editor's ⌘-F.
|
|
716
|
+
block: `${finding.path}:${finding.line}`
|
|
717
|
+
}))
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
async function renderSetupPage(ctx, options = {}) {
|
|
721
|
+
const existing = await readSidecarConfig(ctx.kv);
|
|
722
|
+
const blocks = [
|
|
723
|
+
{ type: "header", text: "seogeo setup" },
|
|
724
|
+
{
|
|
725
|
+
type: "context",
|
|
726
|
+
text: "Paste the URL and auth token of your deployed seogeo-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."
|
|
727
|
+
}
|
|
728
|
+
];
|
|
729
|
+
if (options.bannerError !== void 0) {
|
|
730
|
+
blocks.push({
|
|
731
|
+
type: "banner",
|
|
732
|
+
title: options.bannerError,
|
|
733
|
+
variant: "error"
|
|
734
|
+
});
|
|
735
|
+
} else if (options.bannerSuccess !== void 0) {
|
|
736
|
+
blocks.push({
|
|
737
|
+
type: "banner",
|
|
738
|
+
title: options.bannerSuccess,
|
|
739
|
+
variant: "default"
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
blocks.push(
|
|
743
|
+
{
|
|
744
|
+
type: "form",
|
|
745
|
+
fields: [
|
|
746
|
+
{
|
|
747
|
+
type: "text_input",
|
|
748
|
+
action_id: "evaluator_url",
|
|
749
|
+
label: "Sidecar URL",
|
|
750
|
+
placeholder: "https://seogeo-crawl-worker.<subdomain>.workers.dev",
|
|
751
|
+
initial_value: options.initialUrl ?? existing?.url ?? ""
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
type: "secret_input",
|
|
755
|
+
action_id: "eval_token",
|
|
756
|
+
label: "EVAL_TOKEN",
|
|
757
|
+
placeholder: existing === null ? "Generate with: openssl rand -hex 32" : "Leave blank to keep the existing token",
|
|
758
|
+
has_value: existing !== null
|
|
759
|
+
}
|
|
760
|
+
],
|
|
761
|
+
submit: {
|
|
762
|
+
label: "Save",
|
|
763
|
+
action_id: "save_setup"
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
{ type: "divider" },
|
|
767
|
+
{
|
|
768
|
+
type: "context",
|
|
769
|
+
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.`
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
return { blocks };
|
|
773
|
+
}
|
|
774
|
+
async function handleSetupSubmit(ctx, values) {
|
|
775
|
+
const rawUrl = values["evaluator_url"];
|
|
776
|
+
const rawToken = values["eval_token"];
|
|
777
|
+
const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
|
|
778
|
+
const token = typeof rawToken === "string" ? rawToken.trim() : "";
|
|
779
|
+
if (url.length === 0) {
|
|
780
|
+
return renderSetupPage(ctx, {
|
|
781
|
+
bannerError: "Sidecar URL is required.",
|
|
782
|
+
initialUrl: url
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
let parsed;
|
|
786
|
+
try {
|
|
787
|
+
parsed = new URL(url);
|
|
788
|
+
} catch {
|
|
789
|
+
return renderSetupPage(ctx, {
|
|
790
|
+
bannerError: `Sidecar URL is not a valid URL: ${url}`,
|
|
791
|
+
initialUrl: url
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
795
|
+
return renderSetupPage(ctx, {
|
|
796
|
+
bannerError: `Sidecar URL must be http(s): got ${parsed.protocol}`,
|
|
797
|
+
initialUrl: url
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (parsed.hostname === "localhost" || parsed.hostname.endsWith(".localhost") || /^127\./.test(parsed.hostname)) {
|
|
801
|
+
return renderSetupPage(ctx, {
|
|
802
|
+
bannerError: "emdash's sandbox blocks fetches to localhost (anti-SSRF). Use the deployed *.workers.dev URL.",
|
|
803
|
+
initialUrl: url
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
const existing = await readSidecarConfig(ctx.kv);
|
|
807
|
+
let resolvedToken;
|
|
808
|
+
if (token.length > 0) {
|
|
809
|
+
resolvedToken = token;
|
|
810
|
+
} else if (existing !== null) {
|
|
811
|
+
resolvedToken = existing.token;
|
|
812
|
+
} else {
|
|
813
|
+
return renderSetupPage(ctx, {
|
|
814
|
+
bannerError: "EVAL_TOKEN is required on first setup.",
|
|
815
|
+
initialUrl: url
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
const next = { url, token: resolvedToken };
|
|
819
|
+
await writeSidecarConfig(ctx.kv, next);
|
|
820
|
+
return renderSetupPage(ctx, {
|
|
821
|
+
bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function notFound(page) {
|
|
825
|
+
return {
|
|
826
|
+
blocks: [
|
|
827
|
+
{ type: "header", text: "Not found" },
|
|
828
|
+
{ type: "context", text: `unknown page: ${page}` }
|
|
829
|
+
]
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
var sandbox_entry_default = definePlugin({
|
|
833
|
+
hooks: {
|
|
834
|
+
"content:afterSave": handleAfterSave
|
|
835
|
+
},
|
|
836
|
+
routes: {
|
|
837
|
+
admin: { handler: handleAdminRoute }
|
|
838
|
+
},
|
|
839
|
+
// emdash's MCP server picks tools up from this field. The exact
|
|
840
|
+
// host-side spec for plugin-contributed tools is still being mapped;
|
|
841
|
+
// the field is harmless if unrecognized.
|
|
842
|
+
mcpTools: tools
|
|
843
|
+
});
|
|
844
|
+
export {
|
|
845
|
+
sandbox_entry_default as default
|
|
846
|
+
};
|