@avocadostudio-ai/orchestrator-core 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/agent/agent-logger.js +2 -1
  2. package/dist/chat/chat-pipeline.js +16 -1
  3. package/dist/chat/prompts.d.ts +5 -0
  4. package/dist/chat/prompts.js +92 -9
  5. package/dist/checks/field-walk.d.ts +18 -1
  6. package/dist/checks/field-walk.js +46 -0
  7. package/dist/checks/rules-draft.js +79 -15
  8. package/dist/checks/run-checks.d.ts +11 -1
  9. package/dist/checks/run-checks.js +11 -4
  10. package/dist/checks/session-runner.js +4 -0
  11. package/dist/checks/types.d.ts +44 -0
  12. package/dist/cms/adapter.d.ts +74 -1
  13. package/dist/cms/adapter.js +1 -0
  14. package/dist/cms/index.d.ts +1 -1
  15. package/dist/cms/index.js +1 -1
  16. package/dist/cms/media-sources.d.ts +29 -1
  17. package/dist/cms/media-sources.js +188 -7
  18. package/dist/handler/create-orchestrator.js +247 -34
  19. package/dist/handler/library-mount.d.ts +6 -0
  20. package/dist/handler/library-mount.js +45 -0
  21. package/dist/http/history-actions.d.ts +43 -0
  22. package/dist/http/history-actions.js +122 -0
  23. package/dist/http/publish-actions.d.ts +11 -0
  24. package/dist/http/publish-actions.js +3 -3
  25. package/dist/image/image-helpers.js +3 -2
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/nlp/intent-detection.d.ts +16 -0
  29. package/dist/nlp/intent-detection.js +15 -1
  30. package/dist/nlp/plan-normalizer.js +12 -26
  31. package/dist/publish/publish-helpers.d.ts +12 -2
  32. package/dist/publish/publish-helpers.js +12 -4
  33. package/dist/publish/publish-selection.d.ts +84 -0
  34. package/dist/publish/publish-selection.js +113 -0
  35. package/dist/publish/targets/git.js +2 -2
  36. package/dist/state/data-dir.d.ts +6 -0
  37. package/dist/state/data-dir.js +35 -0
  38. package/dist/state/site-assets.d.ts +41 -0
  39. package/dist/state/site-assets.js +40 -0
  40. package/dist/state/sqlite-store-singleton.js +3 -17
  41. package/package.json +3 -3
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import { appendFileSync } from "node:fs";
6
6
  import { resolve } from "node:path";
7
- const LOG_PATH = resolve(process.cwd(), "../../.data/agent-log.ndjson");
7
+ import { resolveDataDir } from "../state/data-dir.js";
8
+ const LOG_PATH = resolve(resolveDataDir(), "agent-log.ndjson");
8
9
  export function logAgent(streamId, event, detail, startedAt) {
9
10
  const entry = {
10
11
  ts: Date.now(),
@@ -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);
@@ -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[];
@@ -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
- 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 like 'RichText', 'FeatureGrid', 'CardGrid', 'FAQAccordion' — use natural descriptions instead: 'text section', 'features grid', 'card grid', 'FAQ section'. Exception: 'Hero', 'CTA', and 'Testimonials' are fine as-is since users understand these terms.";
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 — depend only on provider (fixed for a given planner) and
172
- // static rule constants. These bytes are identical across requests and are
173
- // the part Anthropic caches. IMAGES is here too; it depends on hasNativeTools
174
- // but that's stable per provider.
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 (Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, TwoColumn, Banner, Carousel, Embed, Footer, Gallery, Quote, SiteHeader, Stats, Table, Tabs, Video) or SEO/site-config edits. 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.");
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
- "add_block: use exact prop names from blockContracts. 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 will fall back to demo template defaults (e.g. 'Key features / Fast setup / Safe edits / Live updates') if you omit props, and that template will mislead the user. Common mistakes: use 'title' not 'heading' for section titles (except Hero which uses 'heading'), use 'q'/'a' not 'question'/'answer' for FAQ items, use 'quote' not 'testimonial' for Testimonials items. 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.",
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.fields
283
+ return ctx.links
263
284
  /*
264
- * `link` is the kind that means "navigation target"; `url` is still
265
- * accepted because a custom block's manifest may declare either, and a
266
- * dead link is a dead link whichever way it was tagged. `parseLink`
267
- * screens out mailto/tel/anchors/external and the default "/", so only a
268
- * real route reaches `isKnownRoute` — the same matcher the editor's link
269
- * field uses inline, so the panel and the field cannot disagree.
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((f) => f.kind === "link" || f.kind === "url")
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((f) => parseLink(f.value).path?.startsWith("/") === true)
279
- .filter((f) => !isKnownRoute(String(f.value), known))
280
- .map((field) => ({
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: String(field.value),
284
- evidence: evidenceFor(field, String(field.value))
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
- for (const page of args.pages)
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
  }
@@ -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.