diff --git a/skills/wix-headless/SKILL.md b/skills/wix-headless/SKILL.md index 882dd10e..b0aed79f 100644 --- a/skills/wix-headless/SKILL.md +++ b/skills/wix-headless/SKILL.md @@ -81,7 +81,7 @@ If `wix.config.json` is present in the working directory, offer: *"I found an ex | 3 | seeders + design-system + image-phase-1 | design-system (fg) | merge `designTokens` into site.json; `emit-design-tokens.mjs`; grep Layout.astro CSS imports | | 4 | components + components-css + image-phase-2 (all bg) | (none — all backgrounded) | `copy-utility-templates.mjs components`; on Image Phase 1 return: `patch-decorative-slots.mjs`; on Phase 3 return: `check-manifest.mjs components` | | 5 | pages | components done + Phase 1 seeders done | `copy-utility-templates.mjs pages` | -| 6 | (bash) | pages done + image-phase-2 done | `check-manifest.mjs pages`; `release.sh`; `finalize-run-json.mjs` | +| 6 | (bash) | pages done + image-phase-2 done | `merge-navigation.mjs` + `merge-home.mjs` (collect each Phase 4 agent's `data.navContributions` / `data.homeContributions`); `check-manifest.mjs pages`; `release.sh`; `finalize-run-json.mjs` | **Phase axis.** Core pipeline: `Phase 1 (Seed) → Phase 2 (Design System) → Phase 3 (Components) → Phase 4 (Pages)`. Image pipeline runs in parallel: Image Phase 1 (Decorative) alongside Phases 1–2; Image Phase 2 (Entity) alongside Phase 3 (depends only on Phase 1 Seed entity IDs + brand). Each Phase 4 agent writes its routes ONCE with both visual design and data queries — no placeholder-then-rewrite split. @@ -263,11 +263,21 @@ Each prompt includes the standard fields PLUS: **Rate-limit recovery for `image-phase-2-entity`.** If the subagent returns `status: "failed"` with an error matching `/limit|quota|resets/i`, do NOT stall or retry the subagent. Run the inline recovery in the orchestrator: batched generate (one call) → concurrent imports (one batch) → concurrent product/CMS PATCHes (one batch). Procedure encoded in `references/shared/IMAGE_GENERATION.md`. -2. **Post-Phase-4 manifest check.** `node "/scripts/check-manifest.mjs" "$(pwd)" pages ""`. Same recovery rules as Phase 3 check. Surface any unrecoverable `missing[]` entries (exit code 1) before invoking release — those are page files Phase 4 didn't write that have no template fallback. Record `{ phase: "manifest-check-pages", seconds }`. +2. **Merge home-page contributions.** Phase 4 page agents now return per-pack `data.homeContributions` (per `references/shared/RETURN_CONTRACT.md`) instead of patching `src/pages/index.astro` themselves. Pipe contributions through the merge script: -3. **Wait for npm install** — the background install from Wave 2. Wait on its handle; do not `sleep`-poll. On non-zero exit follow the recovery ladder in `references/SETUP.md` § "npm install recovery" (foreground retry with timeout, never delete the lockfile). + ```bash + echo "$HOME_CONTRIBUTIONS_JSON" | node "/scripts/merge-home.mjs" "$(pwd)" + ``` + + `$HOME_CONTRIBUTIONS_JSON` is `{contributions: [, ...]}` in pack order. Markers today: `home:stores` (stores featured grid), `home:gift-cards` (gift-cards probe-gated teaser, disabled pack — script reports it in `skipped[]` if the designer didn't emit the marker, status: \`partial\` but non-fatal). Record `{ phase: "merge-home", seconds }`. + + > **Note on stores home-and-nav scope:** that agent still writes `index.astro` directly to perform category-href rewrites on the designer's category cards (non-marker edits). The merge script reads the post-edit file, so those rewrites are preserved. Only the `home:stores` featured-grid section moved to the contribution model. + +3. **Post-Phase-4 manifest check.** `node "/scripts/check-manifest.mjs" "$(pwd)" pages ""`. Same recovery rules as Phase 3 check. Surface any unrecoverable `missing[]` entries (exit code 1) before invoking release — those are page files Phase 4 didn't write that have no template fallback. Record `{ phase: "manifest-check-pages", seconds }`. + +4. **Wait for npm install** — the background install from Wave 2. Wait on its handle; do not `sleep`-poll. On non-zero exit follow the recovery ladder in `references/SETUP.md` § "npm install recovery" (foreground retry with timeout, never delete the lockfile). -4. **`bash /scripts/release.sh`** — runs `npx @wix/cli build` + `release`, extracts the URL from `Site published on `, prints the URL on stdout. Capture build / release timings via `date -u` wrappers and record `{ phase: "build", seconds }` and `{ phase: "release", seconds }`. The script's stdout becomes `outcome.releaseUrl` in `run.json`. This populates the **Frontend link** in headless settings so transactional emails link to the deployed frontend. On build failure, surface the compiler error and stop — do NOT deploy a broken site. +5. **`bash /scripts/release.sh`** — runs `npx @wix/cli build` + `release`, extracts the URL from `Site published on `, prints the URL on stdout. Capture build / release timings via `date -u` wrappers and record `{ phase: "build", seconds }` and `{ phase: "release", seconds }`. The script's stdout becomes `outcome.releaseUrl` in `run.json`. This populates the **Frontend link** in headless settings so transactional emails link to the deployed frontend. On build failure, surface the compiler error and stop — do NOT deploy a broken site. Use `bash /scripts/preview.sh` (not this step) only when the user is iterating on an existing site and explicitly asks for a fast preview without touching production. @@ -298,7 +308,7 @@ One concluding turn containing, in order: 1. **Release URL text first** — bold heading / link at the top so the user sees it immediately. 2. **Compose the draft `run.json` blob** in scratch. Aggregate every subagent return into `phases[]`, set `outcome.previewUrl`, fill `run.started` (from `runStartedAt`) / `run.ended` (capture now via `date -u`), compute `run.totalSeconds`, and compose `requiredPhases[]` — phases that MUST have a captured duration: - - Always: `mcp-bootstrap`, `init-site-json`, `scaffold`, `env-pull`, `seed-utilities`, `emit-design-tokens`, `manifest-check-components`, `manifest-check-pages`, `decorative-slot-patch`, `npm-install`, `build`, `release` (or `preview` if `preview.sh` was used). + - Always: `mcp-bootstrap`, `init-site-json`, `scaffold`, `env-pull`, `seed-utilities`, `emit-design-tokens`, `manifest-check-components`, `manifest-check-pages`, `decorative-slot-patch`, `merge-home`, `npm-install`, `build`, `release` (or `preview` if `preview.sh` was used). - Per app installed in Wave 2: `app-install-`. - When `copy-utility-templates` ran: `copy-utility-templates-components` and/or `copy-utility-templates-pages`. - `image-phase-2-entity`'s duration arrives via its return; record from there if any pack produced entities. diff --git a/skills/wix-headless/references/gift-cards/PAGES.md b/skills/wix-headless/references/gift-cards/PAGES.md index 4df5d68f..1c3ed0cb 100644 --- a/skills/wix-headless/references/gift-cards/PAGES.md +++ b/skills/wix-headless/references/gift-cards/PAGES.md @@ -8,10 +8,10 @@ Files this agent OWNS (writes fresh): - `src/pages/gift-cards.astro` — landing page; redirects to `/` when probe returns null -Files this agent PATCHES (insert at marker, preserve everything else): +Navigation + home contributions (returned as JSON, NOT direct writes): -- `src/components/Navigation.astro` — insert "Gift Cards" link at `` -- `src/pages/index.astro` — insert home teaser at `` +- The "Gift Cards" link is returned as `data.navContributions` (spliced into `Navigation.astro` by `scripts/merge-navigation.mjs`). +- The home teaser is returned as `data.homeContributions` (spliced into `src/pages/index.astro` by `scripts/merge-home.mjs`). Files this agent MUST NOT touch: - `src/utils/gift-cards.ts`, `src/components/GiftCardPurchase.tsx`, `src/styles/components-gift-cards.css` — Components scope. @@ -69,21 +69,33 @@ Body: hero image + name + description + ` **Do NOT write `src/pages/index.astro` from this scope.** Return the contribution as JSON; the orchestrator splices it via `scripts/merge-home.mjs` after all Phase 4 agents return. If the `` marker is absent from the designer's emission (this is `disabled: true` pack — the designer may have skipped it), the merge script reports it in `skipped[]` with reason `MARKER_NOT_FOUND` — non-fatal, observable. - const giftCardProduct = await getGiftCardProduct(); - const giftCardImage = giftCardProduct - ? resolveWixImageUrl(giftCardProduct.image, 800, 600) - : null; - ``` - (`resolveWixImageUrl` import may already be present from the stores patcher — if so, do not re-import.) -3. Locate the line containing ``. Insert the teaser snippet immediately after it (see `templates/_home-teaser-snippet.astro`). +Build the contribution as part of your return JSON: + +```json +{ + "data": { + "homeContributions": { + "imports": [ + "import { getGiftCardProduct } from '../utils/gift-cards';", + "import { resolveWixImageUrl } from '../utils/wix-image';" + ], + "frontmatter": [ + "const giftCardProduct = await getGiftCardProduct().catch(() => null);", + "const giftCardImage = giftCardProduct ? resolveWixImageUrl(giftCardProduct.image, 800, 600) : null;" + ], + "byMarker": { + "home:gift-cards": "{giftCardProduct && (
…teaser snippet from templates/_home-teaser-snippet.astro…
)}" + } + } + } +} +``` + +The merge script dedupes the `resolveWixImageUrl` import against the existing frontmatter (it may already be imported by another pack's contribution). Imports + frontmatter additions are dedupe-safe. ## Verification @@ -104,18 +116,32 @@ After writing/patching, grep the project to confirm: "pageWritten": true, "navigationPatched": true, "homePatched": true, - "markersFound": ["", ""] + "markersFound": ["", ""], + "homeContributions": { + "imports": [ + "import { getGiftCardProduct } from '../utils/gift-cards';", + "import { resolveWixImageUrl } from '../utils/wix-image';" + ], + "frontmatter": [ + "const giftCardProduct = await getGiftCardProduct().catch(() => null);", + "const giftCardImage = giftCardProduct ? resolveWixImageUrl(giftCardProduct.image, 800, 600) : null;" + ], + "byMarker": { + "home:gift-cards": "{giftCardProduct && (
)}" + } + } }, "files": [ "src/pages/gift-cards.astro", - "src/components/Navigation.astro", - "src/pages/index.astro" + "src/components/Navigation.astro" ], "errors": [] } ``` -If a marker is missing, return `status: "partial"` with `errors: [{ code: "MARKER_NOT_FOUND", file: "", marker: "" }]`. Do NOT invent your own insertion point — that signals the designer foundation didn't scaffold the shell correctly and should be fixed upstream. +> `src/pages/index.astro` is removed from `files[]` — the orchestrator owns home-page writes via `scripts/merge-home.mjs`. The Navigation.astro line predates the navContributions migration (sibling PR). + +If the home marker is absent (e.g. the designer skipped `` because the pack is `disabled`), the merge script reports it in `skipped[]`; the agent should NOT manually patch index.astro to compensate. Surfacing the omission keeps the designer's emission contract honest. ## Anti-patterns diff --git a/skills/wix-headless/references/shared/RETURN_CONTRACT.md b/skills/wix-headless/references/shared/RETURN_CONTRACT.md index 66881060..2f5b370c 100644 --- a/skills/wix-headless/references/shared/RETURN_CONTRACT.md +++ b/skills/wix-headless/references/shared/RETURN_CONTRACT.md @@ -216,6 +216,42 @@ Phase 4 CMS page agents reference these collection names; the image agent attach } ``` +### Phase 2: homeContributions (index.astro contributions) + +Identical shape to `navContributions` (see above) but targets `src/pages/index.astro` and `home:*` markers. Phase 4 page agents that previously patched the home page at marker comments (stores `home-and-nav` at ``, gift-cards `pages` at ``) now return their contribution as `data.homeContributions`. The orchestrator collects every Phase 4 agent's `homeContributions`, then invokes `scripts/merge-home.mjs` ONCE to splice them into the designer-emitted shell. + +```json +{ + "status": "complete", + "phase": "stores-pages-home-and-nav", + "scope": "pages-home-and-nav", + "data": { + "homeContributions": { + "imports": [ + "import ProductCard from '../components/ProductCard.astro';", + "import { productsV3 } from '@wix/stores';" + ], + "frontmatter": [ + "let featured: any[] = [];", + "try { featured = (await productsV3.queryProducts().limit(12).find()).items ?? []; } catch (err) { console.error('[home] featured products query failed:', err); }", + "featured = featured.filter((p) => p?.ribbon?.name !== 'Gift Card').slice(0, 3);" + ], + "byMarker": { + "home:stores": "{featured.length > 0 && (
{featured.map((p) => )}
)}" + } + }, + "featuredProductsCount": 3 + }, + "files": [ + "src/pages/index.astro" + ] +} +``` + +> The stores `home-and-nav` agent still lists `src/pages/index.astro` in `files[]` because it ALSO performs non-marker edits on the page (category-card href rewrites). The orchestrator reads the post-edit file before invoking `merge-home.mjs`, so those rewrites are preserved. + +The `gift-cards` page agent contributes at `home:gift-cards`. If the designer didn't emit that marker (e.g. because gift-cards is `disabled: true` and the designer was told to skip surfaces for disabled packs), `merge-home.mjs` reports it in `skipped[]` with reason `MARKER_NOT_FOUND` — non-fatal, observable. + ### Designer foundation ```json diff --git a/skills/wix-headless/references/stores/HOME_AND_NAV.md b/skills/wix-headless/references/stores/HOME_AND_NAV.md index 14fb4f41..2e564585 100644 --- a/skills/wix-headless/references/stores/HOME_AND_NAV.md +++ b/skills/wix-headless/references/stores/HOME_AND_NAV.md @@ -43,49 +43,49 @@ If the home page is purely editorial (hero + copy + newsletter), return with `fe ## Implementation -### 1. Home page: featured products +### 1. Home page: featured products (returned as `data.homeContributions`) -Find the placeholder products array in `src/pages/index.astro`. Replace with a server-side `productsV3` query; pass the raw product objects to `ProductCard`. +> **Do NOT patch `src/pages/index.astro` at the `` marker yourself.** Return the contribution as JSON; the orchestrator splices it via `scripts/merge-home.mjs` after all Phase 4 agents return. The category-href rewrites in section 2 below STILL happen as direct edits to `index.astro` (they're non-marker surgical edits) — only the featured-grid section at the marker is contributed. + +Build the contribution as part of your return JSON's `data.homeContributions`: + +```json +{ + "data": { + "homeContributions": { + "imports": [ + "import ProductCard from '../components/ProductCard.astro';", + "import { productsV3 } from '@wix/stores';" + ], + "frontmatter": [ + "let featured: any[] = [];", + "try { const { items: featuredProducts } = await productsV3.queryProducts({ fields: ['CURRENCY'] }).limit(4).find(); featured = featuredProducts ?? []; } catch (err) { console.error('[home] featured products query failed:', err); }", + "featured = featured.filter((p) => p?.ribbon?.name !== 'Gift Card').slice(0, 3);" + ], + "byMarker": { + "home:stores": "{featured.length > 0 && (
{featured.map((p) => )}
)}" + } + } + } +} +``` + +The merge script: +- Dedupes the `imports[]` and `frontmatter[]` lines against the designer's existing content. +- Inserts the `byMarker["home:stores"]` snippet immediately after the marker line, preserving the marker. > **WRONG:** `featured = products.map(p => ({name: p.name, price: ...}))` then `` > **RIGHT:** `featured = featuredProducts ?? []` as `any[]`, then `` > > Flat-mapping strips fields that ProductCard needs (`media`, `actualPriceRange`, etc.) and crashes the home page (`product` is `undefined`). -```astro ---- -// src/pages/index.astro (frontmatter additions only — keep existing imports) -import { productsV3 } from "@wix/stores"; - -// Wrap every SSR await in try/catch — see references/shared/IMPLEMENTER.md -// § "SSR error guards" for the full rule. -let featured: any[] = []; -try { - const { items: featuredProducts } = await productsV3 - .queryProducts({ fields: ["CURRENCY"] }) - .limit(4) - .find(); - featured = featuredProducts ?? []; -} catch (err) { - console.error("[home] featured products query failed:", err); -} ---- -``` +> **Gift-card mirror filter.** Apply `featured = featured.filter((p) => p.ribbon?.name !== "Gift Card")` so the Wix Gift Card app's auto-created DIGITAL mirror products don't appear in the featured grid. The home teaser block (gift-cards pack) is the right surface for gift cards on the home page. -Then pass each raw product to `ProductCard`. The ribbon is fetched inside ProductCard itself — pages no longer wire offers: -```astro -{featured.map((p) => )} -``` +Do **NOT** flat-map products into `{name, price, slug, image}` — pass the raw SDK objects in the marker snippet. ProductCard handles its own field extraction, image resolution, and ribbon (offer) rendering internally. -> **Gift-card mirror filter.** After `featured = featuredProducts ?? []`, apply the same filter the listing template uses: -> ```ts -> featured = featured.filter((p) => p.ribbon?.name !== "Gift Card"); -> ``` -> The Wix Gift Card app, when enabled in the dashboard, auto-creates 5 DIGITAL Stores products tagged with the "Gift Card" ribbon. They must not appear in the home featured grid for the same reason they're filtered from `/products`: buying a mirror produces a dud line item that doesn't trigger gift-card issuance, and the home teaser block (gift-cards pack) is the right surface for gift cards on the home page. +If `featured` is empty (query failed, or stock cleared), the wrapped `{featured.length > 0 && (...)}` expression renders nothing — the rest of the page stays up. -Do **NOT** flat-map products into `{name, price, slug, image}` — pass the raw SDK objects. ProductCard handles its own field extraction, image resolution, and ribbon (offer) rendering internally. - -Preserve every other prop the designer set on the grid. If `featured` is empty (query failed, or stock cleared), the grid renders with zero cards — the rest of the page stays up. +> **If the home page is purely editorial** (hero + copy + newsletter; no `` marker), omit `data.homeContributions` entirely or pass empty arrays/objects. The merge script will report no patches for stores. Return with `featuredProductsWired: false`. ### 1a. ProductCard interface (template — deterministic) @@ -196,7 +196,21 @@ Keep the marker comment immediately after the inserted `
  • ` so other vertical "categoryCardsFound": 3, "categoryCardsMatched": 2, "categoryCardsFallbackToProducts": 1, - "navSubmenuCategoryCount": 0 + "navSubmenuCategoryCount": 0, + "homeContributions": { + "imports": [ + "import ProductCard from '../components/ProductCard.astro';", + "import { productsV3 } from '@wix/stores';" + ], + "frontmatter": [ + "let featured: any[] = [];", + "try { const { items: featuredProducts } = await productsV3.queryProducts({ fields: ['CURRENCY'] }).limit(4).find(); featured = featuredProducts ?? []; } catch (err) { console.error('[home] featured products query failed:', err); }", + "featured = featured.filter((p) => p?.ribbon?.name !== 'Gift Card').slice(0, 3);" + ], + "byMarker": { + "home:stores": "{featured.length > 0 && (
    )}" + } + } }, "files": [ "src/pages/index.astro", @@ -206,6 +220,8 @@ Keep the marker comment immediately after the inserted `
  • ` so other vertical } ``` +> `src/pages/index.astro` stays in `files[]` because this scope still performs non-marker edits on it (category-card href rewrites). The `home:stores` marker insertion is contributed via `data.homeContributions` instead, and is merged by the orchestrator after this scope returns. `src/components/Navigation.astro` listed here predates the navContributions migration — see the sibling PR for that change. + ## Scope boundaries (reinforced) - **Do NOT edit** home page layout, copy, hero text, newsletter section, or decorative SVGs. diff --git a/skills/wix-headless/references/verticals/gift-cards.md b/skills/wix-headless/references/verticals/gift-cards.md index 782cee49..369ff61a 100644 --- a/skills/wix-headless/references/verticals/gift-cards.md +++ b/skills/wix-headless/references/verticals/gift-cards.md @@ -35,12 +35,13 @@ pages: - name: "gift-cards-pages" agentLocation: "references/gift-cards/" scope: "pages" - description: "Write /gift-cards landing page (redirects when app is disabled), patch nav link + home teaser via markers" + description: "Write /gift-cards landing page (redirects when app is disabled); return probe-gated nav link as data.navContributions and probe-gated home teaser as data.homeContributions (orchestrator merges via scripts/merge-navigation.mjs and scripts/merge-home.mjs)" references: ["references/gift-cards/PAGES.md"] files: - "src/pages/gift-cards.astro" - - "src/components/Navigation.astro (patch — gift-cards link)" - - "src/pages/index.astro (patch — gift-cards teaser)" + # Navigation.astro + index.astro are no longer written by this agent. Nav link + # is returned as data.navContributions and home teaser as data.homeContributions. + # The orchestrator merges contributions (see contributes: below for which markers). creates: - { file: src/utils/gift-cards.ts, phase: components } diff --git a/skills/wix-headless/scripts/merge-home.mjs b/skills/wix-headless/scripts/merge-home.mjs new file mode 100755 index 00000000..fb861aaf --- /dev/null +++ b/skills/wix-headless/scripts/merge-home.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// Merge per-pack home-page (`src/pages/index.astro`) contributions into the +// designer-emitted shell. +// +// Today two Phase 4 page agents patch the home page at distinct `home:*` +// markers (the markers don't conflict like nav:links does), but the broader +// pattern is the same as scripts/merge-navigation.mjs: +// +// - stores-pages-home-and-nav → (featured products grid) +// - gift-cards-pages → (probe-gated teaser) +// +// Even without a same-marker race, agents writing the same file concurrently +// is brittle: missing markers are masked (the gift-cards agent silently +// no-ops if the designer forgot to emit `home:gift-cards`, observed on a +// 2026-05 run), and ordering is undefined. Pulling the splicing into a +// deterministic post-Phase-4 step makes both problems observable. +// +// Usage: +// echo "$CONTRIBUTIONS" | node merge-home.mjs +// +// stdin shape (mirrors merge-navigation.mjs): +// { +// "contributions": [ +// { +// "source": "stores", // pack name; used in error reports +// "imports": [ // raw TS import lines +// "import ProductCard from '../components/ProductCard.astro';", +// "import { productsV3 } from '@wix/stores';" +// ], +// "frontmatter": [ // raw TS lines (after imports) +// "const { items: featured } = await productsV3.queryProducts(...);" +// ], +// "byMarker": { // raw HTML/Astro to splice +// "home:stores": "
    ...
    " +// } +// }, +// ... +// ] +// } +// +// Behavior matches merge-navigation.mjs exactly: +// - Imports + frontmatter additions are deduped against existing content. +// - Multiple contributions at the same marker are joined in input order. +// - Unknown markers are reported in `skipped[]` with `MARKER_NOT_FOUND`; +// output status becomes "partial" but exit is 0. This makes the +// designer-forgot-to-emit-a-marker case observable and recoverable — +// e.g. an `` omission for a disabled pack is +// a known non-fatal outcome. +// - Atomic write (write-then-rename). +// - NOT idempotent: orchestrator must invoke once per build. +// +// Exit codes: +// 0 — merged (any `skipped` markers are non-fatal and reported in JSON) +// 2 — argument validation, malformed input, or missing index.astro + +import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs"; +import { join } from "node:path"; + +const projectDir = process.argv[2]; +if (!projectDir) { + console.error("usage: cat contributions.json | merge-home.mjs "); + process.exit(2); +} + +const homePath = join(projectDir, "src/pages/index.astro"); +if (!existsSync(homePath)) { + console.error(`merge-home: ${homePath} does not exist — designer (Phase 2) must run first.`); + process.exit(2); +} + +let payload; +try { + payload = JSON.parse(readFileSync(0, "utf8")); +} catch (e) { + console.error(`merge-home: stdin is not valid JSON (${e.message})`); + process.exit(2); +} + +const contributions = Array.isArray(payload.contributions) ? payload.contributions : null; +if (!contributions) { + console.error("merge-home: stdin must contain `contributions: [...]`"); + process.exit(2); +} + +const source = readFileSync(homePath, "utf8"); + +const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; +const m = source.match(frontmatterRegex); +if (!m) { + console.error("merge-home: index.astro has no --- frontmatter block; cannot merge imports/frontmatter contributions."); + process.exit(2); +} + +let frontmatter = m[1]; +let body = m[2]; + +// --- Imports + frontmatter additions ----------------------------------- +const existingLines = new Set(frontmatter.split("\n").map((l) => l.trim())); + +const addedImports = []; +const addedFrontmatter = []; + +for (const contrib of contributions) { + const imports = Array.isArray(contrib.imports) ? contrib.imports : []; + const fm = Array.isArray(contrib.frontmatter) ? contrib.frontmatter : []; + + for (const line of imports) { + if (typeof line !== "string") continue; + const trimmed = line.trim(); + if (trimmed === "" || existingLines.has(trimmed)) continue; + addedImports.push({ source: contrib.source, line: line }); + existingLines.add(trimmed); + } + + for (const line of fm) { + if (typeof line !== "string") continue; + const trimmed = line.trim(); + if (trimmed === "" || existingLines.has(trimmed)) continue; + addedFrontmatter.push({ source: contrib.source, line: line }); + existingLines.add(trimmed); + } +} + +const fmLines = frontmatter.split("\n"); +let lastImportIdx = -1; +for (let i = 0; i < fmLines.length; i++) { + if (/^\s*import\b/.test(fmLines[i])) lastImportIdx = i; +} + +if (addedImports.length > 0) { + const importLines = addedImports.map((c) => c.line); + if (lastImportIdx >= 0) { + fmLines.splice(lastImportIdx + 1, 0, ...importLines); + lastImportIdx += importLines.length; + } else { + fmLines.unshift(...importLines); + lastImportIdx = importLines.length - 1; + } +} + +if (addedFrontmatter.length > 0) { + const fmAdditions = addedFrontmatter.map((c) => c.line); + const insertIdx = lastImportIdx + 1; + const needsSeparator = lastImportIdx >= 0 && fmLines[insertIdx]?.trim() !== ""; + if (needsSeparator) fmAdditions.unshift(""); + fmLines.splice(insertIdx, 0, ...fmAdditions); +} + +frontmatter = fmLines.join("\n"); + +// --- byMarker splicing ------------------------------------------------- +// Group contributions per marker so we splice once per marker with all +// snippets joined in input order. See merge-navigation.mjs for why this is +// done in two passes. + +const patched = []; +const skipped = []; + +/** @type {Map>} */ +const groupedByMarker = new Map(); + +for (const contrib of contributions) { + const byMarker = contrib.byMarker && typeof contrib.byMarker === "object" ? contrib.byMarker : {}; + for (const [markerName, snippet] of Object.entries(byMarker)) { + if (typeof snippet !== "string" || snippet.trim() === "") continue; + if (!groupedByMarker.has(markerName)) groupedByMarker.set(markerName, []); + groupedByMarker.get(markerName).push({ source: contrib.source, snippet }); + } +} + +for (const [markerName, entries] of groupedByMarker) { + const markerComment = ``; + if (!body.includes(markerComment)) { + for (const { source } of entries) { + skipped.push({ source, marker: markerName, reason: "MARKER_NOT_FOUND" }); + } + continue; + } + + const markerLineRegex = new RegExp(`(^|\\n)([ \\t]*)${markerComment.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(\\r?\\n)`); + const lineMatch = body.match(markerLineRegex); + const indent = lineMatch ? (lineMatch[2] ?? "") : ""; + + const indentedSnippets = entries.map(({ snippet }) => + snippet + .split("\n") + .map((line, i) => (i === 0 || line === "" ? line : `${indent}${line}`)) + .join("\n") + ); + const combined = indentedSnippets.join("\n" + indent); + + if (lineMatch) { + body = body.replace(markerLineRegex, `$1$2${markerComment}$3${indent}${combined}\n`); + } else { + body = body.replace(markerComment, `${markerComment}\n${combined}`); + } + + for (const { source } of entries) { + patched.push({ source, marker: markerName }); + } +} + +// --- Write atomically -------------------------------------------------- +const out = `---\n${frontmatter}\n---\n${body}`; +const tmpPath = homePath + ".tmp"; +writeFileSync(tmpPath, out); +renameSync(tmpPath, homePath); + +console.log(JSON.stringify({ + status: skipped.length > 0 ? "partial" : "ok", + path: homePath, + patched, + skipped, + addedImports: addedImports.map((c) => ({ source: c.source, line: c.line.trim() })), + addedFrontmatter: addedFrontmatter.map((c) => ({ source: c.source, line: c.line.trim() })), +}, null, 2));