`). When present and -// non-trivial, this is canonical — every modern CMS that ships -// semantic HTML uses it. Highest confidence. -// -// 2. Highest text-density block. Score every container element by -// `text-length × text-density` (text density = text-length / -// outer-html-length). The winner is usually the actual content -// region: lots of text, low markup overhead. Falls through when -// every container is tiny or markup-heavy. -// -// 3. Body minus chrome. Clone , remove -// header/nav/aside/footer/script/style/noscript and a list of -// common chrome class/id patterns (`.site-header`, `#footer`, -// etc.), keep the remainder. Lowest confidence; ships everything -// we couldn't classify, which on platform-rendered pages is -// still a lot. -// -// Design notes: -// - Pure transformation; no I/O, no agent. Same input → same output. -// - Returns `{ html, source, byteReduction }` so callers can log -// which rule fired and report compression to the watch log. -// - Validates the extracted region isn't catastrophically empty -// (must have at least 100 chars of text); falls through to the -// next rule if it would be. -// - Minimum-text threshold prevents the text-density rule from -// picking a 50-char widget when the real content is in a giant -// positional div with mostly spans. -// - -import * as cheerio from 'cheerio'; - -export type ContentRegionSource = 'main' | 'text-density' | 'body-minus-chrome' | 'whole-body'; - -export interface ContentRegionResult { - /** The extracted HTML content region (inner HTML — no wrapping element). */ - html: string; - /** Which rule produced the result. */ - source: ContentRegionSource; - /** Bytes of the input HTML. */ - inputBytes: number; - /** Bytes of the extracted region. */ - outputBytes: number; - /** Notes for diagnostics — e.g. text density score, removed chrome elements. */ - notes: string[]; -} - -/** Minimum text length for a candidate region to be considered "non-trivial". */ -const MIN_TEXT_LEN = 100; - -/** Class/id patterns commonly used for chrome in real-world sites. Mirrored to a CSS selector list. */ -const CHROME_PATTERNS = [ - // Direct semantic tags handled separately. - // Class-based: - '.site-header', '.site-footer', '.site-navigation', - '.global-header', '.global-footer', - '.navbar', '.nav-bar', '.menu-bar', '.top-bar', '.bottom-bar', - '.breadcrumb', '.breadcrumbs', - '.cookie-banner', '.cookie-notice', '.gdpr-banner', - '.skip-link', '.skip-to-content', - '.search-overlay', '.modal-overlay', - '.cart-drawer', '.cart-sidebar', - '.announcement-bar', - // ID-based: - '#header', '#footer', '#nav', '#navigation', '#site-header', '#site-footer', - '#cart', '#search', '#breadcrumb', '#breadcrumbs', -]; - -export function extractContentRegion(sanitizedHtml: string): ContentRegionResult { - const inputBytes = sanitizedHtml.length; - const $ = cheerio.load(sanitizedHtml); - const notes: string[] = []; - - // Rule 1 — explicit
- const $main = $('main').first(); - if ($main.length > 0) { - const text = $main.text().trim(); - if (text.length >= MIN_TEXT_LEN) { - const html = ($main.html() ?? '').trim(); - notes.push(`
found, ${text.length} chars of text`); - return { - html, - source: 'main', - inputBytes, - outputBytes: html.length, - notes, - }; - } - notes.push(`
found but has only ${text.length} chars text — falling through`); - } - - // Rule 2 — highest text-density container - // Consider article/section/div elements that contain meaningful text. - // Score = text length × density. Density punishes containers that are - // mostly markup (e.g. navigation, sidebar widgets) and rewards prose- - // heavy regions. We require minimum text and minimum density to avoid - // picking a tiny container. - let best: { el: cheerio.Cheerio; score: number; textLen: number; density: number } | null = null; - $('article, section, div').each((_, el) => { - const $el = $(el); - const text = $el.text().trim(); - if (text.length < MIN_TEXT_LEN * 4) return; // be more demanding here - const html = $.html($el); - const density = text.length / Math.max(html.length, 1); - if (density < 0.05) return; // markup-heavy, probably navigation - const score = text.length * density; - if (!best || score > best.score) { - best = { el: $el, score, textLen: text.length, density }; - } - }); - if (best) { - // Cast through unknown — cheerio's generic parameter has tightened - // since v1.0; we don't depend on the inner node type here, only on - // .html() being available, which is on the base Cheerio. - const winner = (best as { el: { html: () => string | null } }).el; - const html = (winner.html() ?? '').trim(); - notes.push( - `text-density winner: ${(best as { textLen: number }).textLen} chars text, density=${(best as { density: number }).density.toFixed(3)}, score=${(best as { score: number }).score.toFixed(0)}`, - ); - return { - html, - source: 'text-density', - inputBytes, - outputBytes: html.length, - notes, - }; - } - notes.push('no text-density winner — falling through to body-minus-chrome'); - - // Rule 3 — body minus chrome elements - const $body = $('body').first(); - if ($body.length > 0) { - const $clone = cheerio.load(`${$body.html() ?? ''}`); - // Strip semantic chrome. - $clone('header, nav, aside, footer, script, style, noscript').remove(); - // Strip pattern-matched chrome (best-effort; selectors that fail - // silently do nothing). - let removedPatterns = 0; - for (const pat of CHROME_PATTERNS) { - try { - const matched = $clone(pat); - if (matched.length > 0) { - removedPatterns += matched.length; - matched.remove(); - } - } catch { - // Invalid selector — skip silently. - } - } - const html = ($clone('body').html() ?? '').trim(); - if (html.length > 0) { - notes.push(`body-minus-chrome: stripped ${removedPatterns} chrome-pattern matches`); - return { - html, - source: 'body-minus-chrome', - inputBytes, - outputBytes: html.length, - notes, - }; - } - } - - // Last resort — return the whole body if we have one, else the whole - // sanitized input. Better to ship something than nothing. - const $bodyFallback = $('body').first(); - const fallback = ($bodyFallback.length > 0 ? $bodyFallback.html() : sanitizedHtml) ?? sanitizedHtml; - notes.push('all rules failed — returning whole body unchanged'); - return { - html: fallback.trim(), - source: 'whole-body', - inputBytes, - outputBytes: fallback.length, - notes, - }; -} diff --git a/packages/data-liberation-agent/src/lib/streaming/design-fragment-install.test.ts b/packages/data-liberation-agent/src/lib/streaming/design-fragment-install.test.ts deleted file mode 100644 index 0e5f5455c4..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/design-fragment-install.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Design-fragment sidecar → contentOverride → media-URL rewrite - * ============================================================== - * Integration test for Task 10: when `/design/.fragment.html` - * exists, its contents become the post's contentOverride, flowing through the - * existing prepareInstallContentWithMediaUrls so source URLs are - * swapped to local upload URLs. - * - * This test exercises the exact sequence that processOne() in - * watch-runner.ts executes: - * 1. designSidecarPath() → resolve sidecar path - * 2. readFileSync(sidecar) → contentOverride = fragment - * 3. prepareInstallContentWithMediaUrls({ sourceContent, contentOverride, mediaUrlMap }) - * → rewrites source CDN URLs to local upload URLs in the fragment - */ - -import { describe, expect, it } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { designSidecarPath } from '../screenshot/design-capture-runner.js'; -import { prepareInstallContentWithMediaUrls } from './post-content-media-rewrite.js'; - -const TMP_ROOT = join(process.cwd(), '.tmp-test', 'design-fragment-install'); -mkdirSync(TMP_ROOT, { recursive: true }); - -describe('design-fragment sidecar as contentOverride with media-URL rewrite', () => { - it('loads the design sidecar as contentOverride and rewrites source img URLs to local upload URLs', () => { - const outDir = mkdtempSync(join(TMP_ROOT, 'out-')); - try { - const slug = 'about'; - const sourceImgUrl = 'https://src.test/a.png'; - const localUploadUrl = 'http://localhost:8881/wp-content/uploads/a.png'; - - // Write the design fragment sidecar (mirrors what captureDesignForUrl produces) - const sidecar = designSidecarPath(outDir, slug); - mkdirSync(join(outDir, 'design'), { recursive: true }); - const fragmentHtml = `
hero

About us

`; - writeFileSync(sidecar, fragmentHtml, 'utf8'); - - // Step 1: processOne reads the sidecar (mirrors the watch-runner.ts logic) - const fragment = readFileSync(sidecar, 'utf8'); - expect(fragment.trim().length).toBeGreaterThan(0); - - // Step 2: use it as contentOverride exactly as processOne does - const contentOverride = fragment; - - // Step 3: pass through prepareInstallContentWithMediaUrls (the existing media-rewrite) - const mediaUrlMap = new Map([[sourceImgUrl, localUploadUrl]]); - const result = prepareInstallContentWithMediaUrls({ - sourceContent: '

raw extracted content

', - contentOverride, - mediaUrlMap, - }); - - // The design fragment's img src must be rewritten to the local upload URL - expect(result.contentOverride).toContain(`src="${localUploadUrl}"`); - // The source CDN URL must NOT appear in the installed content - expect(result.contentOverride).not.toContain(sourceImgUrl); - expect(result.rewritten).toBe(true); - // contentOverride was provided — sourceContent was NOT promoted - expect(result.usedSourceContent).toBe(false); - expect(result.missing).toEqual([]); - } finally { - rmSync(outDir, { recursive: true, force: true }); - } - }); - - it('designSidecarPath returns /design/.fragment.html', () => { - expect(designSidecarPath('/tmp/mysite', 'contact')).toBe('/tmp/mysite/design/contact.fragment.html'); - }); - - it('falls back to raw source content when no design sidecar exists', () => { - const outDir = mkdtempSync(join(TMP_ROOT, 'out-nosidecar-')); - try { - const slug = 'services'; - const sourceImgUrl = 'https://src.test/banner.jpg'; - const localUploadUrl = 'http://localhost:8881/wp-content/uploads/banner.jpg'; - - // No sidecar written — contentOverride stays undefined - const contentOverride = undefined; - - const mediaUrlMap = new Map([[sourceImgUrl, localUploadUrl]]); - const result = prepareInstallContentWithMediaUrls({ - sourceContent: `

