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