@avocadostudio-ai/orchestrator-core 0.3.3 → 0.4.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/dist/chat/chat-pipeline.js +16 -1
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/checks/field-walk.d.ts +18 -1
- package/dist/checks/field-walk.js +46 -0
- package/dist/checks/rules-draft.js +79 -15
- package/dist/checks/run-checks.d.ts +11 -1
- package/dist/checks/run-checks.js +11 -4
- package/dist/checks/session-runner.js +4 -0
- package/dist/checks/types.d.ts +44 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/handler/create-orchestrator.js +220 -32
- package/dist/http/history-actions.d.ts +43 -0
- package/dist/http/history-actions.js +122 -0
- package/dist/http/publish-actions.d.ts +11 -0
- package/dist/http/publish-actions.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +12 -26
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -4,6 +4,7 @@ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
|
|
|
4
4
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
5
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
6
|
import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
+
import { getSiteAssets } from "../state/site-assets.js";
|
|
7
8
|
import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
|
|
8
9
|
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
9
10
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
@@ -336,12 +337,26 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
336
337
|
}
|
|
337
338
|
const sanitizedMessage = sanitizeMessageForPlanning(body.message ?? "");
|
|
338
339
|
const pageDirectory = buildPageDirectory(body.session);
|
|
340
|
+
/*
|
|
341
|
+
* The site's documents ride along with the page list.
|
|
342
|
+
*
|
|
343
|
+
* Without them "link the winter menu" has no referent: the planner knows the
|
|
344
|
+
* pages and the block schemas and nothing about the fifteen PDFs the site
|
|
345
|
+
* hosts, so it writes a path that looks right and is not — which is exactly
|
|
346
|
+
* how a link to a misspelled filename gets into a page. `undefined` when the
|
|
347
|
+
* site cannot enumerate its documents, in which case the block simply omits
|
|
348
|
+
* the section rather than claiming the site has none.
|
|
349
|
+
*/
|
|
350
|
+
const siteDocuments = await getSiteAssets();
|
|
339
351
|
const siteContextBlock = buildSiteContextBlock({
|
|
340
352
|
sitePurpose: body.sitePurpose,
|
|
341
353
|
siteHosting: body.siteHosting,
|
|
342
354
|
businessContext: body.businessContext,
|
|
343
355
|
siteContext: body.siteContext,
|
|
344
|
-
pageDirectory: pageDirectory || undefined
|
|
356
|
+
pageDirectory: pageDirectory || undefined,
|
|
357
|
+
...(siteDocuments?.length
|
|
358
|
+
? { documents: siteDocuments.map((d) => ({ path: d.path, ...(d.name ? { name: d.name } : {}) })) }
|
|
359
|
+
: {})
|
|
345
360
|
});
|
|
346
361
|
// Site context is now passed to the LLM system prompt (cacheable) instead of the user message.
|
|
347
362
|
let plannerMessage = plannerMessageWithPendingContext(body.session, sanitizedMessage);
|
package/dist/chat/prompts.d.ts
CHANGED
|
@@ -65,3 +65,8 @@ export declare function buildPlannerSystemPromptSegments(opts: PlannerPromptOpti
|
|
|
65
65
|
stable: string;
|
|
66
66
|
dynamic: string;
|
|
67
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* The subset of the corrections above that is true of THIS site's catalogue.
|
|
70
|
+
* Returns one prompt line per surviving correction, or an empty array.
|
|
71
|
+
*/
|
|
72
|
+
export declare function propNameCorrectionLines(effectiveBlockTypes: string[]): string[];
|
package/dist/chat/prompts.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Eliminates duplication between OpenAI and Anthropic planner modules.
|
|
5
5
|
* Provider-specific extensions are injected via the `provider` option.
|
|
6
6
|
*/
|
|
7
|
+
import { blockAcceptsProp, blockListItemAcceptsKey } from "@avocadostudio-ai/shared";
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Intent parser
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
@@ -154,7 +155,13 @@ const ANTHROPIC_IMAGE_TOOL_LINES = [
|
|
|
154
155
|
"When using image.generate, write the returned imageUrl into the relevant imageUrl field and set imageAlt from the returned alt text.",
|
|
155
156
|
];
|
|
156
157
|
const BLOCK_NAME_PRIVACY_OPENAI = "Never mention internal block IDs (b_hero_*, b_featuregrid_*, etc.), prop names (imageUrl, imageAlt), or system settings in summary_for_user or change_log. Use human-friendly descriptions instead (e.g. 'Update the Hero image' not 'Update imageUrl on b_hero_123').";
|
|
157
|
-
|
|
158
|
+
/*
|
|
159
|
+
* Stated as a rule about identifiers rather than as a list of our block names:
|
|
160
|
+
* a site with its own catalogue has its own CamelCase type names, and an
|
|
161
|
+
* allowlist of ours neither covers them nor describes them. The test is whether
|
|
162
|
+
* the word reads as English to someone who has never seen the schema.
|
|
163
|
+
*/
|
|
164
|
+
const BLOCK_NAME_PRIVACY_ANTHROPIC = "Never mention internal block IDs (b_hero_*, b_featuregrid_*, etc.), prop names (imageUrl, imageAlt), or system settings in summary_for_user, change_log, or suggested_next_actions. Also avoid raw block type names — they are schema identifiers, not words users know. Describe the section by what it is: a type named 'RichText' is a 'text section', 'FeatureGrid' a 'features grid', 'FAQAccordion' an 'FAQ section', 'heroSplit' or 'PageHeader' just 'the hero' or 'the page header'. The test is whether the word reads as ordinary English rather than as a name from a schema; where it does — 'Hero', 'CTA', 'Testimonials', 'Gallery' — using it as-is is fine.";
|
|
158
165
|
// ---------------------------------------------------------------------------
|
|
159
166
|
// Full planner prompt — composed from section builders.
|
|
160
167
|
// Each section becomes a ## HEADER in the emitted prompt so the LLM can
|
|
@@ -168,16 +175,18 @@ function joinSections(sections) {
|
|
|
168
175
|
}
|
|
169
176
|
function buildFullPlannerSegments(opts) {
|
|
170
177
|
const hasNativeTools = opts.provider === "anthropic" || opts.provider === "gemini";
|
|
171
|
-
// Stable sections —
|
|
172
|
-
//
|
|
173
|
-
// the
|
|
174
|
-
//
|
|
178
|
+
// Stable sections — the part Anthropic caches. "Stable" means stable across
|
|
179
|
+
// *requests*, not constant: IMAGES varies with hasNativeTools and OPERATION
|
|
180
|
+
// CATALOG with the site's block catalogue, and both are fixed for a given
|
|
181
|
+
// provider and site. Two sites with different catalogues get two cache
|
|
182
|
+
// entries, which is the correct trade — a prompt that tells the model the
|
|
183
|
+
// wrong prop names caches beautifully and edits the wrong field.
|
|
175
184
|
const stableSections = [
|
|
176
185
|
sectionRole(),
|
|
177
186
|
sectionOutputContract(),
|
|
178
187
|
sectionIntentDecisionTree(),
|
|
179
188
|
sectionVoice(opts, hasNativeTools),
|
|
180
|
-
sectionOperationCatalog(),
|
|
189
|
+
sectionOperationCatalog(opts),
|
|
181
190
|
sectionSchemaDiscipline(),
|
|
182
191
|
sectionImages(hasNativeTools),
|
|
183
192
|
];
|
|
@@ -241,14 +250,88 @@ function sectionVoice(opts, hasNativeTools) {
|
|
|
241
250
|
if (hasNativeTools) {
|
|
242
251
|
lines.push("For edit_plan intent: summary_for_user must be ONE short sentence (max ~20 words) describing what the plan will do. Do NOT elaborate, explain why, or describe the content being added — let change_log carry the detail. Bad: 'Updated the hero heading with a punchier tone.' Good: 'Will add a **text section** about blueberry varieties after the features grid.'", "change_log coverage is MANDATORY: emit exactly one change_log entry per op, in the same order as ops[], describing what that specific op does. If ops has N entries, change_log must have N entries — never cluster multiple ops into one entry, never skip an op, never leave an op undescribed. The user reads change_log to decide whether to approve; a missing entry is a silent bait-and-switch.", "change_log entries should add specific detail NOT already in summary_for_user — e.g. list the actual content, items, or values being set. Do not paraphrase the summary.");
|
|
243
252
|
}
|
|
244
|
-
lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types in blockContracts / blockCatalogue
|
|
253
|
+
lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions", "2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made — not a generic action. NEVER suggest 'Open /X' or any navigation to a page that the current plan is creating, duplicating, or otherwise still pending — the page won't exist until the user approves the plan, and there is no special navigation chip handler (suggestions are sent verbatim as the next chat command). Suggest plan refinements instead (e.g. 'Use a punchier hero headline', 'Add a card grid for spotlights', 'Drop the FAQ section'). Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Revert to previous'). For example, after rewriting stats labels, suggest refining the same section ('Make the numbers bigger', 'Add a stat about X') — not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Every suggestion must be an action the user can perform inside this editor — restricted to the block types listed in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits. Never suggest adding a section this site has no block for; the catalogue is the whole list, not a sample of a larger one. NEVER suggest unsupported features: no forms, no email capture, no contact forms, no subscribe boxes, no newsletter signups, no popups/modals, no chat widgets, no live video, no payment/checkout — these require custom code the editor cannot produce. Never suggest actions outside the editor's scope such as A/B testing, analytics, performance monitoring, user research, or marketing strategy.");
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
/*
|
|
257
|
+
* Prop-name corrections the model actually needs, and the reason they are not
|
|
258
|
+
* written as prose.
|
|
259
|
+
*
|
|
260
|
+
* Models reach for the obvious English word — `heading` for a section title,
|
|
261
|
+
* `question`/`answer` for an FAQ entry, `testimonial` for a quote — and several
|
|
262
|
+
* of Avocado's built-in blocks chose a different name. Telling the model so is
|
|
263
|
+
* worth real accuracy, and the line that did it read: "use 'title' not
|
|
264
|
+
* 'heading' for section titles (except Hero which uses 'heading')".
|
|
265
|
+
*
|
|
266
|
+
* That is a fact about Avocado's own catalogue stated as a fact about section
|
|
267
|
+
* titles. A site that brings its own blocks and names a text prop `heading` was
|
|
268
|
+
* being instructed, in the system prompt, to emit a prop its schema does not
|
|
269
|
+
* have. It is the same mistake the plan normalizer was fixed for in 0.3.3 — the
|
|
270
|
+
* normalizer no longer *creates* the wrong prop name, but nothing had stopped
|
|
271
|
+
* the prompt from *asking* for it, so the bug simply moved one layer up.
|
|
272
|
+
*
|
|
273
|
+
* So each correction now names the block type it is about, and is emitted only
|
|
274
|
+
* when the registry confirms it is still true: the type is in this site's
|
|
275
|
+
* catalogue, it really does reject the wrong key, and it really does accept the
|
|
276
|
+
* right one. A site that registers its own `Hero` with a `title` prop gets no
|
|
277
|
+
* line about Hero; a site with no FAQAccordion gets no line about `q`/`a`.
|
|
278
|
+
*/
|
|
279
|
+
const PROP_NAME_CORRECTIONS = [
|
|
280
|
+
{ type: "Hero", wrong: "title", right: "heading", what: "the headline" },
|
|
281
|
+
{ type: "FeatureGrid", wrong: "heading", right: "title", what: "the section title" },
|
|
282
|
+
{ type: "CardGrid", wrong: "heading", right: "title", what: "the section title" },
|
|
283
|
+
{ type: "Testimonials", wrong: "heading", right: "title", what: "the section title" },
|
|
284
|
+
{ type: "FAQAccordion", wrong: "heading", right: "title", what: "the section title" },
|
|
285
|
+
{ type: "CTA", wrong: "title", right: "heading", what: "the headline" },
|
|
286
|
+
];
|
|
287
|
+
const LIST_ITEM_CORRECTIONS = [
|
|
288
|
+
{ type: "FAQAccordion", list: "items", wrong: "question", right: "q" },
|
|
289
|
+
{ type: "FAQAccordion", list: "items", wrong: "answer", right: "a" },
|
|
290
|
+
{ type: "Testimonials", list: "items", wrong: "testimonial", right: "quote" },
|
|
291
|
+
];
|
|
292
|
+
/**
|
|
293
|
+
* The subset of the corrections above that is true of THIS site's catalogue.
|
|
294
|
+
* Returns one prompt line per surviving correction, or an empty array.
|
|
295
|
+
*/
|
|
296
|
+
export function propNameCorrectionLines(effectiveBlockTypes) {
|
|
297
|
+
const present = new Set(effectiveBlockTypes);
|
|
298
|
+
const lines = [];
|
|
299
|
+
for (const c of PROP_NAME_CORRECTIONS) {
|
|
300
|
+
if (!present.has(c.type))
|
|
301
|
+
continue;
|
|
302
|
+
if (blockAcceptsProp(c.type, c.wrong))
|
|
303
|
+
continue;
|
|
304
|
+
if (!blockAcceptsProp(c.type, c.right))
|
|
305
|
+
continue;
|
|
306
|
+
lines.push(`${c.type} names ${c.what} '${c.right}', not '${c.wrong}'.`);
|
|
307
|
+
}
|
|
308
|
+
for (const c of LIST_ITEM_CORRECTIONS) {
|
|
309
|
+
if (!present.has(c.type))
|
|
310
|
+
continue;
|
|
311
|
+
if (blockListItemAcceptsKey(c.type, c.list, c.wrong))
|
|
312
|
+
continue;
|
|
313
|
+
if (!blockListItemAcceptsKey(c.type, c.list, c.right))
|
|
314
|
+
continue;
|
|
315
|
+
lines.push(`${c.type} \`${c.list}\` entries use '${c.right}', not '${c.wrong}'.`);
|
|
316
|
+
}
|
|
245
317
|
return lines;
|
|
246
318
|
}
|
|
247
|
-
function sectionOperationCatalog() {
|
|
319
|
+
function sectionOperationCatalog(opts) {
|
|
320
|
+
/*
|
|
321
|
+
* The universal rule first, so it governs even when no correction below
|
|
322
|
+
* applies: a prop name is a property of one block type's schema, never of the
|
|
323
|
+
* English word for the thing it holds.
|
|
324
|
+
*/
|
|
325
|
+
const corrections = propNameCorrectionLines(opts.effectiveBlockTypes);
|
|
326
|
+
const addBlockPropRule = "add_block: use the exact prop names blockContracts lists for THAT block type. A prop name is a fact about one block's schema, not about the kind of content it holds — never carry a prop name across from another block type, and never infer one from the English word for the field ('heading', 'question', 'subtitle'). If blockContracts does not list a prop, the block does not have it: omit it rather than inventing a plausible name." +
|
|
327
|
+
(corrections.length > 0
|
|
328
|
+
? ` Corrections for blocks on this site that models routinely get wrong: ${corrections.join(" ")}`
|
|
329
|
+
: "") +
|
|
330
|
+
" Always populate block.props with REAL content matching the user's request (headlines, body copy, list items grounded in the user's topic) — never emit add_block with empty or missing props; the system falls back to demo template defaults if you omit props, and that template will mislead the user. Placement: set `afterBlockId` to the id of the block the new section should follow — e.g. to add a section 'below the hero' / 'under the hero' / 'after the hero', set afterBlockId to the hero block's id from the pageOutline. Honor any position the user names ('below the hero', 'above the footer', 'after the pricing'). Omit afterBlockId only when no position is implied — it then appends at the very end of the page.";
|
|
248
331
|
return [
|
|
249
332
|
"## OPERATION CATALOG",
|
|
250
333
|
"update_props: blockId is required and must target an existing block id (b_*). Never use a page route/path as blockId or path. Use blockId values from the pageOutline — never invent block IDs. Set patch to changed props only; use existing prop keys for the target block type. Emit keys in this exact order: op, pageSlug (if present), blockId, patch.",
|
|
251
|
-
|
|
334
|
+
addBlockPropRule,
|
|
252
335
|
"remove_block: delete an ENTIRE block/section from the page (blockId required, no listKey). Use this — NOT remove_item — whenever the user removes a whole section, INCLUDING when they pick it by POSITION or TYPE: 'delete the second card grid', 'remove the first feature grid', 'hide the third section', 'remove the FAQ', 'get rid of the testimonials'. For 'remove ALL the <type>' / 'delete every <type>', emit one remove_block per matching block. remove_item is ONLY for deleting ONE entry inside a block's list and is signalled by an item word scoped INTO a container ('remove the last card IN the grid', 'delete a question FROM the FAQ'). A bare '[the Nth] <block-or-section-noun>' with no into-container preposition is ALWAYS a remove_block — never remove an item to satisfy it.",
|
|
253
336
|
"add_item / update_item / remove_item / move_item: edit ONE entry inside a block's list prop (e.g. FeatureGrid `features`, FAQAccordion `items`, Testimonials `items`, CardGrid `cards`). Required on every item op: pageSlug, blockId, listKey. Address the target entry by its stable `itemId` (the item's `id` field) WHEN the block's full props are in your context — never guess or invent an itemId you cannot see. Otherwise use the 0-based `index`; the system resolves it against the current page state, so an index is safe too. add_item: supply `item` with the entry's props (do NOT invent an `id`, one is assigned); optional `afterItemId` (or `afterIndex`) positions it, omit ⇒ append. update_item: `patch` is a merge-patch over that entry's fields. move_item: `afterItemId` (or `afterIndex`) sets the new position, omit ⇒ move to front. When changing SEVERAL entries of one list in a single plan, replacing the whole list with one update_props op is also fine.",
|
|
254
337
|
"update_page_meta: set SEO metadata (title, description, ogImage) on a page. Patch is merge-patch: only supplied keys update. Set a field to empty string to clear it.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BlockManifest, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
-
import type { FieldEntry } from "./types.ts";
|
|
2
|
+
import type { FieldEntry, LinkEntry } from "./types.ts";
|
|
3
3
|
export declare function walkPageFields(page: PageDoc, manifest: BlockManifest): FieldEntry[];
|
|
4
4
|
/**
|
|
5
5
|
* Readable text inside a field value.
|
|
@@ -23,3 +23,20 @@ export declare function fieldText(value: unknown): string;
|
|
|
23
23
|
* has two of anything else.
|
|
24
24
|
*/
|
|
25
25
|
export declare function groupByBlock(fields: FieldEntry[]): Map<string, FieldEntry[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Every link on the page, from the two places links live.
|
|
28
|
+
*
|
|
29
|
+
* The first is a `link`- or `url`-kind prop, which is what every link-aware
|
|
30
|
+
* rule already walked. The second is prose — a markdown or ProseMirror link
|
|
31
|
+
* inside a richtext body — which none of them did, and which on a content page
|
|
32
|
+
* is where most of the links are. A live site's Bistro section carries four
|
|
33
|
+
* menu-PDF links written exactly that way; not one was ever inspected, and the
|
|
34
|
+
* one pointing at a misspelled filename had been wrong for months with nothing
|
|
35
|
+
* able to see it.
|
|
36
|
+
*
|
|
37
|
+
* A prose link is attributed to the field that contains it. That is honest
|
|
38
|
+
* about what we can offer — the panel can open the body, but there is no
|
|
39
|
+
* editable path for a span inside it — and `inProse` lets a rule word its
|
|
40
|
+
* finding accordingly instead of pretending otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export declare function walkPageLinks(page: PageDoc, manifest: BlockManifest): LinkEntry[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { linksInRichText } from "@avocadostudio-ai/shared";
|
|
1
2
|
/*
|
|
2
3
|
* Flatten a page's block props into located, kind-tagged fields.
|
|
3
4
|
*
|
|
@@ -150,3 +151,48 @@ export function groupByBlock(fields) {
|
|
|
150
151
|
}
|
|
151
152
|
return byBlock;
|
|
152
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Every link on the page, from the two places links live.
|
|
156
|
+
*
|
|
157
|
+
* The first is a `link`- or `url`-kind prop, which is what every link-aware
|
|
158
|
+
* rule already walked. The second is prose — a markdown or ProseMirror link
|
|
159
|
+
* inside a richtext body — which none of them did, and which on a content page
|
|
160
|
+
* is where most of the links are. A live site's Bistro section carries four
|
|
161
|
+
* menu-PDF links written exactly that way; not one was ever inspected, and the
|
|
162
|
+
* one pointing at a misspelled filename had been wrong for months with nothing
|
|
163
|
+
* able to see it.
|
|
164
|
+
*
|
|
165
|
+
* A prose link is attributed to the field that contains it. That is honest
|
|
166
|
+
* about what we can offer — the panel can open the body, but there is no
|
|
167
|
+
* editable path for a span inside it — and `inProse` lets a rule word its
|
|
168
|
+
* finding accordingly instead of pretending otherwise.
|
|
169
|
+
*/
|
|
170
|
+
export function walkPageLinks(page, manifest) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const field of walkPageFields(page, manifest)) {
|
|
173
|
+
const located = {
|
|
174
|
+
blockId: field.blockId,
|
|
175
|
+
blockType: field.blockType,
|
|
176
|
+
...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
|
|
177
|
+
path: field.path,
|
|
178
|
+
...(field.label ? { label: field.label } : {})
|
|
179
|
+
};
|
|
180
|
+
if (field.kind === "link" || field.kind === "url" || field.kind === "file") {
|
|
181
|
+
if (typeof field.value === "string" && field.value.trim() !== "") {
|
|
182
|
+
out.push({ ...located, value: field.value, inProse: false });
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
/*
|
|
187
|
+
* Only richtext. A `text` prop holding something that looks like markdown
|
|
188
|
+
* is a string that happens to contain brackets, and treating it as prose
|
|
189
|
+
* would invent links out of product copy.
|
|
190
|
+
*/
|
|
191
|
+
if (field.kind !== "richtext")
|
|
192
|
+
continue;
|
|
193
|
+
for (const href of linksInRichText(field.value)) {
|
|
194
|
+
out.push({ ...located, value: href, inProse: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IMAGE_PLACEHOLDER, isKnownRoute, parseLink, toAltPath } from "@avocadostudio-ai/shared";
|
|
1
|
+
import { IMAGE_PLACEHOLDER, isKnownRoute, normalizeLinkPath, parseLink, toAltPath } from "@avocadostudio-ai/shared";
|
|
2
2
|
import { fieldText, groupByBlock } from "./field-walk.js";
|
|
3
3
|
/*
|
|
4
4
|
* The eleven-ish draft-tier rules. Each is a pure function; none does IO.
|
|
@@ -253,35 +253,98 @@ const thinContent = {
|
|
|
253
253
|
// ---------------------------------------------------------------------------
|
|
254
254
|
// Links, images, leftovers
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
|
+
/** A link's evidence, which points at the field the link was written in. */
|
|
257
|
+
function linkEvidence(link) {
|
|
258
|
+
return {
|
|
259
|
+
source: "draft",
|
|
260
|
+
blockId: link.blockId,
|
|
261
|
+
blockType: link.blockType,
|
|
262
|
+
...(link.blockLabel ? { blockLabel: link.blockLabel } : {}),
|
|
263
|
+
path: link.path,
|
|
264
|
+
excerpt: link.value
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/*
|
|
268
|
+
* One finding per link, and a link written in prose needs a key that separates
|
|
269
|
+
* it from its neighbours: three PDF links in one body all sit at the same
|
|
270
|
+
* field path, so keying on the path alone collapses them into one finding and
|
|
271
|
+
* two broken links go unreported.
|
|
272
|
+
*/
|
|
273
|
+
function linkKey(link) {
|
|
274
|
+
const base = link.path === "" ? link.blockId : `${link.blockId}:${link.path}`;
|
|
275
|
+
return link.inProse ? `${base}#${link.value}` : base;
|
|
276
|
+
}
|
|
256
277
|
const internalLinkDead = {
|
|
257
278
|
id: "seo.internal-link-dead",
|
|
258
279
|
agent: "seo",
|
|
259
280
|
severity: "warning",
|
|
260
281
|
run: (ctx) => {
|
|
261
282
|
const known = new Set(ctx.site.slugs);
|
|
262
|
-
return ctx.
|
|
283
|
+
return ctx.links
|
|
263
284
|
/*
|
|
264
|
-
* `
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
285
|
+
* `ctx.links` already carries both sources — `link`/`url`/`file` props
|
|
286
|
+
* and hrefs written into prose. It used to read `ctx.fields` and so saw
|
|
287
|
+
* only the first, which meant the links a content page actually has, the
|
|
288
|
+
* ones inside its bodies, were never checked.
|
|
289
|
+
*
|
|
290
|
+
* `parseLink` screens out mailto/tel/anchors/external, the default "/",
|
|
291
|
+
* and now documents: a `.pdf` path is `kind: "file"` and is not a route,
|
|
292
|
+
* so it is not this rule's business. `fileLinkUnknown` below answers the
|
|
293
|
+
* question that *is* right for a file.
|
|
270
294
|
*/
|
|
271
|
-
.filter((
|
|
295
|
+
.filter((link) => {
|
|
296
|
+
const parsed = parseLink(link.value);
|
|
297
|
+
return parsed.kind === "page" && parsed.path?.startsWith("/") === true;
|
|
298
|
+
})
|
|
272
299
|
/*
|
|
273
300
|
* Absolute routes only, as before. A bare "pricing" is a relative link
|
|
274
301
|
* and broken from any page but the root, but flagging it is a separate
|
|
275
302
|
* judgement call from this rule's, and one that would light up existing
|
|
276
303
|
* sites without warning.
|
|
277
304
|
*/
|
|
278
|
-
.filter((
|
|
279
|
-
.
|
|
280
|
-
|
|
281
|
-
key: field.path === "" ? field.blockId : `${field.blockId}:${field.path}`,
|
|
305
|
+
.filter((link) => !isKnownRoute(link.value, known))
|
|
306
|
+
.map((link) => ({
|
|
307
|
+
key: linkKey(link),
|
|
282
308
|
title: `Link points at a page that does not exist`,
|
|
283
|
-
detail:
|
|
284
|
-
evidence:
|
|
309
|
+
detail: link.inProse ? `${link.value} — written in ${link.label ?? link.path}` : link.value,
|
|
310
|
+
evidence: linkEvidence(link)
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/*
|
|
315
|
+
* A link to a document the site does not have.
|
|
316
|
+
*
|
|
317
|
+
* This is the rule that pays for the `file` kind. A menu PDF is linked by
|
|
318
|
+
* hand-typed path, nothing renders a 404 until a customer clicks it, and the
|
|
319
|
+
* filename is usually long and often misspelled — one live site links
|
|
320
|
+
* `/downloads/AadventureArenaBerm-Gruppen-DE.pdf`, and whether that is the real
|
|
321
|
+
* filename or a typo for it is not a question anyone has been able to ask.
|
|
322
|
+
*
|
|
323
|
+
* It runs only when the site can enumerate its documents. `ctx.site.assets`
|
|
324
|
+
* undefined means it cannot, and then this rule returns nothing rather than
|
|
325
|
+
* reporting every document on the site as missing — the same discipline as
|
|
326
|
+
* `resolveLink`, where "we did not check" must not read as "it is not there".
|
|
327
|
+
*/
|
|
328
|
+
const fileLinkUnknown = {
|
|
329
|
+
id: "content.file-link-unknown",
|
|
330
|
+
agent: "seo",
|
|
331
|
+
severity: "error",
|
|
332
|
+
run: (ctx) => {
|
|
333
|
+
const assets = ctx.site.assets;
|
|
334
|
+
if (!assets)
|
|
335
|
+
return [];
|
|
336
|
+
const known = new Set(assets.map((a) => normalizeLinkPath(a.path)));
|
|
337
|
+
return ctx.links
|
|
338
|
+
.filter((link) => {
|
|
339
|
+
const parsed = parseLink(link.value);
|
|
340
|
+
return parsed.kind === "file" && parsed.path?.startsWith("/") === true;
|
|
341
|
+
})
|
|
342
|
+
.filter((link) => !known.has(normalizeLinkPath(parseLink(link.value).path ?? "")))
|
|
343
|
+
.map((link) => ({
|
|
344
|
+
key: linkKey(link),
|
|
345
|
+
title: "Link points at a document the site does not have",
|
|
346
|
+
detail: link.inProse ? `${link.value} — written in ${link.label ?? link.path}` : link.value,
|
|
347
|
+
evidence: linkEvidence(link)
|
|
285
348
|
}));
|
|
286
349
|
}
|
|
287
350
|
};
|
|
@@ -370,6 +433,7 @@ export const DRAFT_RULES = [
|
|
|
370
433
|
headingOrder,
|
|
371
434
|
thinContent,
|
|
372
435
|
internalLinkDead,
|
|
436
|
+
fileLinkUnknown,
|
|
373
437
|
altMissing,
|
|
374
438
|
unfinished
|
|
375
439
|
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BlockManifest, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
2
|
import type { CheckRunRecord, CheckRunTrigger, DurableStore } from "../durable/types.ts";
|
|
3
|
-
import type { CheckRule } from "./types.ts";
|
|
3
|
+
import type { CheckRule, SiteAsset } from "./types.ts";
|
|
4
4
|
/**
|
|
5
5
|
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
6
6
|
*
|
|
@@ -24,6 +24,16 @@ export type RunChecksArgs = {
|
|
|
24
24
|
* page's title as unique, nor every link into them as dead.
|
|
25
25
|
*/
|
|
26
26
|
slugs?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* The documents the site holds, when the caller could list them.
|
|
29
|
+
*
|
|
30
|
+
* Omitted — not `[]` — when it could not. `content.file-link-unknown` is
|
|
31
|
+
* silent without this, because a site that cannot enumerate its assets has no
|
|
32
|
+
* grounds to call any of its own document links broken. Filling it is the
|
|
33
|
+
* caller's job because listing assets is IO, and every rule in this directory
|
|
34
|
+
* is a pure function by construction.
|
|
35
|
+
*/
|
|
36
|
+
assets?: SiteAsset[];
|
|
27
37
|
rules?: CheckRule[];
|
|
28
38
|
store?: DurableStore;
|
|
29
39
|
runId?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
3
3
|
import { NEUTRAL_PAGE_WEIGHT, impactFor } from "../durable/finding-impact.js";
|
|
4
|
-
import { walkPageFields } from "./field-walk.js";
|
|
4
|
+
import { walkPageFields, walkPageLinks } from "./field-walk.js";
|
|
5
5
|
import { computePageWeights } from "./page-weight.js";
|
|
6
6
|
import { DRAFT_RULES } from "./rules-draft.js";
|
|
7
7
|
/**
|
|
@@ -25,7 +25,10 @@ export async function runDraftChecks(args) {
|
|
|
25
25
|
const site = {
|
|
26
26
|
slugs: args.pages.map((p) => p.slug),
|
|
27
27
|
pages: args.pages.map((p) => ({ slug: p.slug, title: p.title, ...(p.meta ? { meta: p.meta } : {}) })),
|
|
28
|
-
config: args.siteConfig ?? {}
|
|
28
|
+
config: args.siteConfig ?? {},
|
|
29
|
+
// Present only when the caller could enumerate the site's documents.
|
|
30
|
+
// Passing `[]` where it could not would make every file link report broken.
|
|
31
|
+
...(args.assets ? { assets: args.assets } : {})
|
|
29
32
|
};
|
|
30
33
|
const wanted = args.slugs ? new Set(args.slugs) : null;
|
|
31
34
|
const scanned = args.pages.filter((p) => (wanted ? wanted.has(p.slug) : true));
|
|
@@ -41,8 +44,11 @@ export async function runDraftChecks(args) {
|
|
|
41
44
|
* pages reuse their entry rather than being walked a second time.
|
|
42
45
|
*/
|
|
43
46
|
const fieldsBySlug = new Map();
|
|
44
|
-
|
|
47
|
+
const linksBySlug = new Map();
|
|
48
|
+
for (const page of args.pages) {
|
|
45
49
|
fieldsBySlug.set(page.slug, walkPageFields(page, args.manifest));
|
|
50
|
+
linksBySlug.set(page.slug, walkPageLinks(page, args.manifest));
|
|
51
|
+
}
|
|
46
52
|
const weights = computePageWeights({ pages: site.pages, fieldsBySlug, config: site.config });
|
|
47
53
|
await store.startCheckRun({
|
|
48
54
|
id: runId,
|
|
@@ -62,7 +68,8 @@ export async function runDraftChecks(args) {
|
|
|
62
68
|
page,
|
|
63
69
|
site,
|
|
64
70
|
manifest: args.manifest,
|
|
65
|
-
fields: fieldsBySlug.get(page.slug) ?? []
|
|
71
|
+
fields: fieldsBySlug.get(page.slug) ?? [],
|
|
72
|
+
links: linksBySlug.get(page.slug) ?? []
|
|
66
73
|
};
|
|
67
74
|
for (const rule of rules) {
|
|
68
75
|
// One rule throwing must not cost the run every other rule's findings —
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildBlockManifest } from "@avocadostudio-ai/shared";
|
|
2
2
|
import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
|
|
3
3
|
import { runDraftChecks } from "./run-checks.js";
|
|
4
|
+
import { getSiteAssets } from "../state/site-assets.js";
|
|
4
5
|
/*
|
|
5
6
|
* Binds the pure rules engine to session state.
|
|
6
7
|
*
|
|
@@ -10,6 +11,8 @@ import { runDraftChecks } from "./run-checks.js";
|
|
|
10
11
|
* the HTTP action and the triggers below run identical code.
|
|
11
12
|
*/
|
|
12
13
|
export async function runChecksForSession(args) {
|
|
14
|
+
// `undefined` when the site cannot list its documents — see `site-assets.ts`.
|
|
15
|
+
const assets = await getSiteAssets();
|
|
13
16
|
return runDraftChecks({
|
|
14
17
|
scopeKey: args.scopeKey,
|
|
15
18
|
pages: [...getSessionDraft(args.scopeKey).values()],
|
|
@@ -19,6 +22,7 @@ export async function runChecksForSession(args) {
|
|
|
19
22
|
manifest: buildBlockManifest(),
|
|
20
23
|
siteConfig: getSiteConfig(args.scopeKey),
|
|
21
24
|
trigger: args.trigger,
|
|
25
|
+
...(assets ? { assets } : {}),
|
|
22
26
|
...(args.slugs?.length ? { slugs: args.slugs } : {})
|
|
23
27
|
});
|
|
24
28
|
}
|
package/dist/checks/types.d.ts
CHANGED
|
@@ -22,6 +22,37 @@ export type FieldEntry = {
|
|
|
22
22
|
*/
|
|
23
23
|
container: string;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* One link on the page, wherever it was written.
|
|
27
|
+
*
|
|
28
|
+
* Separate from `FieldEntry` because most links are not fields. A `link`-kind
|
|
29
|
+
* prop is one source; the other — and on a content-heavy page much the larger —
|
|
30
|
+
* is prose: `[Menükarte](/downloads/menu-de.pdf)` inside a richtext body. Every
|
|
31
|
+
* link-aware rule read declared fields only, so those were never checked.
|
|
32
|
+
*
|
|
33
|
+
* A prose link has no editable path of its own, which is why `path` points at
|
|
34
|
+
* the *field that contains it* and `inProse` says so. A finding can still send
|
|
35
|
+
* you to the right control; it just cannot highlight the link itself.
|
|
36
|
+
*/
|
|
37
|
+
export type LinkEntry = {
|
|
38
|
+
blockId: string;
|
|
39
|
+
blockType: string;
|
|
40
|
+
blockLabel?: string;
|
|
41
|
+
/** The field the link is in — its own path for a link field, the containing field for a prose link. */
|
|
42
|
+
path: string;
|
|
43
|
+
label?: string;
|
|
44
|
+
/** The href as written. */
|
|
45
|
+
value: string;
|
|
46
|
+
/** True when this came out of a richtext body rather than from a link field. */
|
|
47
|
+
inProse: boolean;
|
|
48
|
+
};
|
|
49
|
+
/** A document the site can link to. Empty is not the same as absent — see `assets`. */
|
|
50
|
+
export type SiteAsset = {
|
|
51
|
+
path: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
contentType?: string;
|
|
54
|
+
size?: number;
|
|
55
|
+
};
|
|
25
56
|
/** The other pages, for the rules that cannot be answered from one page. */
|
|
26
57
|
export type SiteView = {
|
|
27
58
|
slugs: string[];
|
|
@@ -29,6 +60,17 @@ export type SiteView = {
|
|
|
29
60
|
meta?: PageDoc["meta"];
|
|
30
61
|
}>;
|
|
31
62
|
config: SiteConfig;
|
|
63
|
+
/**
|
|
64
|
+
* The documents the site holds, when it can enumerate them.
|
|
65
|
+
*
|
|
66
|
+
* `undefined` and `[]` mean different things and rules must keep them apart:
|
|
67
|
+
* undefined is "this site cannot list its assets", under which no rule may
|
|
68
|
+
* conclude a file link is broken; `[]` is "it listed them, and there are
|
|
69
|
+
* none", under which every file link is broken and saying so is correct.
|
|
70
|
+
* A site answers this by implementing `getMedia`; most do not, and for those
|
|
71
|
+
* the file rules stay silent rather than reporting every document on the site.
|
|
72
|
+
*/
|
|
73
|
+
assets?: SiteAsset[];
|
|
32
74
|
};
|
|
33
75
|
export type CheckContext = {
|
|
34
76
|
scopeKey: string;
|
|
@@ -37,6 +79,8 @@ export type CheckContext = {
|
|
|
37
79
|
manifest: BlockManifest;
|
|
38
80
|
/** Every field on the page, already flattened. Rules should not re-walk. */
|
|
39
81
|
fields: FieldEntry[];
|
|
82
|
+
/** Every link on the page — from link fields and from prose alike. */
|
|
83
|
+
links: LinkEntry[];
|
|
40
84
|
};
|
|
41
85
|
/**
|
|
42
86
|
* What a rule returns.
|