`, - contentOverride, - mediaUrlMap, - }); - - // Falls back to sourceContent, still rewrites the URL - expect(result.contentOverride).toContain(`src="${localUploadUrl}"`); - expect(result.usedSourceContent).toBe(true); - } finally { - rmSync(outDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/foundation-drift.test.ts b/packages/data-liberation-agent/src/lib/streaming/foundation-drift.test.ts deleted file mode 100644 index 62b61234cc..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/foundation-drift.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { computeInputsDigest, driftScore } from './foundation-drift.js'; - -const baselinePalette = { - version: 1, - sampledUrls: 4, - colors: [ - { hex: '#111111', count: 10, urls: 4 }, - { hex: '#fefefe', count: 9, urls: 4 }, - ], -}; -const baselineTypography = { - version: 1, - sampledUrls: 4, - bySelector: { - body: [{ fontFamily: 'Inter', fontSize: '16px', fontWeight: '400', lineHeight: '24px', urls: 4 }], - }, -}; -const baselineBreakpoints = { version: 1, sampledUrls: 4, minWidth: [768, 1024], maxWidth: [] }; - -describe('computeInputsDigest', () => { - it('returns a sha256: digest', () => { - const d = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - expect(d).toMatch(/^sha256:[a-f0-9]{64}$/); - }); - - it('is stable across key reorderings', () => { - const a = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - // Reorder top-level keys of palette - const reordered = { sampledUrls: 4, colors: baselinePalette.colors, version: 1 as const }; - const b = computeInputsDigest(reordered, baselineTypography, baselineBreakpoints); - expect(a).toBe(b); - }); - - it('changes when palette content changes', () => { - const a = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const shifted = { - ...baselinePalette, - colors: [...baselinePalette.colors, { hex: '#ff0000', count: 5, urls: 4 }], - }; - const b = computeInputsDigest(shifted, baselineTypography, baselineBreakpoints); - expect(a).not.toBe(b); - }); - - it('changes when typography changes', () => { - const a = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const shifted = { - ...baselineTypography, - bySelector: { - body: [ - { fontFamily: 'Roboto', fontSize: '16px', fontWeight: '400', lineHeight: '24px', urls: 4 }, - ], - }, - }; - const b = computeInputsDigest(baselinePalette, shifted, baselineBreakpoints); - expect(a).not.toBe(b); - }); - - it('changes when breakpoints change', () => { - const a = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const shifted = { ...baselineBreakpoints, minWidth: [1024, 1280] }; - const b = computeInputsDigest(baselinePalette, baselineTypography, shifted); - expect(a).not.toBe(b); - }); -}); - -describe('driftScore', () => { - it('returns 0 when current inputs hash matches prevDigest', () => { - const digest = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const score = driftScore(digest, { - palette: baselinePalette, - typography: baselineTypography, - breakpoints: baselineBreakpoints, - }); - expect(score).toBe(0); - }); - - it('returns > 1 when palette has shifted (re-rev needed)', () => { - const digest = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const shifted = { - ...baselinePalette, - colors: [...baselinePalette.colors, { hex: '#00ffaa', count: 7, urls: 3 }], - }; - const score = driftScore(digest, { - palette: shifted, - typography: baselineTypography, - breakpoints: baselineBreakpoints, - }); - expect(score).toBeGreaterThan(1); - }); - - it('returns > 1 when typography has a font-family change', () => { - const digest = computeInputsDigest(baselinePalette, baselineTypography, baselineBreakpoints); - const shifted = { - ...baselineTypography, - bySelector: { - body: [ - { fontFamily: 'Comic Sans', fontSize: '16px', fontWeight: '400', lineHeight: '24px', urls: 4 }, - ], - }, - }; - const score = driftScore(digest, { - palette: baselinePalette, - typography: shifted, - breakpoints: baselineBreakpoints, - }); - expect(score).toBeGreaterThan(1); - }); - - it('returns > 1 when prevDigest is empty (first run)', () => { - const score = driftScore('', { - palette: baselinePalette, - typography: baselineTypography, - breakpoints: baselineBreakpoints, - }); - expect(score).toBeGreaterThan(1); - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/foundation-drift.ts b/packages/data-liberation-agent/src/lib/streaming/foundation-drift.ts deleted file mode 100644 index 3b99fbb98e..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/foundation-drift.ts +++ /dev/null @@ -1,90 +0,0 @@ -// -// Foundation drift -// ================ -// Helpers the tick-scheduler uses to decide whether to re-rev the design -// foundation. Wraps the existing `sha256` utility from -// `src/lib/design-foundation/scaffold.ts` (re-exported here so the streaming -// pipeline doesn't depend on a deep import path) and adds a `driftScore` -// estimate. -// -// Drift threshold contract: a returned score `> 1` means the foundation -// should be re-revved. The tick-scheduler reads `state.lastFoundationInputsDigest` -// and feeds it here alongside the current input objects. -// -import { sha256 } from '../design-foundation/scaffold.js'; - -/** - * Compute a single sha256 digest over palette + typography + breakpoints. - * The inputs are first JSON-stringified with stable key ordering (via - * `JSON.stringify` of canonical-keyed values) so two semantically-equal inputs - * always produce the same digest. - * - * Reuses `sha256` from scaffold.ts to keep the digest convention identical. - */ -export function computeInputsDigest( - palette: unknown, - typography: unknown, - breakpoints: unknown, - computedStyles?: unknown, -): string { - const canonical = JSON.stringify({ - palette: canonicalize(palette), - typography: canonicalize(typography), - breakpoints: canonicalize(breakpoints), - ...(computedStyles === undefined ? {} : { computedStyles: canonicalize(computedStyles) }), - }); - return sha256(canonical); -} - -/** - * Estimate how much the foundation inputs have drifted since the previous - * digest was recorded. - * - * Returns: - * 0 — current inputs hash to `prevDigest` (no change). - * 2 — current inputs hash differs (above the re-rev threshold). - * - * The "count changed top-8 palette entries + font-family changes" part of the - * contract requires the previous inputs to reconstruct a per-entry diff; - * because the caller only retains the prior digest string, we collapse the - * decision to a binary same / different signal at a value (2) that exceeds - * the documented `> 1` threshold. - * - * If the prevDigest is empty (first run), we treat that as "first foundation - * — please run a tick" and return 2. - */ -export function driftScore( - prevDigest: string, - currentInputs: { palette: unknown; typography: unknown; breakpoints: unknown; computedStyles?: unknown }, -): number { - const current = computeInputsDigest( - currentInputs.palette, - currentInputs.typography, - currentInputs.breakpoints, - currentInputs.computedStyles, - ); - if (!prevDigest) return 2; - if (prevDigest === current) return 0; - return 2; -} - -// --------------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------------- - -/** - * Recursively sort object keys so two structurally-equal inputs produce the - * same JSON string. Arrays preserve order — caller is responsible for any - * domain-level normalization (e.g. ranking palette entries). - */ -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize); - if (value && typeof value === 'object') { - const obj = value as Record; - const sortedKeys = Object.keys(obj).sort(); - const out: Record = {}; - for (const k of sortedKeys) out[k] = canonicalize(obj[k]); - return out; - } - return value; -} diff --git a/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.test.ts b/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.test.ts deleted file mode 100644 index 9fcbd6ba3a..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { emptyState, loadReplicateState, saveReplicateState } from './replicate-state.js'; -import { computeInputsDigest } from './foundation-drift.js'; -import { - foundationRevDecision, - recordFoundationInputsDigest, - selectFoundationSample, -} from './foundation-run-state.js'; - -const FIXTURE_TMP = join(process.cwd(), '.tmp-test'); -mkdirSync(FIXTURE_TMP, { recursive: true }); - -function tmp(): string { - return mkdtempSync(join(FIXTURE_TMP, 'frs-')); -} - -function seedFoundationInputs(dir: string): { digest: string } { - const palette = { - version: 1, - sampledUrls: 3, - colors: [{ hex: '#000000', count: 10, urls: 3 }], - }; - const typography = { - version: 1, - sampledUrls: 3, - bySelector: { body: [{ fontFamily: 'Inter', fontSize: '16px', fontWeight: '400', lineHeight: '24px', urls: 3 }] }, - }; - const breakpoints = { version: 1, sampledUrls: 3, minWidth: [768], maxWidth: [] }; - writeFileSync(join(dir, 'palette.json'), JSON.stringify(palette)); - writeFileSync(join(dir, 'typography.json'), JSON.stringify(typography)); - writeFileSync(join(dir, 'breakpoints.json'), JSON.stringify(breakpoints)); - return { digest: computeInputsDigest(palette, typography, breakpoints) }; -} - -describe('foundation run state', () => { - it('skips a foundation-rev when the current aggregate digest is already recorded', () => { - const dir = tmp(); - const { digest } = seedFoundationInputs(dir); - saveReplicateState(dir, { ...emptyState(), lastFoundationInputsDigest: digest }); - - expect(foundationRevDecision(dir)).toEqual({ - shouldRun: false, - digest, - reason: 'foundation inputs unchanged', - }); - }); - - it('runs a foundation-rev when the recorded digest is stale', () => { - const dir = tmp(); - const { digest } = seedFoundationInputs(dir); - saveReplicateState(dir, { - ...emptyState(), - lastFoundationInputsDigest: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', - }); - - expect(foundationRevDecision(dir)).toEqual({ - shouldRun: true, - digest, - reason: 'foundation inputs changed', - }); - }); - - it('records the current foundation aggregate digest after a successful run', () => { - const dir = tmp(); - const { digest } = seedFoundationInputs(dir); - - const recorded = recordFoundationInputsDigest(dir); - - expect(recorded).toBe(digest); - expect(loadReplicateState(dir).lastFoundationInputsDigest).toBe(digest); - }); - - it('uses one representative sample for the foundation fast path and prefers homepage', () => { - const sample = selectFoundationSample({ - page: [ - { url: 'a', html: 'html/a.html', screenshot: 'screenshots/desktop/a.png' }, - { url: 'b', html: 'html/b.html', screenshot: 'screenshots/desktop/b.png' }, - { url: 'c', html: 'html/c.html', screenshot: 'screenshots/desktop/c.png' }, - { url: 'd', html: 'html/d.html', screenshot: 'screenshots/desktop/d.png' }, - ], - homepage: [ - { url: 'home', html: 'html/home.html', screenshot: 'screenshots/desktop/home.png' }, - ], - product: [ - { url: 'p1', html: 'html/p1.html', screenshot: 'screenshots/desktop/p1.png' }, - { url: 'p2', html: 'html/p2.html', screenshot: 'screenshots/desktop/p2.png' }, - ], - }); - - expect(sample).toEqual({ - homepage: [{ url: 'home', html: 'html/home.html', screenshot: 'screenshots/desktop/home.png' }], - }); - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.ts b/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.ts deleted file mode 100644 index dfa128cb22..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/foundation-run-state.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { classifyUrl, type UrlType } from '../extraction/sitemap.js'; -import { computeInputsDigest } from './foundation-drift.js'; -import { loadReplicateState, saveReplicateState } from './replicate-state.js'; - -export interface FoundationRevDecision { - shouldRun: boolean; - digest: string | null; - reason: string; -} - -export interface FoundationSampleEntry { - url: string; - html?: string | null; - screenshot?: string | null; - scrolledScreenshot?: string | null; -} - -export type FoundationSample = Partial>; - -interface ManifestEntry { - html?: string; - desktop?: string; - desktopScrolled?: string; -} - -interface Manifest { - entries?: Record; -} - -const FOUNDATION_INPUT_FILES = ['palette.json', 'typography.json', 'breakpoints.json'] as const; -const OPTIONAL_FOUNDATION_INPUT_FILES = ['computed-styles.json'] as const; -const DEFAULT_MAX_FOUNDATION_SAMPLES = 1; -const FOUNDATION_ARCHETYPE_PRIORITY: UrlType[] = ['homepage', 'page', 'product', 'post', 'gallery', 'event']; - -export function readCurrentFoundationInputsDigest(outputDir: string): string | null { - try { - const [palette, typography, breakpoints] = FOUNDATION_INPUT_FILES.map((file) => - JSON.parse(readFileSync(join(outputDir, file), 'utf8')) as unknown, - ); - const computedStyles = readOptionalJson(outputDir, OPTIONAL_FOUNDATION_INPUT_FILES[0]); - return computeInputsDigest(palette, typography, breakpoints, computedStyles); - } catch { - return null; - } -} - -function readOptionalJson(outputDir: string, file: string): unknown { - const path = join(outputDir, file); - if (!existsSync(path)) return undefined; - try { - return JSON.parse(readFileSync(path, 'utf8')) as unknown; - } catch { - return undefined; - } -} - -export function foundationRevDecision(outputDir: string): FoundationRevDecision { - const digest = readCurrentFoundationInputsDigest(outputDir); - if (!digest) { - return { shouldRun: true, digest: null, reason: 'foundation inputs unavailable' }; - } - - const state = loadReplicateState(outputDir); - if (state.lastFoundationInputsDigest === digest) { - return { shouldRun: false, digest, reason: 'foundation inputs unchanged' }; - } - - return { - shouldRun: true, - digest, - reason: state.lastFoundationInputsDigest ? 'foundation inputs changed' : 'foundation inputs not recorded', - }; -} - -export function recordFoundationInputsDigest(outputDir: string): string | null { - const digest = readCurrentFoundationInputsDigest(outputDir); - if (!digest) return null; - - const state = loadReplicateState(outputDir); - saveReplicateState(outputDir, { - ...state, - lastFoundationInputsDigest: digest, - }); - return digest; -} - -export function selectFoundationSample( - representatives: Partial>, - maxSamples = DEFAULT_MAX_FOUNDATION_SAMPLES, -): FoundationSample { - const out: FoundationSample = {}; - if (maxSamples <= 0) return out; - - let selected = 0; - for (const archetype of FOUNDATION_ARCHETYPE_PRIORITY) { - const entries = representatives[archetype]; - if (!Array.isArray(entries) || entries.length === 0) continue; - - const remaining = maxSamples - selected; - if (remaining <= 0) break; - - const picked = entries.slice(0, remaining); - out[archetype] = picked; - selected += picked.length; - } - return out; -} - -export function buildFoundationSampleFromManifest( - outputDir: string, - maxSamples = DEFAULT_MAX_FOUNDATION_SAMPLES, -): FoundationSample { - const manifestPath = join(outputDir, 'screenshots', 'manifest.json'); - if (!existsSync(manifestPath)) return {}; - - let manifest: Manifest; - try { - manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest; - } catch { - return {}; - } - - const buckets: Partial> = {}; - const entries = manifest.entries ?? {}; - for (const [url, entry] of Object.entries(entries)) { - const archetype = classifyUrl(url); - const bucket = buckets[archetype] ?? []; - bucket.push({ - url, - html: entry.html ?? null, - }); - buckets[archetype] = bucket; - } - - return selectFoundationSample(buckets, maxSamples); -} diff --git a/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.test.ts b/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.test.ts deleted file mode 100644 index 3298eb345e..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { heuristicBlocks } from './heuristic-blocks.js'; - -describe('heuristicBlocks', () => { - it('handles pure paragraphs', () => { - const html = '

First paragraph.

Second paragraph.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('First paragraph.'); - expect(result.blocks).toContain('Second paragraph.'); - }); - - it('handles paragraphs interleaved with h2/h3 headings', () => { - const html = '

Section

Some prose.

Subsection

More prose.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('Section'); - expect(result.blocks).toContain('Subsection'); - }); - - it('handles a single image followed by paragraphs', () => { - const html = 'Hero

Caption-like text.

More body.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('src="https://example.com/hero.jpg"'); - expect(result.blocks).toContain('alt="Hero"'); - expect(result.blocks).toContain(''); - }); - - it('handles a
followed by paragraphs', () => { - const html = '
H
Cap

Body.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('src="https://example.com/h.jpg"'); - }); - - it('handles a single
with heading + paragraphs as a wp:group', () => { - const html = '

About

We make things.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('About'); - expect(result.blocks).toContain('We make things.'); - }); - - it('refuses complex page with multiple
blocks', () => { - const html = '

One

Two

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(false); - }); - - it('refuses pages with lists, tables, or unfamiliar elements', () => { - expect(heuristicBlocks('
  • a
  • b
').handled).toBe(false); - expect(heuristicBlocks('
x
').handled).toBe(false); - expect(heuristicBlocks('
stuff
').handled).toBe(false); - }); - - it('refuses an empty or whitespace-only input', () => { - expect(heuristicBlocks('').handled).toBe(false); - expect(heuristicBlocks(' \n ').handled).toBe(false); - }); - - it('refuses pages where a paragraph is followed by an image (out-of-order)', () => { - // Image-then-paragraphs is fine; paragraph-then-image is not in our - // shape set — fall through to the AI path. - const html = '

Lead.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(false); - }); - - it('refuses h1 (since post_content should not duplicate post title)', () => { - const html = '

Title

Body.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(false); - }); - - it('refuses a section that mixes images with text', () => { - const html = '

Hi

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(false); - }); - - it('preserves inline markup inside paragraphs (e.g. , )', () => { - const html = '

Click here now.

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(true); - expect(result.blocks).toContain(''); - expect(result.blocks).toContain('here'); - }); - - it('rejects pages with stray top-level text (not inside any element)', () => { - const html = 'stray text

then a paragraph

'; - const result = heuristicBlocks(html); - expect(result.handled).toBe(false); - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.ts b/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.ts deleted file mode 100644 index 936fa4edaa..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/heuristic-blocks.ts +++ /dev/null @@ -1,205 +0,0 @@ -// -// Heuristic block transformer -// =========================== -// Pure function that recognises trivially-structured pages and emits valid -// WP block markup directly, sidestepping the AI compose path. Returning -// `{handled: false}` means "I'm not sure" — the caller falls through to the -// AI skill. -// -// Confidence floor: heuristic only claims `handled: true` when EVERY visible -// element fits one of the recognised shapes. Any unexpected element type -// (lists, tables, sections, divs with classes, custom elements, etc.) flips -// to `handled: false`. -// -// Recognised shapes (calibrated for the first eval pass): -// 1. Pure text page — only `

` and `

`/`

` elements. -// Heading levels stay 2-3; we don't synthesise `

` here because -// `post_content` shouldn't repeat the post title. -// 2. Single image followed by paragraphs — leading `` (or -// `
`) then 1+ paragraphs. -// 3. Single section with a heading + text — one `
` containing -// one `

`/`

` and 1+ paragraphs. -// -// Any other shape returns `{handled: false}`. -// - -import * as cheerio from 'cheerio'; - -export interface HeuristicResult { - handled: boolean; - blocks?: string; - /** Internal — surfaced for debugging / audit logs. */ - reason?: string; -} - -const ALLOWED_TEXTISH = new Set(['p', 'h2', 'h3']); - -import { escapeHtmlText as escapeHtml } from '../html-escape.js'; - -function paragraphBlock(html: string): string { - return `\n

${html}

\n`; -} - -function headingBlock(level: 2 | 3, html: string): string { - const attrs = level === 2 ? '' : ` {"level":${level}}`; - return `\n${html}\n`; -} - -function imageBlock(src: string, alt: string): string { - const escapedSrc = escapeHtml(src); - const escapedAlt = escapeHtml(alt); - return `\n
${escapedAlt}
\n`; -} - -function groupBlock(inner: string): string { - return `\n
\n${inner}\n
\n`; -} - -interface SimpleEl { - tag: string; - innerHtml: string; - attrs: Record; - childTags: string[]; -} - -/** - * Wrap input in a synthetic body so cheerio's `*` traversal sees the input - * as siblings even when the user passed a fragment without a wrapping element. - */ -function topLevelChildren(html: string): SimpleEl[] { - const $ = cheerio.load(`${html}`); - const body = $('body').first(); - const elements: SimpleEl[] = []; - body.contents().each((_, node) => { - if (node.type === 'tag') { - const $node = $(node); - const attrs: Record = {}; - const tagAttrs = (node as { attribs?: Record }).attribs ?? {}; - for (const [k, v] of Object.entries(tagAttrs)) attrs[k] = v; - const childTags: string[] = []; - $node.children().each((__, c) => { - if (c.type === 'tag') childTags.push((c as { tagName: string }).tagName.toLowerCase()); - }); - elements.push({ - tag: (node as { tagName: string }).tagName.toLowerCase(), - innerHtml: $node.html() ?? '', - attrs, - childTags, - }); - } else if (node.type === 'text') { - const text = (node as { data: string }).data ?? ''; - if (text.trim()) { - elements.push({ tag: '#textnode', innerHtml: text, attrs: {}, childTags: [] }); - } - } - }); - return elements; -} - -interface ImageInfo { - src: string; - alt: string; -} - -/** Parse a `
` element to recognize a `
` (with optional
). */ -function pickFigureImage(figureInnerHtml: string): ImageInfo | null { - const $ = cheerio.load(`${figureInnerHtml}`); - const body = $('body').first(); - const childEls: Array<{ tag: string; src: string; alt: string }> = []; - body.contents().each((_, node) => { - if (node.type === 'tag') { - const tagName = (node as { tagName: string }).tagName.toLowerCase(); - if (tagName === 'img' || tagName === 'figcaption') { - const $n = $(node); - childEls.push({ - tag: tagName, - src: $n.attr('src') ?? '', - alt: $n.attr('alt') ?? '', - }); - } else { - childEls.push({ tag: tagName, src: '', alt: '' }); - } - } - }); - const hasOnlyAllowed = childEls.every((c) => c.tag === 'img' || c.tag === 'figcaption'); - const img = childEls.find((c) => c.tag === 'img'); - if (!hasOnlyAllowed || !img) return null; - return { src: img.src, alt: img.alt }; -} - -function pickLeadingImage(el: SimpleEl): ImageInfo | null { - if (el.tag === 'img') { - return { src: el.attrs.src ?? '', alt: el.attrs.alt ?? '' }; - } - if (el.tag === 'figure') { - return pickFigureImage(el.innerHtml); - } - return null; -} - -function textishToBlock(el: SimpleEl): string { - const inner = el.innerHtml.trim(); - if (el.tag === 'p') return paragraphBlock(inner); - if (el.tag === 'h2') return headingBlock(2, inner); - if (el.tag === 'h3') return headingBlock(3, inner); - return paragraphBlock(escapeHtml(inner)); -} - -/** - * Try to compose blocks from the input HTML using the trivial-shape rules - * above. Returns `{handled: false}` whenever the structure isn't a perfect - * match — the AI path will run instead. - */ -export function heuristicBlocks(html: string): HeuristicResult { - if (!html || !html.trim()) { - return { handled: false, reason: 'empty input' }; - } - - const children = topLevelChildren(html); - if (children.length === 0) { - return { handled: false, reason: 'no structured children' }; - } - - // Stray text directly between top-level blocks is unusual and risky to - // synthesize — bail. - if (children.some((c) => c.tag === '#textnode')) { - return { handled: false, reason: 'top-level stray text' }; - } - - // Shape 3: single
with heading + paragraphs → wrap in wp:group - if (children.length === 1 && children[0].tag === 'section') { - const inner = topLevelChildren(children[0].innerHtml); - const allTextish = inner.every((c) => ALLOWED_TEXTISH.has(c.tag)); - const hasHeading = inner.some((c) => c.tag === 'h2' || c.tag === 'h3'); - if (allTextish && hasHeading && inner.length > 0) { - const innerBlocks = inner.map((c) => textishToBlock(c)).join('\n\n'); - return { handled: true, blocks: groupBlock(innerBlocks), reason: 'section-with-heading' }; - } - return { handled: false, reason: 'section is not pure heading+paragraphs' }; - } - - // Shape 2: leading image (raw or
) followed by paragraphs - const leadingImage = pickLeadingImage(children[0]); - if (leadingImage) { - const rest = children.slice(1); - const restAllParagraphs = rest.every((c) => c.tag === 'p'); - if (restAllParagraphs && rest.length > 0) { - const blocks = [imageBlock(leadingImage.src, leadingImage.alt)]; - for (const p of rest) blocks.push(paragraphBlock(p.innerHtml.trim())); - return { handled: true, blocks: blocks.join('\n\n'), reason: 'image+paragraphs' }; - } - return { handled: false, reason: 'leading image not followed by paragraphs only' }; - } - - // Shape 1: pure paragraphs / h2 / h3 - const allTextish = children.every((c) => ALLOWED_TEXTISH.has(c.tag)); - if (allTextish) { - return { - handled: true, - blocks: children.map((c) => textishToBlock(c)).join('\n\n'), - reason: 'paragraphs+headings', - }; - } - - return { handled: false, reason: 'mixed structure outside heuristic shapes' }; -} diff --git a/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.test.ts b/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.test.ts deleted file mode 100644 index 3d8017499a..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { buildInternalLinkMap, rewriteInternalLinks } from './internal-link-rewrite.js'; - -// Fictional source site — no real source-site URLs/slugs (project convention). -// `redirect-map.json` is the canonical source-path -> local-permalink map (the -// same map the nav/footer rewrite in theme-scaffold consumes). -const redirectMap = [ - { from: '/about-the-shop', to: '/about-the-shop/' }, - { from: '/contact', to: '/contact/' }, -]; -const origins = ['craftwood-fixture.test']; - -describe('buildInternalLinkMap', () => { - it('always maps the site root to "/" under both path and host+path keys', () => { - const map = buildInternalLinkMap(redirectMap, { siteOrigins: origins }); - expect(map.get('/')).toBe('/'); - expect(map.get('craftwood-fixture.test/')).toBe('/'); - }); - - it('maps a redirect entry under both path and host+path keys', () => { - const map = buildInternalLinkMap(redirectMap, { siteOrigins: origins }); - expect(map.get('/about-the-shop')).toBe('/about-the-shop/'); - expect(map.get('craftwood-fixture.test/about-the-shop')).toBe('/about-the-shop/'); - }); - - it('builds path-only keys when no origins are supplied', () => { - const map = buildInternalLinkMap(redirectMap); - expect(map.get('/contact')).toBe('/contact/'); - expect(map.get('craftwood-fixture.test/contact')).toBeUndefined(); - }); -}); - -describe('rewriteInternalLinks', () => { - const map = buildInternalLinkMap(redirectMap, { siteOrigins: origins }); - - it('rewrites an absolute internal href to the root-relative permalink', () => { - const out = rewriteInternalLinks('About', map); - expect(out).toBe('About'); - }); - - it('rewrites a root-relative href', () => { - const out = rewriteInternalLinks('Contact', map); - expect(out).toBe('Contact'); - }); - - it('rewrites a bare relative href', () => { - const out = rewriteInternalLinks('About', map); - expect(out).toBe('About'); - }); - - it('rewrites a .html form', () => { - const out = rewriteInternalLinks('About', map); - expect(out).toBe('About'); - }); - - it('rewrites a trailing-slash form', () => { - const out = rewriteInternalLinks('Contact', map); - expect(out).toBe('Contact'); - }); - - it('matches the non-www host variant', () => { - const out = rewriteInternalLinks('Contact', map); - expect(out).toBe('Contact'); - }); - - it('preserves a #fragment when rewriting', () => { - const out = rewriteInternalLinks('Team', map); - expect(out).toBe('Team'); - }); - - it('leaves an external host untouched and does not warn', () => { - const onMissing = vi.fn(); - const out = rewriteInternalLinks('x', map, { onMissing }); - expect(out).toBe('x'); - expect(onMissing).not.toHaveBeenCalled(); - }); - - it('leaves mailto:/tel: and in-page anchors untouched', () => { - const input = 'mts'; - const out = rewriteInternalLinks(input, map); - expect(out).toBe(input); - }); - - it('leaves an unmapped internal relative href as-is and reports it via onMissing', () => { - const onMissing = vi.fn(); - const out = rewriteInternalLinks('x', map, { onMissing }); - expect(out).toBe('x'); - expect(onMissing).toHaveBeenCalledWith('/never-extracted'); - }); - - it('returns input unchanged for an empty map', () => { - const input = 'Contact'; - expect(rewriteInternalLinks(input, new Map())).toBe(input); - }); - - it('rewrites single-quoted href attributes too', () => { - const out = rewriteInternalLinks("Contact", map); - expect(out).toBe("Contact"); - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.ts b/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.ts deleted file mode 100644 index 263a34d839..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/internal-link-rewrite.ts +++ /dev/null @@ -1,170 +0,0 @@ -// -// Internal link rewriting (source href -> imported permalink) -// =========================================================== -// Reconstructed pages and generated nav template parts carry source hrefs -// verbatim. After import, a link to the source site's `/about` should point at -// the imported WordPress page's permalink instead. -// -// `output//screenshots/manifest.json` is the authoritative -// `sourceUrl -> slug` map. Imported pages get `post_name = slug`, so the target -// is the root-relative pretty permalink `/{slug}/` (homepage -> `/`). -// -// This module is pure (no I/O), mirroring `media-url-rewrite.ts`: the caller -// builds the map from the manifest and hands us a string. The same function is -// used for page-body block markup and for generated nav template parts — both -// expose links as `href="..."` attribute surfaces. -// -/** A `redirect-map.json` entry: source path -> local WP permalink. */ -export interface RedirectMapEntry { - from: string; - to: string; -} - -/** Normalized link key -> root-relative target permalink (e.g. "/about/"). */ -export type InternalLinkMap = Map; - -export interface BuildInternalLinkMapOpts { - /** - * Source-site hostnames (e.g. ["example.test"]). When supplied, each redirect - * entry also registers a `host+path` key so ABSOLUTE same-site hrefs match. - * Absolute hrefs to any other host are left untouched (no false rewrites). - */ - siteOrigins?: string[]; -} - -export interface InternalLinkRewriteOpts { - /** - * Fired once per unique candidate href that looked internal (root-relative or - * bare-relative) but had no mapping — e.g. a page we didn't extract. Mirrors - * `rewriteMediaUrls`' missing-warning contract. - */ - onMissing?: (href: string) => void; -} - -/** - * Collapse a URL pathname into the canonical key form used for both map keys - * and candidate lookups: percent-decoded, `.html`/`.htm` stripped, trailing - * slash removed (except root), lowercased, leading-slash guaranteed. - */ -function normalizePath(pathname: string): string { - let p = pathname; - try { - p = decodeURIComponent(p); - } catch { - // Leave malformed percent-sequences as-is. - } - p = p.replace(/\.html?$/i, ''); - if (!p.startsWith('/')) p = '/' + p; - if (p !== '/') p = p.replace(/\/+$/, ''); - if (p === '') p = '/'; - return p.toLowerCase(); -} - -/** Lowercase host with a leading `www.` stripped. */ -function normalizeHost(host: string): string { - return host.toLowerCase().replace(/^www\./, ''); -} - -/** - * Build the rewrite map from `redirect-map.json` entries — the canonical - * source-path -> local-permalink map the nav/footer rewrite also consumes. - * - * Each entry registers two keys pointing at the same target so both absolute - * and relative source hrefs match: - * - path-only `/about` (root-relative + bare hrefs) - * - host + path `example.test/about` (absolute hrefs; requires origins) - * - * The site root (`/`) is always seeded to `/` so homepage links pass through - * without a spurious "unmapped" warning. - */ -export function buildInternalLinkMap( - redirectMap: RedirectMapEntry[], - opts: BuildInternalLinkMapOpts = {}, -): InternalLinkMap { - const map: InternalLinkMap = new Map(); - const hosts = (opts.siteOrigins ?? []).map(normalizeHost).filter(Boolean); - - const register = (from: string, to: string) => { - const path = normalizePath(from); - map.set(path, to); - for (const host of hosts) map.set(`${host}${path}`, to); - }; - - register('/', '/'); - for (const entry of redirectMap ?? []) { - if (!entry?.from || !entry?.to) continue; - register(entry.from, entry.to); - } - return map; -} - -const SKIP_SCHEME = /^(?:mailto:|tel:|javascript:|data:|sms:|geo:|callto:)/i; - -interface Candidate { - /** Map lookup key, or null when the href should be skipped entirely. */ - key: string | null; - /** `#fragment` (including the leading `#`) to re-append after rewrite, or ''. */ - fragment: string; - /** True when the href is root-relative or bare-relative (clearly internal). */ - internalRelative: boolean; -} - -/** Derive the lookup key + fragment for a single href value. */ -function analyzeHref(rawHref: string): Candidate { - const href = rawHref.trim(); - const none: Candidate = { key: null, fragment: '', internalRelative: false }; - if (!href || SKIP_SCHEME.test(href)) return none; - // Pure in-page anchor: no path component. - if (href.startsWith('#')) return none; - - // Absolute (or protocol-relative) URL. - if (/^https?:\/\//i.test(href) || href.startsWith('//')) { - let url: URL; - try { - url = new URL(href.startsWith('//') ? `https:${href}` : href); - } catch { - return none; - } - const key = `${normalizeHost(url.hostname)}${normalizePath(url.pathname)}`; - return { key, fragment: url.hash, internalRelative: false }; - } - - // Relative (root-relative `/x` or bare `x` / `./x` / `../x`). - const hashIdx = href.indexOf('#'); - const fragment = hashIdx >= 0 ? href.slice(hashIdx) : ''; - let pathPart = hashIdx >= 0 ? href.slice(0, hashIdx) : href; - const queryIdx = pathPart.indexOf('?'); - if (queryIdx >= 0) pathPart = pathPart.slice(0, queryIdx); - pathPart = pathPart.replace(/^(?:\.\.?\/)+/, ''); - return { key: normalizePath(pathPart), fragment, internalRelative: true }; -} - -/** - * Rewrite internal href surfaces in an HTML / block-markup string. Pure. - * - * Only `href` attribute values are touched; unmatched/external/scheme links are - * left as-is. Internal-looking misses are reported via `opts.onMissing`. - */ -export function rewriteInternalLinks( - input: string, - map: InternalLinkMap, - opts: InternalLinkRewriteOpts = {}, -): string { - if (!input || map.size === 0) return input; - - const warned = new Set(); - // Capture the quote char so we re-emit the same style (group 1 = quote). - return input.replace(/\bhref\s*=\s*(["'])([^"']*)\1/gi, (whole, quote: string, value: string) => { - const { key, fragment, internalRelative } = analyzeHref(value); - if (key === null) return whole; - const target = map.get(key); - if (target) { - return `href=${quote}${target}${fragment}${quote}`; - } - if (internalRelative && opts.onMissing && !warned.has(value)) { - warned.add(value); - opts.onMissing(value); - } - return whole; - }); -} diff --git a/packages/data-liberation-agent/src/lib/streaming/media-install.test.ts b/packages/data-liberation-agent/src/lib/streaming/media-install.test.ts deleted file mode 100644 index 8be99a50a1..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/media-install.test.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, utimesSync } from 'node:fs'; -import { join } from 'node:path'; -import { installMediaFiles, installMediaForUrl } from './media-install.js'; -import { MediaStubStore } from '../resume-state/index.js'; - -const FIXTURE_TMP = join(process.cwd(), '.tmp-test'); -mkdirSync(FIXTURE_TMP, { recursive: true }); - -interface SetupOpts { - /** Stubs to seed; key is sourceUrl, value defines status + localPath relative to outputDir/media. */ - stubs: Array<{ - url: string; - filename: string; - bytes?: Buffer; - alreadyInstalled?: number; - status?: 'success' | 'error' | 'awaiting'; - /** Record `svgRisky` on the stub (SVG survival routing). */ - svgRisky?: boolean; - /** Write this PNG into media/ AND record it as the stub's rasterPath. */ - rasterFilename?: string; - /** Write this PNG into media/ WITHOUT recording rasterPath (dedup-guard scenario). */ - sidecarPng?: string; - }>; -} - -function setup(opts: SetupOpts) { - const outputDir = mkdtempSync(join(FIXTURE_TMP, 'mi-')); - const wpRoot = join(outputDir, 'site', 'wordpress'); - mkdirSync(wpRoot, { recursive: true }); - mkdirSync(join(outputDir, 'media'), { recursive: true }); - - const store = MediaStubStore.load(outputDir); - for (const s of opts.stubs) { - const status = s.status ?? 'success'; - const filePath = join(outputDir, 'media', s.filename); - if (status === 'success') { - writeFileSync(filePath, s.bytes ?? Buffer.from('fake')); - let extra: { rasterPath?: string; svgRisky?: boolean } | undefined; - if (s.rasterFilename) { - const rasterPath = join(outputDir, 'media', s.rasterFilename); - writeFileSync(rasterPath, Buffer.from('fake-png')); - extra = { rasterPath, svgRisky: s.svgRisky }; - } else if (s.svgRisky !== undefined) { - extra = { svgRisky: s.svgRisky }; - } - if (s.sidecarPng) { - writeFileSync(join(outputDir, 'media', s.sidecarPng), Buffer.from('fake-png')); - } - store.markSuccess(s.url, filePath, extra); - if (s.alreadyInstalled !== undefined) { - store.recordWpPostId(s.url, s.alreadyInstalled); - } - } else if (status === 'error') { - store.markFailure(s.url, 'test-error'); - } - // 'awaiting' is the default-no-mutation state - } - store.flush(); - return { outputDir, wpRoot }; -} - -/** Read back the JSON payload staged for a given eval-file exec call. */ -function readStagedPayload(outputDir: string, args: string[]): Array<{ filename: string; sourceUrl: string }> { - const vfsPath = args[args.indexOf('eval-file') + 2] as string; - const name = vfsPath.split('/').pop()!; - return JSON.parse(readFileSync(join(outputDir, 'site', '.dla-scripts', 'payloads', name), 'utf8')); -} - -/** All exec calls that are wp-cli `plugin …` invocations (ensurePlugin traffic). */ -function pluginCalls(exec: ReturnType): string[][] { - return exec.mock.calls.filter(([, args]) => (args as string[]).includes('plugin')).map(([, args]) => args as string[]); -} - -const SUCCESS_RESPONSE = (entries: Array<{ sourceUrl: string; filename: string; postId: number; localUrl: string; reused?: boolean }>) => - `Some other PHP output...\nDLA_INSTALL_MEDIA_JSON_BEGIN\n${JSON.stringify({ - results: entries.map((e) => ({ ...e, reused: e.reused ?? false })), - errors: [], - })}\nDLA_INSTALL_MEDIA_JSON_END\nMore noise after\n`; - -describe('installMediaFiles', () => { - it('copies caller-supplied files into uploads and returns parsed installs', async () => { - const root = mkdtempSync(join(FIXTURE_TMP, 'mi-files-')); - const sourceDir = join(root, 'source', 'assets', 'media'); - const wpRoot = join(root, 'site', 'wordpress'); - mkdirSync(sourceDir, { recursive: true }); - mkdirSync(wpRoot, { recursive: true }); - const absPath = join(sourceDir, 'card-aurora.png'); - writeFileSync(absPath, Buffer.from('fictional image')); - const stamp = new Date(2026, 5, 9, 12, 0, 0); - utimesSync(absPath, stamp, stamp); - - try { - const exec = vi.fn().mockResolvedValue({ - stdout: SUCCESS_RESPONSE([ - { - sourceUrl: 'assets/media/card-aurora.png', - filename: 'card-aurora.png', - postId: 17, - localUrl: 'https://studio.test/wp-content/uploads/2026/06/card-aurora.png', - }, - ]), - stderr: '', - }); - - const result = await installMediaFiles({ - files: [{ absPath, sourceUrl: 'assets/media/card-aurora.png' }], - wpRoot, - _execFile: exec, - }); - - expect(result).toEqual({ - installed: [ - { - sourceUrl: 'assets/media/card-aurora.png', - postId: 17, - localUrl: 'https://studio.test/wp-content/uploads/2026/06/card-aurora.png', - }, - ], - errors: [], - }); - expect(existsSync(join(wpRoot, 'wp-content', 'uploads', '2026', '06', 'card-aurora.png'))).toBe(true); - expect(exec).toHaveBeenCalledTimes(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); - -describe('installMediaForUrl', () => { - it('copies media into wpRoot uploads, runs PHP, and records wpPostId', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - const exec = vi.fn().mockResolvedValue({ - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/a.jpg', filename: 'a.jpg', postId: 42, localUrl: 'http://wp/uploads/2024/01/a.jpg' }, - ]), - stderr: '', - }); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(result.errors).toEqual([]); - expect(result.installed).toHaveLength(1); - expect(result.installed[0]).toMatchObject({ sourceUrl: 'https://cdn/a.jpg', postId: 42 }); - - // File was copied into the wpRoot under the year/month derived from mtime. - // The exact year/month varies with the run, but we know the file should - // exist under wp-content/uploads somewhere. - const uploadsDir = join(wpRoot, 'wp-content', 'uploads'); - expect(existsSync(uploadsDir)).toBe(true); - - // Stub store now records the post ID. - const store = MediaStubStore.load(outputDir); - expect(store.get('https://cdn/a.jpg')?.wpPostId).toBe(42); - - // The PHP script + payload were staged to the parent of wpRoot (the site path). - const sitePath = join(outputDir, 'site'); - expect(existsSync(join(sitePath, '.dla-scripts', 'install-media.php'))).toBe(true); - - // exec was called with studio + wp + eval-file + script + payload. - expect(exec).toHaveBeenCalledTimes(1); - const [bin, args] = exec.mock.calls[0]; - expect(bin).toBe('studio'); - expect(args).toContain('wp'); - expect(args).toContain('eval-file'); - expect(args).toContain('--path'); - expect(args[args.indexOf('--path') + 1]).toBe(sitePath); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('uses wpRoot itself as the Studio site path for flat Studio installs', async () => { - const outputDir = mkdtempSync(join(FIXTURE_TMP, 'mi-flat-studio-')); - const wpRoot = join(outputDir, 'flat-site'); - mkdirSync(join(wpRoot, 'wp-content'), { recursive: true }); - mkdirSync(join(outputDir, 'media'), { recursive: true }); - - const filePath = join(outputDir, 'media', 'a.jpg'); - writeFileSync(filePath, Buffer.from('fake')); - const store = MediaStubStore.load(outputDir); - store.markSuccess('https://cdn/a.jpg', filePath); - store.flush(); - - try { - const exec = vi.fn().mockResolvedValue({ - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/a.jpg', filename: 'a.jpg', postId: 42, localUrl: 'http://wp/uploads/2024/01/a.jpg' }, - ]), - stderr: '', - }); - - await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - const [, args] = exec.mock.calls[0]; - expect(args[args.indexOf('--path') + 1]).toBe(wpRoot); - expect(existsSync(join(wpRoot, '.dla-scripts', 'install-media.php'))).toBe(true); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('skips entries already installed without persisted localUrl (legacy stub)', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg', alreadyInstalled: 99 }], - }); - try { - const exec = vi.fn(); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - // Nothing pending → no exec call. - expect(exec).not.toHaveBeenCalled(); - expect(result.installed).toHaveLength(0); - expect(result.skipped.some((s) => s.reason === 'already-installed')).toBe(true); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('returns already-installed stubs in result.installed when localUrl is persisted', async () => { - // Regression for the streaming-mode bug where mediaUrlMap stayed empty - // on resume runs: with localUrl persisted to MediaStub, idempotent - // re-calls surface the mapping so flushPendingImports can rebuild - // its rewrite map without re-running the PHP installer. - const outputDir = mkdtempSync(join(FIXTURE_TMP, 'mi-resume-')); - const wpRoot = join(outputDir, 'site', 'wordpress'); - mkdirSync(wpRoot, { recursive: true }); - mkdirSync(join(outputDir, 'media'), { recursive: true }); - const filePath = join(outputDir, 'media', 'a.jpg'); - writeFileSync(filePath, Buffer.from('fake')); - - const store = MediaStubStore.load(outputDir); - store.markSuccess('https://cdn/a.jpg', filePath); - store.recordWpPostId('https://cdn/a.jpg', 42); - store.recordLocalUrl('https://cdn/a.jpg', 'http://localhost:8882/wp-content/uploads/2024/01/a.jpg'); - store.flush(); - - try { - const exec = vi.fn(); - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(exec).not.toHaveBeenCalled(); - expect(result.skipped).toHaveLength(0); - expect(result.installed).toEqual([ - { - sourceUrl: 'https://cdn/a.jpg', - postId: 42, - // Stored + surfaced root-relative (port-independent) by the stub store. - localUrl: '/wp-content/uploads/2024/01/a.jpg', - localPath: filePath, - }, - ]); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('persists localUrl to the stub on fresh install (so resume runs can rebuild the map)', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/b.jpg', filename: 'b.jpg' }], - }); - try { - const exec = vi.fn().mockResolvedValue({ - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/b.jpg', filename: 'b.jpg', postId: 7, localUrl: 'http://localhost:8882/wp-content/uploads/2024/01/b.jpg' }, - ]), - stderr: '', - }); - - await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - const store = MediaStubStore.load(outputDir); - // PHP returns an absolute URL; the stub store persists it root-relative - // so the mapping survives a Studio site/port change. - expect(store.get('https://cdn/b.jpg')?.localUrl).toBe('/wp-content/uploads/2024/01/b.jpg'); - expect(store.get('https://cdn/b.jpg')?.wpPostId).toBe(7); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('skips stubs whose local file is missing', async () => { - const outputDir = mkdtempSync(join(FIXTURE_TMP, 'mi-missing-')); - const wpRoot = join(outputDir, 'site', 'wordpress'); - mkdirSync(wpRoot, { recursive: true }); - mkdirSync(join(outputDir, 'media'), { recursive: true }); - - // Stub recorded as success but the file isn't actually on disk. - const store = MediaStubStore.load(outputDir); - store.markSuccess('https://cdn/ghost.jpg', join(outputDir, 'media', 'ghost.jpg')); - store.flush(); - - try { - const exec = vi.fn(); - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - expect(exec).not.toHaveBeenCalled(); - expect(result.skipped).toEqual([{ sourceUrl: 'https://cdn/ghost.jpg', reason: 'no-local-file' }]); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('returns errors when the studio exec fails', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - const exec = vi.fn().mockRejectedValue(new Error('studio not found')); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(result.installed).toEqual([]); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toMatch(/studio not found/); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('includes stderr/stdout details when the studio exec fails', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - const err = Object.assign(new Error('Command failed: studio wp eval-file'), { - stderr: 'Fatal error: database is locked', - stdout: 'wp-cli bootstrap output', - }); - const exec = vi.fn().mockRejectedValue(err); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toContain('Fatal error: database is locked'); - expect(result.errors[0].error).toContain('wp-cli bootstrap output'); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('returns errors when the PHP response has no parseable JSON', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - const exec = vi.fn().mockResolvedValue({ stdout: 'unrelated wp-cli output without sentinels', stderr: '' }); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors[0].error).toMatch(/no parseable JSON/); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('reads the response from the sidecar result file (bypasses Studio 64KB stdout cap)', async () => { - // The script writes its full JSON to `.result.json` and emits only - // a tiny `{resultFile}` pointer to stdout. Simulate that: the mock locates - // the staged payload, writes the sidecar, and returns the pointer block. - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/big.jpg', filename: 'big.jpg' }], - }); - try { - const sitePath = join(outputDir, 'site'); - const payloadsDir = join(sitePath, '.dla-scripts', 'payloads'); - const exec = vi.fn().mockImplementation(async () => { - // Find the payload the real code just staged. - const { readdirSync } = await import('node:fs'); - const payloadFile = readdirSync(payloadsDir).find((f) => f.endsWith('.json') && !f.endsWith('.result.json')); - const payloadHostPath = join(payloadsDir, payloadFile!); - const fullResponse = JSON.stringify({ - results: [{ sourceUrl: 'https://cdn/big.jpg', filename: 'big.jpg', postId: 99, reused: false, localUrl: 'http://wp/uploads/2026/05/big.jpg' }], - errors: [], - }); - writeFileSync(`${payloadHostPath}.result.json`, fullResponse); - // stdout carries ONLY the small pointer between the sentinels. - return { - stdout: `noise\nDLA_INSTALL_MEDIA_JSON_BEGIN\n${JSON.stringify({ resultFile: `/wordpress/.dla-scripts/payloads/${payloadFile}.result.json` })}\nDLA_INSTALL_MEDIA_JSON_END\nmore noise\n`, - stderr: '', - }; - }); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - - expect(result.errors).toEqual([]); - expect(result.installed).toHaveLength(1); - expect(result.installed[0]).toMatchObject({ sourceUrl: 'https://cdn/big.jpg', postId: 99 }); - const store = MediaStubStore.load(outputDir); - expect(store.get('https://cdn/big.jpg')?.wpPostId).toBe(99); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('returns errors when the sidecar result file is missing/unreadable', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - // Pointer references a sidecar that was never written. - const exec = vi.fn().mockResolvedValue({ - stdout: `DLA_INSTALL_MEDIA_JSON_BEGIN\n${JSON.stringify({ resultFile: '/wordpress/.dla-scripts/payloads/nonexistent.json.result.json' })}\nDLA_INSTALL_MEDIA_JSON_END\n`, - stderr: '', - }); - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors[0].error).toMatch(/no parseable JSON/); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('reports per-stub errors that came back from PHP', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [ - { url: 'https://cdn/a.jpg', filename: 'a.jpg' }, - { url: 'https://cdn/b.jpg', filename: 'b.jpg' }, - ], - }); - try { - const exec = vi.fn().mockResolvedValue({ - stdout: 'noise\nDLA_INSTALL_MEDIA_JSON_BEGIN\n' + JSON.stringify({ - results: [{ sourceUrl: 'https://cdn/a.jpg', filename: 'a.jpg', postId: 1, reused: false, localUrl: 'http://l/a.jpg' }], - errors: [{ sourceUrl: 'https://cdn/b.jpg', filename: 'b.jpg', error: 'wp_insert_attachment returned 0' }], - }) + '\nDLA_INSTALL_MEDIA_JSON_END\n', - stderr: '', - }); - - const result = await installMediaForUrl({ - outputDir, - url: 'https://example.com/page', - wpRoot, - _execFile: exec, - }); - expect(result.installed).toHaveLength(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].sourceUrl).toBe('https://cdn/b.jpg'); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); -}); - -describe('installMediaForUrl — SVG routing (svg survival)', () => { - it('substitutes the PNG sibling for risky SVGs without touching safe-svg', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/logo.svg', filename: 'logo.svg', svgRisky: true, rasterFilename: 'logo.png' }], - }); - try { - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/logo.svg', filename: 'logo.png', postId: 5, localUrl: 'http://wp/uploads/2026/06/logo.png' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - // No SVG left in the batch → no ensurePlugin traffic at all. - expect(pluginCalls(exec)).toHaveLength(0); - expect(exec).toHaveBeenCalledTimes(1); - const payload = readStagedPayload(outputDir, exec.mock.calls[0][1] as string[]); - expect(payload).toHaveLength(1); - expect(payload[0].filename).toBe('logo.png'); - expect(payload[0].sourceUrl).toBe('https://cdn/logo.svg'); - expect(result.errors).toEqual([]); - expect(result.installed).toHaveLength(1); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 1, svgFailed: 0, safeSvgEnsured: false }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('keeps clean SVGs as SVG and ensures safe-svg exactly once before the batch', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [ - { url: 'https://cdn/a.svg', filename: 'a.svg' }, - { url: 'https://cdn/b.svg', filename: 'b.svg' }, - ], - }); - try { - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/a.svg', filename: 'a.svg', postId: 1, localUrl: 'http://wp/uploads/2026/06/a.svg' }, - { sourceUrl: 'https://cdn/b.svg', filename: 'b.svg', postId: 2, localUrl: 'http://wp/uploads/2026/06/b.svg' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - // ensurePlugin ran once (one is-installed probe) and BEFORE the eval-file batch. - const isInstalledCalls = exec.mock.calls.filter(([, args]) => (args as string[]).includes('is-installed')); - expect(isInstalledCalls).toHaveLength(1); - expect((isInstalledCalls[0][1] as string[])).toContain('safe-svg'); - const firstPluginIdx = exec.mock.calls.findIndex(([, args]) => (args as string[]).includes('plugin')); - const evalIdx = exec.mock.calls.findIndex(([, args]) => (args as string[]).includes('eval-file')); - expect(firstPluginIdx).toBeGreaterThanOrEqual(0); - expect(firstPluginIdx).toBeLessThan(evalIdx); - - const payload = readStagedPayload(outputDir, exec.mock.calls[evalIdx][1] as string[]); - expect(payload.map((p) => p.filename).sort()).toEqual(['a.svg', 'b.svg']); - expect(result.errors).toEqual([]); - expect(result.svg).toEqual({ svgUploaded: 2, svgSubstituted: 0, svgFailed: 0, safeSvgEnsured: true }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('risky deduped SVG (no rasterPath) substitutes the on-disk PNG sibling via the dedup guard', async () => { - // Byte-duplicate SVG URLs dedupe at fetch: the stub points at the - // ORIGINAL's localPath but carries no rasterPath of its own. The - // original's sibling lives at exactly localPath with .svg → .png. - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/dup.svg', filename: 'shared.svg', svgRisky: true, sidecarPng: 'shared.png' }], - }); - try { - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/dup.svg', filename: 'shared.png', postId: 8, localUrl: 'http://wp/uploads/2026/06/shared.png' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - const payload = readStagedPayload(outputDir, exec.mock.calls[0][1] as string[]); - expect(payload[0].filename).toBe('shared.png'); - expect(pluginCalls(exec)).toHaveLength(0); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 1, svgFailed: 0, safeSvgEnsured: false }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('clean deduped SVG stays SVG even when a PNG sibling exists on disk', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/dup2.svg', filename: 'icon.svg', sidecarPng: 'icon.png' }], - }); - try { - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/dup2.svg', filename: 'icon.svg', postId: 3, localUrl: 'http://wp/uploads/2026/06/icon.svg' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - const evalCall = exec.mock.calls.find(([, args]) => (args as string[]).includes('eval-file'))!; - const payload = readStagedPayload(outputDir, evalCall[1] as string[]); - expect(payload[0].filename).toBe('icon.svg'); - expect(result.svg).toEqual({ svgUploaded: 1, svgSubstituted: 0, svgFailed: 0, safeSvgEnsured: true }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('ensurePlugin failure → mass PNG substitution + error stub for SVGs without raster', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [ - { url: 'https://cdn/c.svg', filename: 'c.svg', rasterFilename: 'c.png' }, - { url: 'https://cdn/d.svg', filename: 'd.svg' }, - ], - }); - try { - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('plugin')) throw new Error('no network'); - if (args.includes('eval-file')) { - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/c.svg', filename: 'c.png', postId: 6, localUrl: 'http://wp/uploads/2026/06/c.png' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - const evalCall = exec.mock.calls.find(([, args]) => (args as string[]).includes('eval-file'))!; - const payload = readStagedPayload(outputDir, evalCall[1] as string[]); - expect(payload.map((p) => p.filename)).toEqual(['c.png']); - expect(result.installed).toHaveLength(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].sourceUrl).toBe('https://cdn/d.svg'); - expect(result.errors[0].error).toMatch(/safe-svg unavailable and no raster fallback/); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 1, svgFailed: 1, safeSvgEnsured: false }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('retries a per-file SVG insert failure once with the PNG sibling', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/e.svg', filename: 'e.svg', rasterFilename: 'e.png' }], - }); - try { - let evalCalls = 0; - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - evalCalls += 1; - if (evalCalls === 1) { - return { - stdout: 'DLA_INSTALL_MEDIA_JSON_BEGIN\n' + JSON.stringify({ - results: [], - errors: [{ sourceUrl: 'https://cdn/e.svg', filename: 'e.svg', error: 'svg_mime_rejected: image/svg+xml is not allowed on this site (Safe SVG inactive)' }], - }) + '\nDLA_INSTALL_MEDIA_JSON_END\n', - stderr: '', - }; - } - return { - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/e.svg', filename: 'e.png', postId: 9, localUrl: 'http://wp/uploads/2026/06/e.png' }, - ]), - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - expect(evalCalls).toBe(2); - const evalArgList = exec.mock.calls.filter(([, args]) => (args as string[]).includes('eval-file')); - const retryPayload = readStagedPayload(outputDir, evalArgList[1][1] as string[]); - expect(retryPayload.map((p) => p.filename)).toEqual(['e.png']); - expect(result.errors).toEqual([]); - expect(result.installed).toHaveLength(1); - expect(result.installed[0].postId).toBe(9); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 1, svgFailed: 0, safeSvgEnsured: true }); - // The PNG was copied into uploads for the retry batch. - const store = MediaStubStore.load(outputDir); - expect(store.get('https://cdn/e.svg')?.wpPostId).toBe(9); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('a failed PNG retry surfaces as svgFailed with a retry-tagged error', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/f.svg', filename: 'f.svg', rasterFilename: 'f.png' }], - }); - try { - let evalCalls = 0; - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - evalCalls += 1; - const failure = evalCalls === 1 - ? { sourceUrl: 'https://cdn/f.svg', filename: 'f.svg', error: 'svg_mime_rejected: nope' } - : { sourceUrl: 'https://cdn/f.svg', filename: 'f.png', error: 'wp_insert_attachment returned 0' }; - return { - stdout: 'DLA_INSTALL_MEDIA_JSON_BEGIN\n' + JSON.stringify({ results: [], errors: [failure] }) + '\nDLA_INSTALL_MEDIA_JSON_END\n', - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - expect(evalCalls).toBe(2); - expect(result.installed).toEqual([]); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toMatch(/svg png retry/); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 0, svgFailed: 1, safeSvgEnsured: true }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('a per-file SVG failure with no raster fallback stays an error (no retry batch)', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/g.svg', filename: 'g.svg' }], - }); - try { - let evalCalls = 0; - const exec = vi.fn().mockImplementation(async (_bin: string, args: string[]) => { - if (args.includes('eval-file')) { - evalCalls += 1; - return { - stdout: 'DLA_INSTALL_MEDIA_JSON_BEGIN\n' + JSON.stringify({ - results: [], - errors: [{ sourceUrl: 'https://cdn/g.svg', filename: 'g.svg', error: 'svg_mime_rejected: nope' }], - }) + '\nDLA_INSTALL_MEDIA_JSON_END\n', - stderr: '', - }; - } - return { stdout: '', stderr: '' }; - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - expect(evalCalls).toBe(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toMatch(/svg_mime_rejected/); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 0, svgFailed: 1, safeSvgEnsured: true }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); - - it('non-SVG batches never touch the plugin CLI and report a zero tally', async () => { - const { outputDir, wpRoot } = setup({ - stubs: [{ url: 'https://cdn/a.jpg', filename: 'a.jpg' }], - }); - try { - const exec = vi.fn().mockResolvedValue({ - stdout: SUCCESS_RESPONSE([ - { sourceUrl: 'https://cdn/a.jpg', filename: 'a.jpg', postId: 42, localUrl: 'http://wp/uploads/2024/01/a.jpg' }, - ]), - stderr: '', - }); - - const result = await installMediaForUrl({ outputDir, url: 'https://example.com/page', wpRoot, _execFile: exec }); - - expect(exec).toHaveBeenCalledTimes(1); - expect(pluginCalls(exec)).toHaveLength(0); - expect(result.svg).toEqual({ svgUploaded: 0, svgSubstituted: 0, svgFailed: 0, safeSvgEnsured: false }); - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/data-liberation-agent/src/lib/streaming/media-install.ts b/packages/data-liberation-agent/src/lib/streaming/media-install.ts deleted file mode 100644 index 6d297b209b..0000000000 --- a/packages/data-liberation-agent/src/lib/streaming/media-install.ts +++ /dev/null @@ -1,650 +0,0 @@ -// -// Per-URL media install -// ===================== -// Phase 1.5 of the streaming/incremental replicate pipeline. For each URL -// processed by the streaming loop, install pending media into the running -// Studio replica WP site so pages render with real images while streaming. -// -// Behavior: -// - Reads MediaStubStore.list() for all stubs in `success` state with -// `localPath` set and no `wpPostId` (i.e., not yet installed). -// - Copies each file from /media/ into -// /wp-content/uploads/// based on the -// local file's mtime (matches WP's default uploads layout). -// - Invokes a vendored PHP script (install-media.php) via `studio wp -// eval-file` that runs `wp_insert_attachment` for each entry. -// The script is idempotent: it checks `_wp_attached_file` first and -// re-uses an existing attachment ID when present. -// - Records the resulting post ID back into MediaStubStore via -// `recordWpPostId(url, postId)` so subsequent calls skip the URL. -// -// Scope: -// - Per the contract, this installs ALL pending media each call. The -// existing MediaStubStore doesn't track URL→media membership, so -// scoping to one URL's media isn't possible without a schema change. -// Idempotency keeps duplicate calls cheap. -// -import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { fileURLToPath } from 'node:url'; -import { MediaStubStore, type MediaStub } from '../resume-state/index.js'; -import { ensurePlugin, type ExecFn } from '../preview/ensure-plugin.js'; -import { studioExecFileAsync } from '../studio-cli.js'; - -const execFileAsync = promisify(execFile); - -/** Vendored PHP installer that runs inside the running WP site via wp-cli. */ -const INSTALL_MEDIA_SCRIPT = resolve( - dirname(fileURLToPath(import.meta.url)), - '..', - 'preview', - 'scripts', - 'install-media.php', -); - -/** - * Studio mounts the host site directory at VFS path `/wordpress` (mirrors - * studio.ts's STUDIO_VFS_ROOT constant). Re-declared here to avoid a - * cross-module dependency for a path constant. - */ -const STUDIO_VFS_ROOT = '/wordpress'; - -const SCRIPTS_SUBDIR = '.dla-scripts'; -const PAYLOADS_SUBDIR = '.dla-scripts/payloads'; - -/** Monotonic per-process payload counter — see payloadFilename below. */ -let payloadSeq = 0; - -export interface MediaInstallOpts { - /** Liberation output directory containing media/ and media-stubs.json. */ - outputDir: string; - /** Source URL whose media we're installing — kept for trace logging. */ - url: string; - /** Running Studio WP install root (e.g. /wordpress or for flat sites). */ - wpRoot: string; - /** Override the studio binary location (for tests). */ - _studioBin?: string; - /** Inject an exec-file impl (for tests). */ - _execFile?: (file: string, args: readonly string[]) => Promise<{ stdout: string; stderr: string }>; -} - -/** Install-time SVG routing tally (svg survival, F1). */ -export interface SvgInstallTally { - /** SVG-origin assets that landed in the library as SVG. */ - svgUploaded: number; - /** SVG-origin assets that landed as their rasterized PNG sibling. */ - svgSubstituted: number; - /** SVG-origin assets that failed to land at all. */ - svgFailed: number; - /** True when ensurePlugin('safe-svg') ran and succeeded for this batch. */ - safeSvgEnsured: boolean; -} - -export interface MediaInstallResult { - installed: Array<{ sourceUrl: string; postId: number; localUrl: string; localPath: string }>; - skipped: Array<{ sourceUrl: string; reason: 'already-installed' | 'no-local-file' | 'no-stub' }>; - errors: Array<{ sourceUrl: string; error: string }>; - svg: SvgInstallTally; -} - -export interface MediaFile { - absPath: string; - sourceUrl: string; -} - -export interface MediaFilesResult { - installed: Array<{ sourceUrl: string; postId: number; localUrl: string }>; - errors: Array<{ sourceUrl: string; error: string }>; -} - -export interface MediaFilesInstallOpts { - files: MediaFile[]; - wpRoot: string; - _studioBin?: string; - _execFile?: (file: string, args: readonly string[]) => Promise<{ stdout: string; stderr: string }>; -} - -interface PayloadEntry { - filename: string; - year: string; - month: string; - sourceUrl: string; -} - -interface PendingItem { - url: string; - stub: MediaStub; - entry: PayloadEntry; - absPath: string; - /** Set when the stub's local file is an SVG — drives install-time routing. */ - svgOrigin?: boolean; - /** Resolved absolute path of the PNG raster sibling (stub field or dedup-guard derivation). */ - rasterAbs?: string | null; - /** True once the entry was rerouted to upload the PNG instead of the SVG. */ - substituted?: boolean; - /** Dropped from the batch entirely (safe-svg unavailable + no raster fallback). */ - dropped?: boolean; -} - -interface PhpResultEntry { - sourceUrl: string; - filename: string; - postId: number; - reused: boolean; - localUrl: string; -} - -interface PhpErrorEntry { - sourceUrl: string; - filename: string; - error: string; -} - -interface PhpResponse { - results: PhpResultEntry[]; - errors: PhpErrorEntry[]; -} - -export async function installMediaFiles(opts: MediaFilesInstallOpts): Promise { - const result: MediaFilesResult = { installed: [], errors: [] }; - const pending: Array<{ file: MediaFile; entry: PayloadEntry }> = []; - - for (const file of opts.files) { - let mtime: Date; - try { - mtime = statSync(file.absPath).mtime; - } catch { - result.errors.push({ sourceUrl: file.sourceUrl, error: 'source file is missing or unstattable' }); - continue; - } - - const filename = basenameOf(file.absPath); - const year = String(mtime.getFullYear()).padStart(4, '0'); - const month = String(mtime.getMonth() + 1).padStart(2, '0'); - pending.push({ file, entry: { filename, year, month, sourceUrl: file.sourceUrl } }); - } - - if (pending.length === 0) { - return result; - } - - // Copy each file into the running site's uploads dir before wp_insert_attachment. - const uploadsRoot = join(resolve(opts.wpRoot), 'wp-content', 'uploads'); - for (const item of pending) { - const destDir = join(uploadsRoot, item.entry.year, item.entry.month); - const destPath = join(destDir, item.entry.filename); - try { - mkdirSync(destDir, { recursive: true }); - if (!existsSync(destPath)) { - copyFileSync(item.file.absPath, destPath); - } - } catch (err) { - result.errors.push({ sourceUrl: item.file.sourceUrl, error: `copy: ${(err as Error).message}` }); - } - } - - const copied = pending.filter( - (p) => !result.errors.find((e) => e.sourceUrl === p.file.sourceUrl), - ); - if (copied.length === 0) { - return result; - } - - let scriptOut: { stdout: string; resultHostPath: string }; - try { - scriptOut = await installViaStudio(opts, copied.map((p) => p.entry)); - } catch (err) { - for (const item of copied) { - result.errors.push({ - sourceUrl: item.file.sourceUrl, - error: `wp eval-file install-media.php failed: ${formatExecError(err)}`, - }); - } - return result; - } - - const parsed = parsePhpResponse(scriptOut.stdout, scriptOut.resultHostPath); - if (!parsed) { - for (const item of copied) { - result.errors.push({ - sourceUrl: item.file.sourceUrl, - error: 'install-media.php produced no parseable JSON response', - }); - } - return result; - } - - for (const ok of parsed.results) { - result.installed.push({ - sourceUrl: ok.sourceUrl, - postId: ok.postId, - localUrl: ok.localUrl, - }); - } - for (const fail of parsed.errors) { - result.errors.push({ sourceUrl: fail.sourceUrl, error: fail.error }); - } - - return result; -} - -/** Single entry-point. Always opens MediaStubStore in-place. */ -export async function installMediaForUrl(opts: MediaInstallOpts): Promise { - const result: MediaInstallResult = { - installed: [], - skipped: [], - errors: [], - svg: { svgUploaded: 0, svgSubstituted: 0, svgFailed: 0, safeSvgEnsured: false }, - }; - const stubs = MediaStubStore.load(opts.outputDir); - const mediaDir = join(resolve(opts.outputDir), 'media'); - - // 1. Walk every stub and bucket it: ready-to-install, already-done, - // not-locally-downloaded, etc. - const pending: PendingItem[] = []; - for (const [url, stub] of stubs.list()) { - if (stub.status !== 'success' || !stub.localPath) { - result.skipped.push({ sourceUrl: url, reason: 'no-local-file' }); - continue; - } - if (typeof stub.wpPostId === 'number') { - // Already-installed: surface the persisted localUrl in `installed` - // so the run-wide rewrite map can be (re-)built from this call's - // result alone, even on resume runs where the PHP script wouldn't - // re-run for these entries. Falls back to `skipped` when the stub - // pre-dates the localUrl persistence change. - if (stub.localUrl) { - result.installed.push({ - sourceUrl: url, - postId: stub.wpPostId, - localUrl: stub.localUrl, - localPath: stub.localPath ?? '', - }); - } else { - result.skipped.push({ sourceUrl: url, reason: 'already-installed' }); - } - continue; - } - - // Resolve the canonical filename. localPath may be absolute (older - // adapters) or just a basename — handle both. Source-of-truth is - // /media/. - const filename = basenameOf(stub.localPath); - const absPath = join(mediaDir, filename); - if (!existsSync(absPath)) { - result.skipped.push({ sourceUrl: url, reason: 'no-local-file' }); - continue; - } - let mtime: Date; - try { - mtime = statSync(absPath).mtime; - } catch { - // Treat unstattable files as missing — should be rare; surfaces as - // a skip rather than a hard failure. - result.skipped.push({ sourceUrl: url, reason: 'no-local-file' }); - continue; - } - const year = String(mtime.getFullYear()).padStart(4, '0'); - const month = String(mtime.getMonth() + 1).padStart(2, '0'); - - pending.push({ url, stub, entry: { filename, year, month, sourceUrl: url }, absPath }); - } - - if (pending.length === 0) { - return result; - } - - // 1.5 SVG routing (svg survival, F1). Default WP rejects image/svg+xml, so - // before the PHP batch each SVG-origin item is routed: - // - risky SVG (Safe SVG's sanitizer would mangle its / graph) - // with a PNG raster sibling → upload the PNG instead; - // - any SVG still in the batch → ensurePlugin('safe-svg') ONCE first; - // when that fails, fall back to PNG for every SVG that has a raster and - // error-stub the rest. - const svgItems = pending.filter((p) => /\.svg$/i.test(p.entry.filename)); - for (const item of svgItems) { - item.svgOrigin = true; - item.rasterAbs = resolveRasterAbs(item.stub, mediaDir); - if (item.stub.svgRisky === true && item.rasterAbs) { - substituteRaster(item); - } - } - const svgStillInBatch = svgItems.filter((p) => !p.substituted); - if (svgStillInBatch.length > 0) { - const ensured = await ensurePlugin( - studioSitePathForWpRoot(opts.wpRoot), - 'safe-svg', - wpExecFor(opts), - ); - if (ensured.ok) { - result.svg.safeSvgEnsured = true; - } else { - for (const item of svgStillInBatch) { - if (item.rasterAbs) { - substituteRaster(item); - } else { - item.dropped = true; - result.errors.push({ - sourceUrl: item.url, - error: `safe-svg unavailable and no raster fallback (${ensured.error})`, - }); - } - } - } - } - const batch = pending.filter((p) => !p.dropped); - if (batch.length === 0) { - finalizeSvgTally(result, svgItems); - return result; - } - - // 2. Copy each pending file into the running site's uploads dir. - // This must happen BEFORE the PHP script runs — wp_insert_attachment - // requires the file to exist on disk to compute metadata. - const uploadsRoot = join(resolve(opts.wpRoot), 'wp-content', 'uploads'); - for (const item of batch) { - const destDir = join(uploadsRoot, item.entry.year, item.entry.month); - const destPath = join(destDir, item.entry.filename); - try { - mkdirSync(destDir, { recursive: true }); - // Idempotent: if the file is already in place, skip the copy. - if (!existsSync(destPath)) { - copyFileSync(item.absPath, destPath); - } - } catch (err) { - result.errors.push({ sourceUrl: item.url, error: `copy: ${(err as Error).message}` }); - } - } - - // Drop any items whose copy failed before invoking PHP. - const installedFiles = batch.filter( - (p) => !result.errors.find((e) => e.sourceUrl === p.url), - ); - if (installedFiles.length === 0) { - finalizeSvgTally(result, svgItems); - return result; - } - - // 3. Stage payload + invoke wp eval-file. - let scriptOut: { stdout: string; resultHostPath: string }; - try { - scriptOut = await installViaStudio(opts, installedFiles.map((p) => p.entry)); - } catch (err) { - // The shell-level failure means none of the entries got registered. - // Each pending entry surfaces as an error so the caller can retry. - for (const item of installedFiles) { - result.errors.push({ - sourceUrl: item.url, - error: `wp eval-file install-media.php failed: ${formatExecError(err)}`, - }); - } - finalizeSvgTally(result, svgItems); - return result; - } - - // 4. Parse the script's response and reconcile with the stub store. - const parsed = parsePhpResponse(scriptOut.stdout, scriptOut.resultHostPath); - if (!parsed) { - for (const item of installedFiles) { - result.errors.push({ - sourceUrl: item.url, - error: 'install-media.php produced no parseable JSON response', - }); - } - finalizeSvgTally(result, svgItems); - return result; - } - - // 4.5 Per-file SVG retry: an SVG that PHP rejected per-file (e.g. the - // svg_mime_rejected marker when Safe SVG didn't take) gets ONE retry as its - // PNG sibling in a second mini-batch. Everything else flows straight through - // as an error. - const phpResults: PhpResultEntry[] = [...parsed.results]; - const retryable: PendingItem[] = []; - for (const fail of parsed.errors) { - const item = installedFiles.find((p) => p.url === fail.sourceUrl); - if (item?.svgOrigin && !item.substituted && item.rasterAbs) { - retryable.push(item); - } else { - result.errors.push({ sourceUrl: fail.sourceUrl, error: fail.error }); - } - } - if (retryable.length > 0) { - const copied: PendingItem[] = []; - for (const item of retryable) { - substituteRaster(item); - try { - const destDir = join(uploadsRoot, item.entry.year, item.entry.month); - mkdirSync(destDir, { recursive: true }); - const destPath = join(destDir, item.entry.filename); - if (!existsSync(destPath)) { - copyFileSync(item.absPath, destPath); - } - copied.push(item); - } catch (err) { - result.errors.push({ sourceUrl: item.url, error: `svg png retry copy: ${(err as Error).message}` }); - } - } - if (copied.length > 0) { - try { - const retryOut = await installViaStudio(opts, copied.map((p) => p.entry)); - const parsedRetry = parsePhpResponse(retryOut.stdout, retryOut.resultHostPath); - if (parsedRetry) { - phpResults.push(...parsedRetry.results); - for (const fail of parsedRetry.errors) { - result.errors.push({ sourceUrl: fail.sourceUrl, error: `svg png retry: ${fail.error}` }); - } - } else { - for (const item of copied) { - result.errors.push({ - sourceUrl: item.url, - error: 'svg png retry: install-media.php produced no parseable JSON response', - }); - } - } - } catch (err) { - for (const item of copied) { - result.errors.push({ sourceUrl: item.url, error: `svg png retry failed: ${formatExecError(err)}` }); - } - } - } - } - - for (const ok of phpResults) { - if (typeof ok.postId === 'number' && ok.postId > 0) { - stubs.recordWpPostId(ok.sourceUrl, ok.postId); - } - if (ok.localUrl) { - // Persist the localUrl to the stub so resume runs can rebuild the - // source→local rewrite map without re-running the PHP script. - stubs.recordLocalUrl(ok.sourceUrl, ok.localUrl); - } - const stub = stubs.get(ok.sourceUrl); - result.installed.push({ - sourceUrl: ok.sourceUrl, - postId: ok.postId, - // Prefer the store's normalized (root-relative) localUrl so the run-wide - // rewrite map is port-independent; fall back to the raw upload URL. - localUrl: stub?.localUrl ?? ok.localUrl, - localPath: stub?.localPath ?? '', - }); - } - finalizeSvgTally(result, svgItems); - return result; -} - -/** - * Resolve the absolute on-disk path of an SVG stub's PNG raster sibling. - * Primary source is the `rasterPath` recorded at fetch time. Dedup guard: - * byte-duplicate SVG URLs dedupe at fetch, so a deduped URL's stub points at - * the ORIGINAL's localPath but carries no rasterPath of its own — the - * original's sibling lives at exactly localPath with `.svg` → `.png` (modulo - * the rare `-N` collision suffix, in which case we miss and the SVG continues - * alone). - */ -function resolveRasterAbs(stub: MediaStub, mediaDir: string): string | null { - if (stub.rasterPath) { - const abs = join(mediaDir, basenameOf(stub.rasterPath)); - if (existsSync(abs)) return abs; - return existsSync(stub.rasterPath) ? stub.rasterPath : null; - } - if (stub.localPath && /\.svg$/i.test(stub.localPath)) { - const abs = join(mediaDir, basenameOf(stub.localPath).replace(/\.svg$/i, '.png')); - return existsSync(abs) ? abs : null; - } - return null; -} - -/** Reroute a pending SVG item to upload its PNG raster sibling instead. */ -function substituteRaster(item: PendingItem): void { - item.entry.filename = basenameOf(item.rasterAbs!); - item.absPath = item.rasterAbs!; - item.substituted = true; -} - -/** - * Count each SVG-origin item exactly once: installed-as-SVG, installed-as-PNG, - * or failed. Called on every post-routing exit path so the tally is accurate - * even when the batch aborts early. - */ -function finalizeSvgTally(result: MediaInstallResult, svgItems: PendingItem[]): void { - for (const item of svgItems) { - const ok = result.installed.some((i) => i.sourceUrl === item.url); - if (!ok) result.svg.svgFailed += 1; - else if (item.substituted) result.svg.svgSubstituted += 1; - else result.svg.svgUploaded += 1; - } -} - -/** - * Adapt this module's injected exec into ensurePlugin's StudioWpRunner shape - * (`studio wp --path <...args>` → stdout). - */ -function wpExecFor(opts: MediaInstallOpts): ExecFn { - const studioBin = opts._studioBin ?? 'studio'; - const exec = opts._execFile ?? defaultExec; - return (sitePath, args) => exec(studioBin, ['wp', '--path', sitePath, ...args]).then((o) => o.stdout); -} - -async function installViaStudio(opts: Pick, entries: PayloadEntry[]): Promise<{ stdout: string; resultHostPath: string }> { - // The PHP script must be readable inside Studio's VFS. Studio mounts the - // *site* directory at /wordpress. Studio sites exist in two layouts: - // - flat: /wp-content - // - nested: /wordpress/wp-content - // The watch runner passes the WP root, so resolve it back to the Studio - // site path before invoking `studio wp --path`. - const sitePath = studioSitePathForWpRoot(opts.wpRoot); - const scriptsDir = join(sitePath, SCRIPTS_SUBDIR); - const payloadsDir = join(sitePath, PAYLOADS_SUBDIR); - mkdirSync(scriptsDir, { recursive: true }); - mkdirSync(payloadsDir, { recursive: true }); - - const scriptHostPath = join(scriptsDir, 'install-media.php'); - copyFileSync(INSTALL_MEDIA_SCRIPT, scriptHostPath); - - // Sequence suffix: the SVG retry mini-batch can fire within the same - // millisecond as the main batch — Date.now()+pid alone would collide and - // overwrite the first payload + sidecar result file. - const payloadFilename = `install-media-${Date.now()}-${process.pid}-${++payloadSeq}.json`; - const payloadHostPath = join(payloadsDir, payloadFilename); - writeFileSync(payloadHostPath, JSON.stringify(entries), 'utf8'); - - const scriptVfsPath = `${STUDIO_VFS_ROOT}/${SCRIPTS_SUBDIR}/install-media.php`; - const payloadVfsPath = `${STUDIO_VFS_ROOT}/${PAYLOADS_SUBDIR}/${payloadFilename}`; - - const studioBin = opts._studioBin ?? 'studio'; - const exec = opts._execFile ?? defaultExec; - const out = await exec(studioBin, [ - 'wp', '--path', sitePath, - 'eval-file', scriptVfsPath, payloadVfsPath, - ]); - // The script writes its full response to `.result.json` on the host - // FS (Studio mounts the site dir), so we can read it directly and bypass the - // 64KB stdout cap. - return { stdout: out.stdout, resultHostPath: `${payloadHostPath}.result.json` }; -} - -function studioSitePathForWpRoot(wpRoot: string): string { - const resolved = resolve(wpRoot); - if (basenameOf(resolved) === 'wordpress') { - return dirname(resolved); - } - return resolved; -} - -function defaultExec(file: string, args: readonly string[]): Promise<{ stdout: string; stderr: string }> { - const opts = { timeout: 300_000, maxBuffer: 50 * 1024 * 1024 }; - // 'studio' resolves via studio-cli (Windows .cmd shims can't be spawned - // directly — STU-2020); an overridden _studioBin path spawns as-is. - const run = file === 'studio' - ? studioExecFileAsync(args as string[], opts) - : execFileAsync(file, args as string[], opts); - return run.then(({ stdout, stderr }) => ({ stdout: String(stdout), stderr: String(stderr) })); -} - -function formatExecError(err: unknown): string { - const e = err as Error & { stderr?: string; stdout?: string }; - const parts = [e?.message ? e.message.trim() : String(err)]; - if (e?.stderr?.trim()) parts.push(`stderr: ${e.stderr.trim().slice(-1000)}`); - if (e?.stdout?.trim()) parts.push(`stdout: ${e.stdout.trim().slice(-1000)}`); - return parts.join(' | '); -} - -/** - * Extract the JSON payload between the script's BEGIN/END sentinels. Returns - * null when the sentinels are missing or the body fails to parse — the caller - * surfaces a generic error in either case. - * - * Two body shapes are supported: - * 1. A `{ resultFile: "" }` pointer — the script wrote the full - * response to a sidecar file (default; bypasses Studio's 64KB stdout cap). - * We read that file off the host FS. `resultHostPath` is the host path the - * caller knows; we prefer it over the (VFS) path the script reports so the - * read works regardless of mount mapping. - * 2. Inline JSON (backward-compatible fallback for small payloads or when the - * sidecar write failed). - */ -function parsePhpResponse(stdout: string, resultHostPath?: string): PhpResponse | null { - const begin = 'DLA_INSTALL_MEDIA_JSON_BEGIN'; - const end = 'DLA_INSTALL_MEDIA_JSON_END'; - const start = stdout.indexOf(begin); - const stop = stdout.indexOf(end); - if (start < 0 || stop < 0 || stop <= start) return null; - const body = stdout.slice(start + begin.length, stop).trim(); - - let raw = body; - try { - const maybePointer = JSON.parse(body) as { resultFile?: string }; - if (maybePointer && typeof maybePointer.resultFile === 'string') { - // Prefer the host path the caller computed; fall back to the path the - // script reported (only valid when host FS === reported path). - const path = resultHostPath ?? maybePointer.resultFile; - try { - raw = readFileSync(path, 'utf8'); - } catch { - return null; - } - } - } catch { - // Not JSON at all → fall through; the parse below will fail and return null. - } - - try { - const parsed = JSON.parse(raw) as PhpResponse; - if (!parsed || !Array.isArray(parsed.results) || !Array.isArray(parsed.errors)) { - return null; - } - return parsed; - } catch { - return null; - } -} - -/** Cross-platform basename — avoids path.basename pitfalls on mixed separators. */ -function basenameOf(p: string): string { - const trimmed = p.replace(/[\\/]+$/, ''); - const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); - return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; -} diff --git a/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.test.ts b/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.test.ts index 7a7f775fe5..4d2dce2f65 100644 --- a/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.test.ts +++ b/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.test.ts @@ -50,6 +50,13 @@ describe('rewriteMediaUrls', () => { expect(out).toContain('https://cdn/unknown.jpg'); }); + it('ignores a root-path mapping that would rewrite every slash', () => { + const html = 'About'; + const map = new Map([['/', 'https://example.com/']]); + + expect(rewriteMediaUrls(html, map)).toBe(html); + }); + it('reports unmapped URLs via onMissing callback', () => { const html = ''; const onMissing = vi.fn(); @@ -138,7 +145,7 @@ describe('rewriteMediaUrls', () => { it('rewrites a Wix srcset whose display filename contains parentheses (no `).png` mangle)', () => { // The Wix logo srcset ends each variant with the display name `… (1).png`. - // URL_LIKE must not truncate at the `)`, or the rewrite leaves `).png`. + // URL extraction must not truncate at the `)`, or the rewrite leaves `).png`. const hash = '670df9_dc553b632f22456e8f3e591105cdc3da'; const base = `https://static.wixstatic.com/media/${hash}~mv2.png`; const local = `http://localhost:8884/wp-content/uploads/2026/05/Cornelius-Holmes-1.png`; @@ -153,6 +160,34 @@ describe('rewriteMediaUrls', () => { expect(out).not.toContain('static.wixstatic.com'); expect(out).not.toContain(').png'); // the mangle signature }); + + it('does not mangle a Wix transform URL on a surface the candidate scan misses', () => { + const hash = 'ea71bb_2b0f0e1b9a1f4f0e9d2a5c7e1b3d4f60'; + const base = `https://static.wixstatic.com/media/${hash}~mv2.png`; + const local = 'http://localhost:8884/wp-content/uploads/2026/05/hero.png'; + const transform = `${base}/v1/fill/w_58,h_57,al_c,q_85,usm_0.66_1.00_0.01,enc_avif,quality_auto/file.png`; + const html = `
`; + + const out = rewriteMediaUrls(html, new Map([[base, local]])); + + expect(out).not.toContain(`${local}/v1/`); // the mangle signature + expect(out).toBe(html); + }); + + it("rewrites Wix display filenames containing apostrophes without suffix corruption", () => { + const hash = '670df9_dc553b632f22456e8f3e591105cdc3da'; + const base = `https://static.wixstatic.com/media/${hash}~mv2.jpg`; + const local = 'http://localhost:8884/wp-content/uploads/2026/05/womens-day.jpg'; + const variant = `${base}/v1/fill/w_640,h_480,q_85,enc_avif,quality_auto/Happy%20Women's%20Day.jpg`; + const html = ``; + + const out = rewriteMediaUrls(html, new Map([[base, local]])); + + expect(out).toBe(``); + expect(out).not.toContain("Women's%20Day.jpg"); + expect(out).not.toContain('data:image/gif;base64,'); + expect(out).not.toContain(`${local}'s%20Day.jpg`); + }); }); describe('toLocalUrlMapping', () => { diff --git a/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.ts b/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.ts index adc5834dc8..5fbda1ccd6 100644 --- a/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.ts +++ b/packages/data-liberation-agent/src/lib/streaming/media-url-rewrite.ts @@ -88,14 +88,23 @@ export function rewriteMediaUrls( // (a 404). Longest-first guarantees the most-specific (full) url is replaced // before any shorter substring of it. const ordered = [...replacements.entries()] - .filter(([source]) => source) + // A same-origin media URL can produce `/` as an alias. Replacing that + // substring would corrupt every path, closing tag, and MIME type in the document. + .filter(([source]) => source && source !== '/') .sort((a, b) => b[0].length - a[0].length); for (const [source, local] of ordered) { // Escape the source URL for safe inclusion in a RegExp. This handles // querystring `?`, `&`, `+` and other regex metacharacters that often // appear in CDN URLs. const safe = escapeRegex(source); - out = out.replace(new RegExp(safe, 'g'), () => local); + // Longest-first only removes the mangle for transform urls the candidate + // scan reached. A transform url on any other surface - a `data-` attribute, + // a ``, a `