@getvitops/utils 8.2.0-alpha.0 → 8.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +399 -23
- package/dist/{adc-D6aw2qgq.mjs → adc-BiW9BzqA.mjs} +2 -3
- package/dist/{adc-D6aw2qgq.mjs.map → adc-BiW9BzqA.mjs.map} +1 -1
- package/dist/{adc-BoEoogyo.d.mts → adc-DvX3-P7a.d.mts} +2 -4
- package/dist/adc-DvX3-P7a.d.mts.map +1 -0
- package/dist/ads/index.d.mts +37 -57
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +21 -10
- package/dist/ads/index.mjs.map +1 -1
- package/dist/audit/index.d.mts +180 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +509 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/color/index.d.mts +7 -5
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +10 -5
- package/dist/color/index.mjs.map +1 -1
- package/dist/content.d.mts +78 -0
- package/dist/content.d.mts.map +1 -0
- package/dist/content.mjs +45 -0
- package/dist/content.mjs.map +1 -0
- package/dist/crm/index.d.mts +5 -87
- package/dist/crm/index.d.mts.map +1 -1
- package/dist/crm/index.mjs +10 -19
- package/dist/crm/index.mjs.map +1 -1
- package/dist/email/index.d.mts +1 -11
- package/dist/email/index.d.mts.map +1 -1
- package/dist/email/index.mjs +6 -10
- package/dist/email/index.mjs.map +1 -1
- package/dist/favicon.d.mts +0 -2
- package/dist/favicon.d.mts.map +1 -1
- package/dist/favicon.mjs +1 -2
- package/dist/git/index.d.mts +0 -2
- package/dist/git/index.d.mts.map +1 -1
- package/dist/git/index.mjs +1 -2
- package/dist/hosting/index.d.mts +0 -8
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +4 -7
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/{identity-BoRddTx3.mjs → identity-C4yInICW.mjs} +2 -3
- package/dist/{identity-BoRddTx3.mjs.map → identity-C4yInICW.mjs.map} +1 -1
- package/dist/index.d.mts +2 -88
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +36 -52
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +3 -42
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +8 -13
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/{json-store-30CFCbZZ.mjs → json-store-VJKEaoVX.mjs} +2 -3
- package/dist/{json-store-30CFCbZZ.mjs.map → json-store-VJKEaoVX.mjs.map} +1 -1
- package/dist/media/index.d.mts +0 -12
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +7 -13
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +64 -44
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +138 -11
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +26 -6
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +92 -5
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/{plan-BLKikoOG.mjs → plan-B9USuc17.mjs} +3 -4
- package/dist/{plan-BLKikoOG.mjs.map → plan-B9USuc17.mjs.map} +1 -1
- package/dist/{plan-Hq8Pb0Uw.d.mts → plan-CH3Wt5ZV.d.mts} +8 -30
- package/dist/plan-CH3Wt5ZV.d.mts.map +1 -0
- package/dist/plan-CddqsmJJ.d.mts +82 -0
- package/dist/plan-CddqsmJJ.d.mts.map +1 -0
- package/dist/plan-DpHv6NO8.mjs +93 -0
- package/dist/plan-DpHv6NO8.mjs.map +1 -0
- package/dist/{plan-format-A18SvJ7C.mjs → plan-format-CIz6Feg2.mjs} +2 -3
- package/dist/{plan-format-A18SvJ7C.mjs.map → plan-format-CIz6Feg2.mjs.map} +1 -1
- package/dist/{platform-0nlOIy8h.mjs → platform-Cb_RtsF1.mjs} +3 -5
- package/dist/{platform-0nlOIy8h.mjs.map → platform-Cb_RtsF1.mjs.map} +1 -1
- package/dist/reviews/index.d.mts +4 -120
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/reviews/index.mjs +6 -99
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/reviews/select.d.mts +2 -0
- package/dist/reviews/select.mjs +3 -0
- package/dist/schema/index.d.mts +0 -12
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/schema/index.mjs +6 -12
- package/dist/schema/index.mjs.map +1 -1
- package/dist/security/index.d.mts +0 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/security/index.mjs +1 -2
- package/dist/{token-DmmoQ7um.d.mts → token-CHR1O4w5.d.mts} +3 -3
- package/dist/token-CHR1O4w5.d.mts.map +1 -0
- package/dist/{token-DkKC5m23.mjs → token-CJSl9eIS.mjs} +33 -14
- package/dist/token-CJSl9eIS.mjs.map +1 -0
- package/dist/tracking/index.d.mts +1 -9
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +3 -4
- package/dist/turnstile/index.d.mts +105 -0
- package/dist/turnstile/index.d.mts.map +1 -0
- package/dist/turnstile/index.mjs +281 -0
- package/dist/turnstile/index.mjs.map +1 -0
- package/dist/{types-7ELci8RQ.d.mts → types-oac2P5or.d.mts} +3 -23
- package/dist/types-oac2P5or.d.mts.map +1 -0
- package/dist/url/index.d.mts +18 -0
- package/dist/url/index.d.mts.map +1 -0
- package/dist/url/index.mjs +34 -0
- package/dist/url/index.mjs.map +1 -0
- package/package.json +43 -2
- package/dist/adc-BoEoogyo.d.mts.map +0 -1
- package/dist/cloudflare-BiK61Oq4.d.mts +0 -27
- package/dist/cloudflare-BiK61Oq4.d.mts.map +0 -1
- package/dist/cloudflare-CD8vwrgj.mjs +0 -92
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/plan-Hq8Pb0Uw.d.mts.map +0 -1
- package/dist/token-DkKC5m23.mjs.map +0 -1
- package/dist/token-DmmoQ7um.d.mts.map +0 -1
- package/dist/types-7ELci8RQ.d.mts.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n current: SitemapEntry[];\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The foreign-sitemap check.\n *\n * A 404 fails loudly; a sitemap that is well-formed and *someone else's* does\n * not. A downstream site's sitemap route collided with its CMS's, so the URL\n * returned 200 with a valid document listing pages that were not theirs, and\n * the run reported a healthy cold submission. Nothing downstream can catch\n * this — the URLs parse, the diff works, the submission succeeds.\n *\n * Compared against the resolved environment origin rather than the canonical,\n * for the same reason `toIndexingConfig` derives the origin that way: on a\n * staging run the canonical IS the foreign host.\n */\n if (config.canonical && urls.length) {\n const own = hostOf(`${trimSlash(config.canonical)}/`);\n const foreign = [...new Set(urls.map(hostOf).filter((h): h is string => !!h && h !== own))];\n if (foreign.length)\n notes.push(\n `sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ` +\n `${foreign.join(', ')} rather than ${own} — check that ${sitemapUrl} is this site's own ` +\n `sitemap and not another route answering that path.`,\n );\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport type { SitemapEntry, Snapshot } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n return readVersioned<Snapshot, 'entries'>(path, 'entries');\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n writeVersioned(path, snapshot);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport {\n encodeSite,\n errorDetail,\n googleAccessToken,\n googleHeaders,\n SCOPES,\n type GoogleAuthLike,\n type ServiceAccount,\n} from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n auth: GoogleAuthLike,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const message = await errorDetail(res, (status) =>\n status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : undefined,\n );\n return { ok: false, status: res.status, message };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n auth: GoogleAuthLike,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n current: SitemapEntry[];\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The foreign-sitemap check.\n *\n * A 404 fails loudly; a sitemap that is well-formed and *someone else's* does\n * not. A downstream site's sitemap route collided with its CMS's, so the URL\n * returned 200 with a valid document listing pages that were not theirs, and\n * the run reported a healthy cold submission. Nothing downstream can catch\n * this — the URLs parse, the diff works, the submission succeeds.\n *\n * Compared against the resolved environment origin rather than the canonical,\n * for the same reason `toIndexingConfig` derives the origin that way: on a\n * staging run the canonical IS the foreign host.\n */\n if (config.canonical && urls.length) {\n const own = hostOf(`${trimSlash(config.canonical)}/`);\n const foreign = [...new Set(urls.map(hostOf).filter((h): h is string => !!h && h !== own))];\n if (foreign.length)\n notes.push(\n `sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ` +\n `${foreign.join(', ')} rather than ${own} — check that ${sitemapUrl} is this site's own ` +\n `sitemap and not another route answering that path.`,\n );\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport type { SitemapEntry, Snapshot } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n return readVersioned<Snapshot, 'entries'>(path, 'entries');\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n writeVersioned(path, snapshot);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport {\n encodeSite,\n errorDetail,\n googleAccessToken,\n googleHeaders,\n SCOPES,\n type GoogleAuthLike,\n type ServiceAccount,\n} from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n auth: GoogleAuthLike,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const message = await errorDetail(res, (status) =>\n status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : undefined,\n );\n return { ok: false, status: res.status, message };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n auth: GoogleAuthLike,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/json-store.ts
|
|
5
5
|
/**
|
|
6
6
|
* Read/write helpers shared by every `.vitops/*.json` cache
|
|
7
7
|
* (`indexing/snapshot.ts`, `reviews/cache.ts`, `media/manifest.ts`).
|
|
@@ -33,6 +33,5 @@ function writeVersioned(path, value) {
|
|
|
33
33
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//#endregion
|
|
37
36
|
export { writeVersioned as n, readVersioned as t };
|
|
38
|
-
//# sourceMappingURL=json-store-
|
|
37
|
+
//# sourceMappingURL=json-store-VJKEaoVX.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json-store-
|
|
1
|
+
{"version":3,"file":"json-store-VJKEaoVX.mjs","names":[],"sources":["../src/json-store.ts"],"sourcesContent":["/**\n * Read/write helpers shared by every `.vitops/*.json` cache\n * (`indexing/snapshot.ts`, `reviews/cache.ts`, `media/manifest.ts`).\n *\n * All three follow the same rule: a corrupt or wrong-version file reads as\n * absent, never as an empty-but-valid record — the alternative is\n * indistinguishable from \"there is genuinely nothing here\" and silently ships\n * an empty result. Each module keeps its own `toX` sorter (they differ in\n * which field holds the record and how its entries are ordered); only the\n * read/write mechanics are shared here.\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\n\n/** A cache file's shape: a version tag plus one record field, keyed by `key`. */\ntype Versioned<Key extends string> = { version: 1 } & Record<Key, Record<string, unknown> | null>;\n\n/**\n * Read a versioned cache file, or `undefined` when there isn't a usable one\n * (missing, unparsable, wrong version, or `key`'s field isn't an object).\n */\nexport function readVersioned<T extends Versioned<Key>, Key extends string>(\n path: string,\n key: Key,\n): T | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as T;\n if (raw?.version !== 1 || typeof raw[key] !== 'object' || raw[key] === null) return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Write a cache file, creating its directory. Call only once the value is final. */\nexport function writeVersioned(path: string, value: unknown): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(value, null, 2)}\\n`);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,cACd,MACA,KACe;CACf,IAAI,CAAC,WAAW,IAAI,GAAG,OAAO;CAC9B,IAAI;EACF,MAAM,MAAM,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC;EACjD,IAAI,KAAK,YAAY,KAAK,OAAO,IAAI,SAAS,YAAY,IAAI,SAAS,MAAM,OAAO;EACpF,OAAO;CACT,QAAQ;EACN;CACF;AACF;;AAGA,SAAgB,eAAe,MAAc,OAAsB;CACjE,UAAU,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;CAC5C,cAAc,MAAM,GAAG,KAAK,UAAU,OAAO,MAAM,CAAC,EAAE,GAAG;AAC3D"}
|
package/dist/media/index.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/media/types.d.ts
|
|
2
1
|
/**
|
|
3
2
|
* The media module's own option surface.
|
|
4
3
|
*
|
|
@@ -113,8 +112,6 @@ interface MediaManifest {
|
|
|
113
112
|
version: 1;
|
|
114
113
|
entries: Record<string, MediaManifestEntry>;
|
|
115
114
|
}
|
|
116
|
-
//#endregion
|
|
117
|
-
//#region src/media/discover.d.ts
|
|
118
115
|
/** Container formats worth accepting as input. */
|
|
119
116
|
export declare const VIDEO_EXT: ReadonlySet<string>;
|
|
120
117
|
/** Directories that never hold hand-managed source assets. */
|
|
@@ -128,8 +125,6 @@ export declare function hashFile(path: string): Promise<string>;
|
|
|
128
125
|
* the same order on every machine, regardless of what the filesystem hands back.
|
|
129
126
|
*/
|
|
130
127
|
export declare function discoverVideos(dir: string): Promise<MediaSource[]>;
|
|
131
|
-
//#endregion
|
|
132
|
-
//#region src/media/plan.d.ts
|
|
133
128
|
/** Defaults, resolved once so the settings hash is computed over real values. */
|
|
134
129
|
interface ResolvedMediaConfig {
|
|
135
130
|
maxWidth: number;
|
|
@@ -183,8 +178,6 @@ export declare function planMedia(input: MediaPlanInput): MediaPlan;
|
|
|
183
178
|
export declare const pendingJobs: (plan: MediaPlan) => MediaJob[];
|
|
184
179
|
/** Human-readable account of a plan, for `--dry` and for the run summary. */
|
|
185
180
|
export declare function formatPlan(plan: MediaPlan): string;
|
|
186
|
-
//#endregion
|
|
187
|
-
//#region src/media/ffmpeg.d.ts
|
|
188
181
|
/**
|
|
189
182
|
* Fail early and clearly when ffmpeg isn't installed.
|
|
190
183
|
*
|
|
@@ -215,8 +208,6 @@ export declare function encodeWebm(input: string, output: string, config: Resolv
|
|
|
215
208
|
export declare function encodeMp4(input: string, output: string, config: ResolvedMediaConfig): Promise<void>;
|
|
216
209
|
/** The poster frame. `-ss` before `-i` so the seek is a fast one. */
|
|
217
210
|
export declare function extractPoster(input: string, output: string, config: ResolvedMediaConfig): Promise<void>;
|
|
218
|
-
//#endregion
|
|
219
|
-
//#region src/media/manifest.d.ts
|
|
220
211
|
/** Default location, relative to the consumer's project root. */
|
|
221
212
|
export declare const MEDIA_MANIFEST_PATH = ".vitops/media-manifest.json";
|
|
222
213
|
/**
|
|
@@ -238,8 +229,6 @@ export declare function toManifest(entries: Record<string, MediaManifestEntry>):
|
|
|
238
229
|
* result is a missing video that no rebuild ever fixes.
|
|
239
230
|
*/
|
|
240
231
|
export declare function writeManifest(path: string, manifest: MediaManifest): void;
|
|
241
|
-
//#endregion
|
|
242
|
-
//#region src/media/index.d.ts
|
|
243
232
|
export interface ProcessMediaOptions {
|
|
244
233
|
/** Directory of unprocessed sources. Walked recursively. */
|
|
245
234
|
raw: string;
|
|
@@ -270,6 +259,5 @@ export interface ProcessMediaResult {
|
|
|
270
259
|
* they call this same function.
|
|
271
260
|
*/
|
|
272
261
|
export declare function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult>;
|
|
273
|
-
//#endregion
|
|
274
262
|
export type { MediaConfig, MediaJob, MediaManifest, MediaManifestEntry, MediaOutput, MediaPlan, MediaPlanInput, MediaSource, OutputKind, OutputProbe, ResolvedMediaConfig };
|
|
275
263
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/media/types.ts","../../src/media/discover.ts","../../src/media/plan.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/media/types.ts","../../src/media/discover.ts","../../src/media/plan.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/index.ts"],"mappings":";;;;;;;;;KAUY;;qBAGC,iBAAiB;;qBAGjB,YAAY,OAAO;;;;;;;;;;qBAenB;;UAGI;;;;;;;EAOf;;;;;;;;;EASA;;;;;;;EAOA;;;;;EAKA;;;;;;;EAOA;;EAEA,UAAU;;;UAIK;EACf;;;;;;EAMA;;EAEA;;;UAIe;EACf,MAAM;;EAEN;;;UAIe;EACf,QAAQ;EACR,SAAS;;EAET;;EAEA;;;UAIe;EACf,MAAM;;EAEN;;;UAIe;;EAEf;EACA;;EAEA;;;;;;;UAQe;EACf;EACA,SAAS,eAAe;;;qBCjHb,WAAW;;qBAYX,eAAe;;wBAGZ,SAAS,eAAe;;;;;;;wBA4BlB,eAAe,cAAc,QAAQ;;UC9B1C;EACf;EACA;EACA;EACA;EACA;EACA,SAAS;;;;;;;;KASC,eAAe;UAEV;EACf,SAAS;EACT,SAAS;;EAET,WAAW;;EAEX,QAAQ;;EAER;;;wBAMc,cAAc,SAAQ,cAAmB;;;;;;;wBAiBzC,aAAa,QAAQ;;;;;;;;;;;;;;wBAuCrB,UAAU,OAAO,iBAAiB;;qBA0CrC,cAAe,MAAM,cAAY;;wBAG9B,WAAW,MAAM;;;;;;;;;;wBC1HjB,iBAAiB;;;;;;;;;wBAqEX,WACpB,eACA,gBACA,QAAQ,sBACP;;;;;;;;;wBA2DmB,UACpB,eACA,gBACA,QAAQ,sBACP;;wBAyBmB,cACpB,eACA,gBACA,QAAQ,sBACP;;qBC7LU;;;;;;;;;wBAUG,aAAa,eAAe;;wBAK5B,WAAW,SAAS,eAAe,sBAAsB;;;;;;;;wBAgBzD,cAAc,cAAc,UAAU;iBCrBrC;;EAEf;;EAEA;EACA,SAAS;;EAET;;EAEA;;EAEA;;EAEA,eAAe;;iBAGA;EACf,MAAM;;EAEN;;EAEA;;;;;;;;;wBAUoB,aAAa,SAAS,sBAAsB,QAAQ"}
|
package/dist/media/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as writeVersioned, t as readVersioned } from "../json-store-
|
|
1
|
+
import { n as writeVersioned, t as readVersioned } from "../json-store-VJKEaoVX.mjs";
|
|
2
2
|
import { createReadStream, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
@@ -6,7 +6,7 @@ import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// src/media/discover.ts
|
|
10
10
|
/**
|
|
11
11
|
* Finding the source videos and fingerprinting them.
|
|
12
12
|
*
|
|
@@ -67,8 +67,7 @@ async function discoverVideos(dir) {
|
|
|
67
67
|
})))).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
//#region src/media/ffmpeg.ts
|
|
70
|
+
// src/media/ffmpeg.ts
|
|
72
71
|
/**
|
|
73
72
|
* The encoder. Executes a job and decides nothing.
|
|
74
73
|
*
|
|
@@ -252,8 +251,7 @@ async function extractPoster(input, output, config) {
|
|
|
252
251
|
assertWritten(output, "poster extraction");
|
|
253
252
|
}
|
|
254
253
|
|
|
255
|
-
|
|
256
|
-
//#region src/media/manifest.ts
|
|
254
|
+
// src/media/manifest.ts
|
|
257
255
|
/** Default location, relative to the consumer's project root. */
|
|
258
256
|
const MEDIA_MANIFEST_PATH = ".vitops/media-manifest.json";
|
|
259
257
|
/**
|
|
@@ -293,8 +291,7 @@ function writeManifest(path, manifest) {
|
|
|
293
291
|
writeVersioned(path, manifest);
|
|
294
292
|
}
|
|
295
293
|
|
|
296
|
-
|
|
297
|
-
//#region src/media/types.ts
|
|
294
|
+
// src/media/types.ts
|
|
298
295
|
/** The default set: a modern codec, a universal fallback, and a poster frame. */
|
|
299
296
|
const DEFAULT_OUTPUTS = [
|
|
300
297
|
"webm",
|
|
@@ -318,8 +315,7 @@ const OUTPUT_EXT = {
|
|
|
318
315
|
*/
|
|
319
316
|
const ENCODER_VERSION = 1;
|
|
320
317
|
|
|
321
|
-
|
|
322
|
-
//#region src/media/plan.ts
|
|
318
|
+
// src/media/plan.ts
|
|
323
319
|
/**
|
|
324
320
|
* Every decision `vitops media` makes, as one pure function.
|
|
325
321
|
*
|
|
@@ -433,8 +429,7 @@ function formatPlan(plan) {
|
|
|
433
429
|
return lines.join("\n");
|
|
434
430
|
}
|
|
435
431
|
|
|
436
|
-
|
|
437
|
-
//#region src/media/index.ts
|
|
432
|
+
// src/media/index.ts
|
|
438
433
|
/**
|
|
439
434
|
* `vitops media` — turning a directory of raw video into web-ready assets.
|
|
440
435
|
*
|
|
@@ -512,6 +507,5 @@ async function processMedia(options) {
|
|
|
512
507
|
};
|
|
513
508
|
}
|
|
514
509
|
|
|
515
|
-
//#endregion
|
|
516
510
|
export { DEFAULT_OUTPUTS, DISCOVER_SKIP, ENCODER_VERSION, MEDIA_MANIFEST_PATH, OUTPUT_EXT, VIDEO_EXT, discoverVideos, encodeMp4, encodeWebm, extractPoster, formatPlan, hashFile, pendingJobs, planMedia, processMedia, readManifest, requireFfmpeg, resolveConfig, settingsHash, toManifest, writeManifest };
|
|
517
511
|
//# sourceMappingURL=index.mjs.map
|
package/dist/media/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/media/discover.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/types.ts","../../src/media/plan.ts","../../src/media/index.ts"],"sourcesContent":["/**\n * Finding the source videos and fingerprinting them.\n *\n * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into\n * memory as a string. That is right for templates and catastrophic for a video —\n * the files this walks are routinely tens or hundreds of megabytes, which is also\n * why the hash is streamed rather than taken over a `readFileSync`.\n */\nimport { createHash } from 'node:crypto';\nimport { createReadStream, readdirSync } from 'node:fs';\nimport { extname, join, relative, sep } from 'node:path';\nimport type { MediaSource } from './types.ts';\n\n/** Container formats worth accepting as input. */\nexport const VIDEO_EXT: ReadonlySet<string> = new Set([\n '.mp4',\n '.mov',\n '.m4v',\n '.mkv',\n '.avi',\n '.webm',\n '.mpg',\n '.mpeg',\n]);\n\n/** Directories that never hold hand-managed source assets. */\nexport const DISCOVER_SKIP: ReadonlySet<string> = new Set(['node_modules', 'dist', '.git']);\n\n/** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */\nexport function hashFile(path: string): Promise<string> {\n return new Promise((resolve, reject) => {\n const hash = createHash('sha256');\n const stream = createReadStream(path);\n stream.on('error', reject);\n stream.on('data', (chunk) => hash.update(chunk));\n stream.on('end', () => resolve(hash.digest('hex').slice(0, 16)));\n });\n}\n\nfunction walk(dir: string, acc: string[]): void {\n for (const e of readdirSync(dir, { withFileTypes: true })) {\n if (e.name.startsWith('.')) continue;\n const p = join(dir, e.name);\n if (e.isDirectory()) {\n if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);\n } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) {\n acc.push(p);\n }\n }\n}\n\n/**\n * Every video under `dir`, with its content hash.\n *\n * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in\n * the same order on every machine, regardless of what the filesystem hands back.\n */\nexport async function discoverVideos(dir: string): Promise<MediaSource[]> {\n const paths: string[] = [];\n walk(dir, paths);\n const sources = await Promise.all(\n paths.map(async (path) => ({\n path,\n id: relative(dir, path).split(sep).join('/'),\n hash: await hashFile(path),\n })),\n );\n return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n","/**\n * The encoder. Executes a job and decides nothing.\n *\n * Every invocation goes through `execFile` with an argv array rather than a shell\n * string: a source path containing a space or a quote is ordinary, and the\n * shell-interpolated form these commands are usually written in breaks on both.\n */\nimport { execFile } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { promisify } from 'node:util';\nimport type { ResolvedMediaConfig } from './plan.ts';\n\nconst exec = promisify(execFile);\n\n/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */\nconst MAX_BUFFER = 16 * 1024 * 1024;\n\n/**\n * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has\n * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the\n * pairing VP9's encoding guide uses for comparable quality.\n */\nconst X264_CRF_RATIO = 0.72;\n\nlet probe: Promise<void> | undefined;\n\n/**\n * Fail early and clearly when ffmpeg isn't installed.\n *\n * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a\n * missing `oxipng` and warns once. That is right for a best-effort lossless crush,\n * where skipping costs a few kilobytes. It is wrong here: skipping produces no\n * output at all, and a build that carries on would ship the page referencing a\n * video that was never encoded.\n */\nexport function requireFfmpeg(): Promise<void> {\n probe ??= exec('ffmpeg', ['-version'], { maxBuffer: MAX_BUFFER }).then(\n () => undefined,\n () => {\n probe = undefined; // so a later call re-probes rather than caching a stale failure\n throw new Error(\n 'media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — ' +\n 'install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, ' +\n 'Windows: `winget install Gyan.FFmpeg`) and run this again.',\n );\n },\n );\n return probe;\n}\n\nasync function run(args: string[]): Promise<void> {\n try {\n await exec('ffmpeg', args, { maxBuffer: MAX_BUFFER });\n } catch (err) {\n const stderr = String((err as { stderr?: string }).stderr ?? '').trim();\n const tail = stderr.split('\\n').slice(-6).join('\\n');\n throw new Error(`media: ffmpeg failed${tail ? `\\n${tail}` : ''}`);\n }\n}\n\n/**\n * An encode that \"succeeds\" but writes nothing is the failure mode worth guarding:\n * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.\n * Without this the manifest would record the job as done and never retry it.\n */\nfunction assertWritten(file: string, what: string): void {\n if (!existsSync(file) || statSync(file).size === 0)\n throw new Error(\n `media: ${what} produced no output (${file}). Check --poster-time and the source.`,\n );\n}\n\n/** Scale filter, capping width and keeping the height even (yuv420p requires it). */\nfunction scaleArgs(maxWidth: number): string[] {\n if (maxWidth <= 0) return [];\n // ffmpeg parses the quotes itself, so the commas inside min() aren't read as a\n // filter separator. There is no shell here to strip them.\n return ['-vf', `scale='min(${maxWidth},iw)':-2`];\n}\n\n/** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */\nfunction bufsize(maxBitrate: string): string {\n const m = /^(\\d+(?:\\.\\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());\n if (!m)\n throw new Error(`media: maxBitrate \"${maxBitrate}\" is not a bitrate (try \"2M\" or \"800k\").`);\n return `${Number(m[1]) * 2}${m[2] ?? ''}`;\n}\n\nconst rateArgs = (maxBitrate: string | undefined): string[] =>\n maxBitrate ? ['-maxrate', maxBitrate, '-bufsize', bufsize(maxBitrate)] : [];\n\nconst audioArgs = (config: ResolvedMediaConfig, codec: string): string[] =>\n config.audio ? ['-c:a', codec, '-b:a', '128k'] : ['-an'];\n\nconst BASE = ['-y', '-nostdin', '-loglevel', 'error'];\n\n/**\n * VP9/WebM, two passes.\n *\n * The pass log goes into a fresh temp directory rather than the working directory.\n * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and\n * makes two concurrent encodes silently share one log — the second encode's rate\n * control then reads the first clip's statistics.\n */\nexport async function encodeWebm(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n const dir = mkdtempSync(join(tmpdir(), 'vitops-media-'));\n const passlog = join(dir, 'pass');\n const video = [\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libvpx-vp9',\n // -b:v 0 with -crf is VP9's constant-quality mode; with -maxrate it becomes\n // constrained quality. A fixed bitrate would be right at one resolution only.\n '-b:v',\n '0',\n '-crf',\n String(config.crf),\n '-row-mt',\n '1',\n ...rateArgs(config.maxBitrate),\n ];\n try {\n // Pass 1 only gathers statistics, so it never encodes audio and writes no file.\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n '-an',\n '-pass',\n '1',\n '-passlogfile',\n passlog,\n '-f',\n 'null',\n '-',\n ]);\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n ...audioArgs(config, 'libopus'),\n '-pass',\n '2',\n '-passlogfile',\n passlog,\n output,\n ]);\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n assertWritten(output, 'webm encode');\n}\n\n/**\n * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the\n * in-app webviews that still don't decode VP9.\n *\n * `+faststart` moves the moov atom to the front. Without it the browser has to\n * download the whole file before it can show frame one, which on a hero video\n * looks exactly like the optimisation didn't work.\n */\nexport async function encodeMp4(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libx264',\n '-preset',\n 'slow',\n '-crf',\n String(Math.round(config.crf * X264_CRF_RATIO)),\n // Required by Safari and by every hardware decoder worth naming.\n '-pix_fmt',\n 'yuv420p',\n '-movflags',\n '+faststart',\n ...rateArgs(config.maxBitrate),\n ...audioArgs(config, 'aac'),\n output,\n ]);\n assertWritten(output, 'mp4 encode');\n}\n\n/** The poster frame. `-ss` before `-i` so the seek is a fast one. */\nexport async function extractPoster(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n ...(config.posterTime > 0 ? ['-ss', String(config.posterTime)] : []),\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-frames:v',\n '1',\n '-q:v',\n '2',\n output,\n ]);\n assertWritten(output, 'poster extraction');\n}\n","/**\n * The cache — what makes a second run free.\n *\n * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout\n * checks in both the processed outputs and this file, so CI never needs ffmpeg\n * and the outputs only change when someone deliberately re-encodes. That is why\n * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce\n * a byte-identical file, or every build shows up as a diff.\n */\nimport type { MediaManifest, MediaManifestEntry } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const MEDIA_MANIFEST_PATH = '.vitops/media-manifest.json';\n\n/**\n * Read the manifest, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, which re-encodes everything.\n * The opposite reading — treating it as an empty but valid manifest — is the same\n * shape of bug `readSnapshot` avoids, except here it would be silent: nothing\n * would encode, and the build would reference outputs that were never written.\n */\nexport function readManifest(path: string): MediaManifest | undefined {\n return readVersioned<MediaManifest, 'entries'>(path, 'entries');\n}\n\n/** Build a manifest with its keys and output lists in a stable order. */\nexport function toManifest(entries: Record<string, MediaManifestEntry>): MediaManifest {\n const sorted: Record<string, MediaManifestEntry> = {};\n for (const key of Object.keys(entries).sort()) {\n const entry = entries[key] as MediaManifestEntry;\n sorted[key] = { ...entry, outputs: [...entry.outputs].sort() };\n }\n return { version: 1, entries: sorted };\n}\n\n/**\n * Write the manifest, creating its directory.\n *\n * Record a job only once its outputs are on disk. Writing eagerly would mark a\n * source as encoded that isn't, and because the next run trusts this file, the\n * result is a missing video that no rebuild ever fixes.\n */\nexport function writeManifest(path: string, manifest: MediaManifest): void {\n writeVersioned(path, manifest);\n}\n","/**\n * The media module's own option surface.\n *\n * Everything here describes *how to encode*, never *what the site is* — which is\n * why there is no `Config` block behind it. `legal`, `icons` and `indexing`\n * are anchored to a site config because what they emit describes the site; an\n * encoder setting describes a build step.\n */\n\n/** What a source video is turned into. */\nexport type OutputKind = 'webm' | 'mp4' | 'poster';\n\n/** The default set: a modern codec, a universal fallback, and a poster frame. */\nexport const DEFAULT_OUTPUTS: OutputKind[] = ['webm', 'mp4', 'poster'];\n\n/** File extension per output kind. */\nexport const OUTPUT_EXT: Record<OutputKind, string> = {\n webm: '.webm',\n mp4: '.mp4',\n poster: '.jpg',\n};\n\n/**\n * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the\n * output bytes. It is part of the settings hash, so bumping it re-encodes every\n * source on the next run.\n *\n * Bump it deliberately and rarely. Consumers commit their processed outputs (see\n * the README), so every bump is a large binary diff in someone's repository —\n * that cost is worth paying for a real encoder fix and not for a tidy-up.\n */\nexport const ENCODER_VERSION = 1;\n\n/** How to encode. Every field here feeds the settings hash. */\nexport interface MediaConfig {\n /**\n * Cap the output width in pixels, preserving aspect ratio. Default 1920.\n *\n * A source larger than its display size is the usual reason a hero video is\n * enormous, so this is on by default rather than opt-in. `0` disables scaling.\n */\n maxWidth?: number | undefined;\n /**\n * Constant-quality level, on **VP9's CRF scale (0–63, lower is better)**.\n * Default 32, which is the range VP9's own guidance recommends for 1080p.\n *\n * H.264 uses a different scale (0–51), so the MP4 fallback is encoded at\n * `round(crf * 0.72)` — 23 at the default, x264's own default. One knob, because\n * two would drift apart and nobody would know which one mattered.\n */\n crf?: number | undefined;\n /**\n * Optional ceiling, e.g. `'2M'`. Absent by default: a bitrate cap makes the\n * encode constrained-quality rather than constant-quality, which is right when\n * you are protecting a data budget and wrong as a default, because the correct\n * cap depends on the resolution you are capping.\n */\n maxBitrate?: string | undefined;\n /**\n * Keep the audio track. Default false — the common case is a muted autoplay\n * loop, where the audio stream is bytes nobody will ever hear.\n */\n audio?: boolean | undefined;\n /**\n * Timestamp (seconds) the poster frame is taken from. Default 0.\n *\n * Frame 0 is often black on a clip that fades in, which is worth knowing before\n * concluding the poster is broken.\n */\n posterTime?: number | undefined;\n /** Which outputs to produce. Default `DEFAULT_OUTPUTS`. */\n outputs?: OutputKind[] | undefined;\n}\n\n/** A discovered source video. */\nexport interface MediaSource {\n path: string;\n /**\n * Path relative to the raw directory, POSIX-separated — the manifest key.\n * Relative so a manifest stays valid when the project moves or is cloned\n * somewhere else, which is the whole point of committing it.\n */\n id: string;\n /** sha256 of the file contents, truncated. */\n hash: string;\n}\n\n/** One file an encode will write. */\nexport interface MediaOutput {\n kind: OutputKind;\n /** Path relative to the output directory, POSIX-separated. */\n file: string;\n}\n\n/** One source and everything decided about it. */\nexport interface MediaJob {\n source: MediaSource;\n outputs: MediaOutput[];\n /** Hash of the settings these outputs were produced under. */\n settings: string;\n /** Why this job needs no work. Present iff it is being skipped. */\n skip?: string | undefined;\n}\n\n/** The complete account of a run, decided before any of it happens. */\nexport interface MediaPlan {\n jobs: MediaJob[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\n/** One manifest record: what these outputs were made from, and under what settings. */\nexport interface MediaManifestEntry {\n /** `MediaSource.hash` at the time of encoding. */\n source: string;\n settings: string;\n /** Output paths relative to the output directory. */\n outputs: string[];\n}\n\n/**\n * The cache. A committed artifact, so it carries no timestamp and its keys are\n * sorted — a rebuild of unchanged sources must produce a byte-identical file, or\n * it shows up as noise in every diff.\n */\nexport interface MediaManifest {\n version: 1;\n entries: Record<string, MediaManifestEntry>;\n}\n","/**\n * Every decision `vitops media` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a\n * source needs re-encoding — the only thing here that can be wrong in a way that\n * costs minutes or ships a stale video — is decided from data passed in, and\n * asserted in `plan.test.ts` on a machine with no ffmpeg installed.\n *\n * That split is the same one `indexing/plan.ts` makes against its I/O modules, and\n * for the same reason: `--dry` should be a complete account of a run rather than\n * an approximation of one.\n */\nimport { createHash } from 'node:crypto';\nimport {\n DEFAULT_OUTPUTS,\n ENCODER_VERSION,\n type MediaConfig,\n type MediaJob,\n type MediaManifest,\n type MediaOutput,\n type MediaPlan,\n type MediaSource,\n OUTPUT_EXT,\n type OutputKind,\n} from './types.ts';\n\n/** Defaults, resolved once so the settings hash is computed over real values. */\nexport interface ResolvedMediaConfig {\n maxWidth: number;\n crf: number;\n maxBitrate: string | undefined;\n audio: boolean;\n posterTime: number;\n outputs: OutputKind[];\n}\n\n/**\n * Injected existence probe — `plan.ts` touches no filesystem itself.\n *\n * Takes a path relative to the output directory. The same injection shape as\n * `collectEntries(source, read)` in `indexing/sitemap.ts`.\n */\nexport type OutputProbe = (relativePath: string) => boolean;\n\nexport interface MediaPlanInput {\n sources: MediaSource[];\n config?: MediaConfig | undefined;\n /** The previous run's manifest, if there is a usable one. */\n previous?: MediaManifest | undefined;\n /** Whether an output file is already on disk. Defaults to \"nothing exists\". */\n probe?: OutputProbe | undefined;\n /** `--force`: re-encode regardless of the cache. */\n force?: boolean | undefined;\n}\n\nconst sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16);\n\n/** Fill in defaults. Exported because the encoder and the hash must agree on them. */\nexport function resolveConfig(config: MediaConfig = {}): ResolvedMediaConfig {\n return {\n maxWidth: config.maxWidth ?? 1920,\n crf: config.crf ?? 32,\n maxBitrate: config.maxBitrate,\n audio: config.audio ?? false,\n posterTime: config.posterTime ?? 0,\n outputs: config.outputs ?? DEFAULT_OUTPUTS,\n };\n}\n\n/**\n * Hash the settings that decide the output bytes.\n *\n * Order-independent in the one place it could bite (`outputs` is sorted), so\n * re-ordering the list in a config doesn't re-encode a whole library.\n */\nexport function settingsHash(config: ResolvedMediaConfig): string {\n return sha(\n JSON.stringify({\n v: ENCODER_VERSION,\n maxWidth: config.maxWidth,\n crf: config.crf,\n maxBitrate: config.maxBitrate ?? null,\n audio: config.audio,\n posterTime: config.posterTime,\n outputs: [...config.outputs].sort(),\n }),\n );\n}\n\n/** `a/b/clip.mp4` → `a/b/clip` */\nfunction stem(id: string): string {\n const dot = id.lastIndexOf('.');\n const slash = id.lastIndexOf('/');\n return dot > slash ? id.slice(0, dot) : id;\n}\n\nfunction outputsFor(source: MediaSource, config: ResolvedMediaConfig): MediaOutput[] {\n const base = stem(source.id);\n return config.outputs.map((kind) => ({ kind, file: `${base}${OUTPUT_EXT[kind]}` }));\n}\n\n/**\n * Decide what to encode.\n *\n * A job is skipped only when all three hold: the source contents hash the same as\n * last time, the settings hash the same, and every declared output is still on\n * disk. Anything else re-encodes — including an absent or unreadable manifest,\n * which reads as \"encode everything\" and never as \"everything is done\".\n *\n * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both\n * want `hero.webm`). That is a layout mistake rather than a runtime condition, and\n * the failure it would otherwise cause — two sources overwriting each other every\n * run, each invalidating the other's cache entry — is invisible from the outside.\n */\nexport function planMedia(input: MediaPlanInput): MediaPlan {\n const config = resolveConfig(input.config);\n const settings = settingsHash(config);\n const probe = input.probe ?? (() => false);\n const notes: string[] = [];\n\n const jobs: MediaJob[] = [];\n const claimed = new Map<string, string>();\n\n for (const source of input.sources) {\n const outputs = outputsFor(source, config);\n for (const out of outputs) {\n const owner = claimed.get(out.file);\n if (owner !== undefined && owner !== source.id)\n throw new Error(\n `media: \"${source.id}\" and \"${owner}\" both produce \"${out.file}\". ` +\n 'Two sources with the same name in one directory overwrite each other — rename one.',\n );\n claimed.set(out.file, source.id);\n }\n\n const prev = input.previous?.entries[source.id];\n const cached =\n !input.force &&\n prev !== undefined &&\n prev.source === source.hash &&\n prev.settings === settings &&\n outputs.every((o) => probe(o.file));\n\n jobs.push({ source, outputs, settings, ...(cached ? { skip: 'unchanged' } : {}) });\n }\n\n const pending = jobs.filter((j) => !j.skip).length;\n if (input.sources.length === 0) notes.push('no video files found');\n else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);\n else if (!input.previous && pending > 0)\n notes.push('no manifest — encoding everything (a first run, or a cache that was not restored)');\n\n return { jobs, notes };\n}\n\n/** The jobs that will actually run. */\nexport const pendingJobs = (plan: MediaPlan): MediaJob[] => plan.jobs.filter((j) => !j.skip);\n\n/** Human-readable account of a plan, for `--dry` and for the run summary. */\nexport function formatPlan(plan: MediaPlan): string {\n const lines: string[] = [];\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n\n lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? '' : 's'}`);\n lines.push(` encode: ${pending.length}`);\n lines.push(` skipped: ${skipped} (unchanged)`);\n\n for (const job of pending)\n lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(', ')}`);\n\n for (const note of plan.notes) lines.push(` ! ${note}`);\n return lines.join('\\n');\n}\n","/**\n * `vitops media` — turning a directory of raw video into web-ready assets.\n *\n * A separate subpath (`@getvitops/utils/media`) rather than part of the package\n * index, matching `./favicon` and `./indexing`: this module shells out to an\n * external encoder, and a consumer importing the content helpers shouldn't carry\n * it.\n *\n * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`\n * decides everything and touches nothing; `ffmpeg.ts` executes and decides\n * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is\n * what lets `--dry` be a complete account of a run, and what lets the cache — the\n * part with real consequences, since getting it wrong means either minutes of\n * needless encoding or a stale video shipped — be tested with no ffmpeg installed.\n */\nimport { existsSync, mkdirSync, statSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { discoverVideos } from './discover.ts';\nimport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nimport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nimport { pendingJobs, planMedia, resolveConfig } from './plan.ts';\nimport type { MediaConfig, MediaManifestEntry, MediaPlan } from './types.ts';\n\nexport interface ProcessMediaOptions {\n /** Directory of unprocessed sources. Walked recursively. */\n raw: string;\n /** Where the encoded outputs go. Structure under `raw` is preserved. */\n out: string;\n config?: MediaConfig | undefined;\n /** Cache location. Default `.vitops/media-manifest.json`. */\n manifest?: string | undefined;\n /** Re-encode regardless of the cache. */\n force?: boolean | undefined;\n /** Decide everything, run nothing. */\n dry?: boolean | undefined;\n /** Called before each source is encoded — these take minutes, so say so. */\n onProgress?: ((message: string) => void) | undefined;\n}\n\nexport interface ProcessMediaResult {\n plan: MediaPlan;\n /** Outputs written this run, relative to `out`. */\n written: string[];\n /** Sources that needed no work. */\n skipped: number;\n}\n\n/**\n * Encode everything under `raw` that has changed since the last run.\n *\n * Callers get the cache for free because it lives here rather than in either of\n * them: `vitops media` and the Astro integration are equally incremental, since\n * they call this same function.\n */\nexport async function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult> {\n const rawDir = resolve(options.raw);\n const outDir = resolve(options.out);\n const manifestPath = resolve(options.manifest ?? MEDIA_MANIFEST_PATH);\n\n if (!existsSync(rawDir) || !statSync(rawDir).isDirectory())\n throw new Error(`media: raw directory not found: ${rawDir}`);\n // Sharing one directory would make each run's outputs the next run's inputs —\n // re-encoding a re-encode, forever, losing quality every time.\n if (rawDir === outDir)\n throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);\n\n const config = resolveConfig(options.config);\n const sources = await discoverVideos(rawDir);\n const previous = readManifest(manifestPath);\n const plan = planMedia({\n sources,\n ...(options.config ? { config: options.config } : {}),\n ...(previous ? { previous } : {}),\n ...(options.force ? { force: options.force } : {}),\n probe: (file) => existsSync(join(outDir, file)),\n });\n\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n if (options.dry) return { plan, written: [], skipped };\n\n // Carry forward what is still valid, so the manifest on disk is a truthful\n // account at every moment — including after a crash halfway through.\n const entries: Record<string, MediaManifestEntry> = {};\n for (const job of plan.jobs) {\n const prev = previous?.entries[job.source.id];\n if (job.skip && prev) entries[job.source.id] = prev;\n }\n\n if (pending.length > 0) await requireFfmpeg();\n\n const written: string[] = [];\n for (const job of pending) {\n options.onProgress?.(`encoding ${job.source.id}`);\n for (const out of job.outputs) {\n const target = join(outDir, out.file);\n mkdirSync(dirname(target), { recursive: true });\n if (out.kind === 'webm') await encodeWebm(job.source.path, target, config);\n else if (out.kind === 'mp4') await encodeMp4(job.source.path, target, config);\n else await extractPoster(job.source.path, target, config);\n written.push(out.file);\n }\n entries[job.source.id] = {\n source: job.source.hash,\n settings: job.settings,\n outputs: job.outputs.map((o) => o.file),\n };\n writeManifest(manifestPath, toManifest(entries));\n }\n\n // Also write when nothing encoded, which is what prunes entries for sources\n // that have since been deleted. Skipped when there is nothing to record either\n // way, so an empty raw directory doesn't create a manifest.\n if (pending.length === 0 && (previous || Object.keys(entries).length > 0))\n writeManifest(manifestPath, toManifest(entries));\n\n return { plan, written, skipped };\n}\n\nexport { DISCOVER_SKIP, VIDEO_EXT, discoverVideos, hashFile } from './discover.ts';\nexport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nexport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nexport { formatPlan, pendingJobs, planMedia, resolveConfig, settingsHash } from './plan.ts';\nexport type { MediaPlanInput, OutputProbe, ResolvedMediaConfig } from './plan.ts';\nexport { DEFAULT_OUTPUTS, ENCODER_VERSION, OUTPUT_EXT } from './types.ts';\nexport type {\n MediaConfig,\n MediaJob,\n MediaManifest,\n MediaManifestEntry,\n MediaOutput,\n MediaPlan,\n MediaSource,\n OutputKind,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/media/discover.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/types.ts","../../src/media/plan.ts","../../src/media/index.ts"],"sourcesContent":["/**\n * Finding the source videos and fingerprinting them.\n *\n * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into\n * memory as a string. That is right for templates and catastrophic for a video —\n * the files this walks are routinely tens or hundreds of megabytes, which is also\n * why the hash is streamed rather than taken over a `readFileSync`.\n */\nimport { createHash } from 'node:crypto';\nimport { createReadStream, readdirSync } from 'node:fs';\nimport { extname, join, relative, sep } from 'node:path';\nimport type { MediaSource } from './types.ts';\n\n/** Container formats worth accepting as input. */\nexport const VIDEO_EXT: ReadonlySet<string> = new Set([\n '.mp4',\n '.mov',\n '.m4v',\n '.mkv',\n '.avi',\n '.webm',\n '.mpg',\n '.mpeg',\n]);\n\n/** Directories that never hold hand-managed source assets. */\nexport const DISCOVER_SKIP: ReadonlySet<string> = new Set(['node_modules', 'dist', '.git']);\n\n/** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */\nexport function hashFile(path: string): Promise<string> {\n return new Promise((resolve, reject) => {\n const hash = createHash('sha256');\n const stream = createReadStream(path);\n stream.on('error', reject);\n stream.on('data', (chunk) => hash.update(chunk));\n stream.on('end', () => resolve(hash.digest('hex').slice(0, 16)));\n });\n}\n\nfunction walk(dir: string, acc: string[]): void {\n for (const e of readdirSync(dir, { withFileTypes: true })) {\n if (e.name.startsWith('.')) continue;\n const p = join(dir, e.name);\n if (e.isDirectory()) {\n if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);\n } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) {\n acc.push(p);\n }\n }\n}\n\n/**\n * Every video under `dir`, with its content hash.\n *\n * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in\n * the same order on every machine, regardless of what the filesystem hands back.\n */\nexport async function discoverVideos(dir: string): Promise<MediaSource[]> {\n const paths: string[] = [];\n walk(dir, paths);\n const sources = await Promise.all(\n paths.map(async (path) => ({\n path,\n id: relative(dir, path).split(sep).join('/'),\n hash: await hashFile(path),\n })),\n );\n return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n","/**\n * The encoder. Executes a job and decides nothing.\n *\n * Every invocation goes through `execFile` with an argv array rather than a shell\n * string: a source path containing a space or a quote is ordinary, and the\n * shell-interpolated form these commands are usually written in breaks on both.\n */\nimport { execFile } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { promisify } from 'node:util';\nimport type { ResolvedMediaConfig } from './plan.ts';\n\nconst exec = promisify(execFile);\n\n/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */\nconst MAX_BUFFER = 16 * 1024 * 1024;\n\n/**\n * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has\n * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the\n * pairing VP9's encoding guide uses for comparable quality.\n */\nconst X264_CRF_RATIO = 0.72;\n\nlet probe: Promise<void> | undefined;\n\n/**\n * Fail early and clearly when ffmpeg isn't installed.\n *\n * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a\n * missing `oxipng` and warns once. That is right for a best-effort lossless crush,\n * where skipping costs a few kilobytes. It is wrong here: skipping produces no\n * output at all, and a build that carries on would ship the page referencing a\n * video that was never encoded.\n */\nexport function requireFfmpeg(): Promise<void> {\n probe ??= exec('ffmpeg', ['-version'], { maxBuffer: MAX_BUFFER }).then(\n () => undefined,\n () => {\n probe = undefined; // so a later call re-probes rather than caching a stale failure\n throw new Error(\n 'media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — ' +\n 'install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, ' +\n 'Windows: `winget install Gyan.FFmpeg`) and run this again.',\n );\n },\n );\n return probe;\n}\n\nasync function run(args: string[]): Promise<void> {\n try {\n await exec('ffmpeg', args, { maxBuffer: MAX_BUFFER });\n } catch (err) {\n const stderr = String((err as { stderr?: string }).stderr ?? '').trim();\n const tail = stderr.split('\\n').slice(-6).join('\\n');\n throw new Error(`media: ffmpeg failed${tail ? `\\n${tail}` : ''}`);\n }\n}\n\n/**\n * An encode that \"succeeds\" but writes nothing is the failure mode worth guarding:\n * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.\n * Without this the manifest would record the job as done and never retry it.\n */\nfunction assertWritten(file: string, what: string): void {\n if (!existsSync(file) || statSync(file).size === 0)\n throw new Error(\n `media: ${what} produced no output (${file}). Check --poster-time and the source.`,\n );\n}\n\n/** Scale filter, capping width and keeping the height even (yuv420p requires it). */\nfunction scaleArgs(maxWidth: number): string[] {\n if (maxWidth <= 0) return [];\n // ffmpeg parses the quotes itself, so the commas inside min() aren't read as a\n // filter separator. There is no shell here to strip them.\n return ['-vf', `scale='min(${maxWidth},iw)':-2`];\n}\n\n/** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */\nfunction bufsize(maxBitrate: string): string {\n const m = /^(\\d+(?:\\.\\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());\n if (!m)\n throw new Error(`media: maxBitrate \"${maxBitrate}\" is not a bitrate (try \"2M\" or \"800k\").`);\n return `${Number(m[1]) * 2}${m[2] ?? ''}`;\n}\n\nconst rateArgs = (maxBitrate: string | undefined): string[] =>\n maxBitrate ? ['-maxrate', maxBitrate, '-bufsize', bufsize(maxBitrate)] : [];\n\nconst audioArgs = (config: ResolvedMediaConfig, codec: string): string[] =>\n config.audio ? ['-c:a', codec, '-b:a', '128k'] : ['-an'];\n\nconst BASE = ['-y', '-nostdin', '-loglevel', 'error'];\n\n/**\n * VP9/WebM, two passes.\n *\n * The pass log goes into a fresh temp directory rather than the working directory.\n * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and\n * makes two concurrent encodes silently share one log — the second encode's rate\n * control then reads the first clip's statistics.\n */\nexport async function encodeWebm(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n const dir = mkdtempSync(join(tmpdir(), 'vitops-media-'));\n const passlog = join(dir, 'pass');\n const video = [\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libvpx-vp9',\n // -b:v 0 with -crf is VP9's constant-quality mode; with -maxrate it becomes\n // constrained quality. A fixed bitrate would be right at one resolution only.\n '-b:v',\n '0',\n '-crf',\n String(config.crf),\n '-row-mt',\n '1',\n ...rateArgs(config.maxBitrate),\n ];\n try {\n // Pass 1 only gathers statistics, so it never encodes audio and writes no file.\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n '-an',\n '-pass',\n '1',\n '-passlogfile',\n passlog,\n '-f',\n 'null',\n '-',\n ]);\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n ...audioArgs(config, 'libopus'),\n '-pass',\n '2',\n '-passlogfile',\n passlog,\n output,\n ]);\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n assertWritten(output, 'webm encode');\n}\n\n/**\n * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the\n * in-app webviews that still don't decode VP9.\n *\n * `+faststart` moves the moov atom to the front. Without it the browser has to\n * download the whole file before it can show frame one, which on a hero video\n * looks exactly like the optimisation didn't work.\n */\nexport async function encodeMp4(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libx264',\n '-preset',\n 'slow',\n '-crf',\n String(Math.round(config.crf * X264_CRF_RATIO)),\n // Required by Safari and by every hardware decoder worth naming.\n '-pix_fmt',\n 'yuv420p',\n '-movflags',\n '+faststart',\n ...rateArgs(config.maxBitrate),\n ...audioArgs(config, 'aac'),\n output,\n ]);\n assertWritten(output, 'mp4 encode');\n}\n\n/** The poster frame. `-ss` before `-i` so the seek is a fast one. */\nexport async function extractPoster(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n ...(config.posterTime > 0 ? ['-ss', String(config.posterTime)] : []),\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-frames:v',\n '1',\n '-q:v',\n '2',\n output,\n ]);\n assertWritten(output, 'poster extraction');\n}\n","/**\n * The cache — what makes a second run free.\n *\n * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout\n * checks in both the processed outputs and this file, so CI never needs ffmpeg\n * and the outputs only change when someone deliberately re-encodes. That is why\n * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce\n * a byte-identical file, or every build shows up as a diff.\n */\nimport type { MediaManifest, MediaManifestEntry } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const MEDIA_MANIFEST_PATH = '.vitops/media-manifest.json';\n\n/**\n * Read the manifest, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, which re-encodes everything.\n * The opposite reading — treating it as an empty but valid manifest — is the same\n * shape of bug `readSnapshot` avoids, except here it would be silent: nothing\n * would encode, and the build would reference outputs that were never written.\n */\nexport function readManifest(path: string): MediaManifest | undefined {\n return readVersioned<MediaManifest, 'entries'>(path, 'entries');\n}\n\n/** Build a manifest with its keys and output lists in a stable order. */\nexport function toManifest(entries: Record<string, MediaManifestEntry>): MediaManifest {\n const sorted: Record<string, MediaManifestEntry> = {};\n for (const key of Object.keys(entries).sort()) {\n const entry = entries[key] as MediaManifestEntry;\n sorted[key] = { ...entry, outputs: [...entry.outputs].sort() };\n }\n return { version: 1, entries: sorted };\n}\n\n/**\n * Write the manifest, creating its directory.\n *\n * Record a job only once its outputs are on disk. Writing eagerly would mark a\n * source as encoded that isn't, and because the next run trusts this file, the\n * result is a missing video that no rebuild ever fixes.\n */\nexport function writeManifest(path: string, manifest: MediaManifest): void {\n writeVersioned(path, manifest);\n}\n","/**\n * The media module's own option surface.\n *\n * Everything here describes *how to encode*, never *what the site is* — which is\n * why there is no `Config` block behind it. `legal`, `icons` and `indexing`\n * are anchored to a site config because what they emit describes the site; an\n * encoder setting describes a build step.\n */\n\n/** What a source video is turned into. */\nexport type OutputKind = 'webm' | 'mp4' | 'poster';\n\n/** The default set: a modern codec, a universal fallback, and a poster frame. */\nexport const DEFAULT_OUTPUTS: OutputKind[] = ['webm', 'mp4', 'poster'];\n\n/** File extension per output kind. */\nexport const OUTPUT_EXT: Record<OutputKind, string> = {\n webm: '.webm',\n mp4: '.mp4',\n poster: '.jpg',\n};\n\n/**\n * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the\n * output bytes. It is part of the settings hash, so bumping it re-encodes every\n * source on the next run.\n *\n * Bump it deliberately and rarely. Consumers commit their processed outputs (see\n * the README), so every bump is a large binary diff in someone's repository —\n * that cost is worth paying for a real encoder fix and not for a tidy-up.\n */\nexport const ENCODER_VERSION = 1;\n\n/** How to encode. Every field here feeds the settings hash. */\nexport interface MediaConfig {\n /**\n * Cap the output width in pixels, preserving aspect ratio. Default 1920.\n *\n * A source larger than its display size is the usual reason a hero video is\n * enormous, so this is on by default rather than opt-in. `0` disables scaling.\n */\n maxWidth?: number | undefined;\n /**\n * Constant-quality level, on **VP9's CRF scale (0–63, lower is better)**.\n * Default 32, which is the range VP9's own guidance recommends for 1080p.\n *\n * H.264 uses a different scale (0–51), so the MP4 fallback is encoded at\n * `round(crf * 0.72)` — 23 at the default, x264's own default. One knob, because\n * two would drift apart and nobody would know which one mattered.\n */\n crf?: number | undefined;\n /**\n * Optional ceiling, e.g. `'2M'`. Absent by default: a bitrate cap makes the\n * encode constrained-quality rather than constant-quality, which is right when\n * you are protecting a data budget and wrong as a default, because the correct\n * cap depends on the resolution you are capping.\n */\n maxBitrate?: string | undefined;\n /**\n * Keep the audio track. Default false — the common case is a muted autoplay\n * loop, where the audio stream is bytes nobody will ever hear.\n */\n audio?: boolean | undefined;\n /**\n * Timestamp (seconds) the poster frame is taken from. Default 0.\n *\n * Frame 0 is often black on a clip that fades in, which is worth knowing before\n * concluding the poster is broken.\n */\n posterTime?: number | undefined;\n /** Which outputs to produce. Default `DEFAULT_OUTPUTS`. */\n outputs?: OutputKind[] | undefined;\n}\n\n/** A discovered source video. */\nexport interface MediaSource {\n path: string;\n /**\n * Path relative to the raw directory, POSIX-separated — the manifest key.\n * Relative so a manifest stays valid when the project moves or is cloned\n * somewhere else, which is the whole point of committing it.\n */\n id: string;\n /** sha256 of the file contents, truncated. */\n hash: string;\n}\n\n/** One file an encode will write. */\nexport interface MediaOutput {\n kind: OutputKind;\n /** Path relative to the output directory, POSIX-separated. */\n file: string;\n}\n\n/** One source and everything decided about it. */\nexport interface MediaJob {\n source: MediaSource;\n outputs: MediaOutput[];\n /** Hash of the settings these outputs were produced under. */\n settings: string;\n /** Why this job needs no work. Present iff it is being skipped. */\n skip?: string | undefined;\n}\n\n/** The complete account of a run, decided before any of it happens. */\nexport interface MediaPlan {\n jobs: MediaJob[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\n/** One manifest record: what these outputs were made from, and under what settings. */\nexport interface MediaManifestEntry {\n /** `MediaSource.hash` at the time of encoding. */\n source: string;\n settings: string;\n /** Output paths relative to the output directory. */\n outputs: string[];\n}\n\n/**\n * The cache. A committed artifact, so it carries no timestamp and its keys are\n * sorted — a rebuild of unchanged sources must produce a byte-identical file, or\n * it shows up as noise in every diff.\n */\nexport interface MediaManifest {\n version: 1;\n entries: Record<string, MediaManifestEntry>;\n}\n","/**\n * Every decision `vitops media` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a\n * source needs re-encoding — the only thing here that can be wrong in a way that\n * costs minutes or ships a stale video — is decided from data passed in, and\n * asserted in `plan.test.ts` on a machine with no ffmpeg installed.\n *\n * That split is the same one `indexing/plan.ts` makes against its I/O modules, and\n * for the same reason: `--dry` should be a complete account of a run rather than\n * an approximation of one.\n */\nimport { createHash } from 'node:crypto';\nimport {\n DEFAULT_OUTPUTS,\n ENCODER_VERSION,\n type MediaConfig,\n type MediaJob,\n type MediaManifest,\n type MediaOutput,\n type MediaPlan,\n type MediaSource,\n OUTPUT_EXT,\n type OutputKind,\n} from './types.ts';\n\n/** Defaults, resolved once so the settings hash is computed over real values. */\nexport interface ResolvedMediaConfig {\n maxWidth: number;\n crf: number;\n maxBitrate: string | undefined;\n audio: boolean;\n posterTime: number;\n outputs: OutputKind[];\n}\n\n/**\n * Injected existence probe — `plan.ts` touches no filesystem itself.\n *\n * Takes a path relative to the output directory. The same injection shape as\n * `collectEntries(source, read)` in `indexing/sitemap.ts`.\n */\nexport type OutputProbe = (relativePath: string) => boolean;\n\nexport interface MediaPlanInput {\n sources: MediaSource[];\n config?: MediaConfig | undefined;\n /** The previous run's manifest, if there is a usable one. */\n previous?: MediaManifest | undefined;\n /** Whether an output file is already on disk. Defaults to \"nothing exists\". */\n probe?: OutputProbe | undefined;\n /** `--force`: re-encode regardless of the cache. */\n force?: boolean | undefined;\n}\n\nconst sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16);\n\n/** Fill in defaults. Exported because the encoder and the hash must agree on them. */\nexport function resolveConfig(config: MediaConfig = {}): ResolvedMediaConfig {\n return {\n maxWidth: config.maxWidth ?? 1920,\n crf: config.crf ?? 32,\n maxBitrate: config.maxBitrate,\n audio: config.audio ?? false,\n posterTime: config.posterTime ?? 0,\n outputs: config.outputs ?? DEFAULT_OUTPUTS,\n };\n}\n\n/**\n * Hash the settings that decide the output bytes.\n *\n * Order-independent in the one place it could bite (`outputs` is sorted), so\n * re-ordering the list in a config doesn't re-encode a whole library.\n */\nexport function settingsHash(config: ResolvedMediaConfig): string {\n return sha(\n JSON.stringify({\n v: ENCODER_VERSION,\n maxWidth: config.maxWidth,\n crf: config.crf,\n maxBitrate: config.maxBitrate ?? null,\n audio: config.audio,\n posterTime: config.posterTime,\n outputs: [...config.outputs].sort(),\n }),\n );\n}\n\n/** `a/b/clip.mp4` → `a/b/clip` */\nfunction stem(id: string): string {\n const dot = id.lastIndexOf('.');\n const slash = id.lastIndexOf('/');\n return dot > slash ? id.slice(0, dot) : id;\n}\n\nfunction outputsFor(source: MediaSource, config: ResolvedMediaConfig): MediaOutput[] {\n const base = stem(source.id);\n return config.outputs.map((kind) => ({ kind, file: `${base}${OUTPUT_EXT[kind]}` }));\n}\n\n/**\n * Decide what to encode.\n *\n * A job is skipped only when all three hold: the source contents hash the same as\n * last time, the settings hash the same, and every declared output is still on\n * disk. Anything else re-encodes — including an absent or unreadable manifest,\n * which reads as \"encode everything\" and never as \"everything is done\".\n *\n * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both\n * want `hero.webm`). That is a layout mistake rather than a runtime condition, and\n * the failure it would otherwise cause — two sources overwriting each other every\n * run, each invalidating the other's cache entry — is invisible from the outside.\n */\nexport function planMedia(input: MediaPlanInput): MediaPlan {\n const config = resolveConfig(input.config);\n const settings = settingsHash(config);\n const probe = input.probe ?? (() => false);\n const notes: string[] = [];\n\n const jobs: MediaJob[] = [];\n const claimed = new Map<string, string>();\n\n for (const source of input.sources) {\n const outputs = outputsFor(source, config);\n for (const out of outputs) {\n const owner = claimed.get(out.file);\n if (owner !== undefined && owner !== source.id)\n throw new Error(\n `media: \"${source.id}\" and \"${owner}\" both produce \"${out.file}\". ` +\n 'Two sources with the same name in one directory overwrite each other — rename one.',\n );\n claimed.set(out.file, source.id);\n }\n\n const prev = input.previous?.entries[source.id];\n const cached =\n !input.force &&\n prev !== undefined &&\n prev.source === source.hash &&\n prev.settings === settings &&\n outputs.every((o) => probe(o.file));\n\n jobs.push({ source, outputs, settings, ...(cached ? { skip: 'unchanged' } : {}) });\n }\n\n const pending = jobs.filter((j) => !j.skip).length;\n if (input.sources.length === 0) notes.push('no video files found');\n else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);\n else if (!input.previous && pending > 0)\n notes.push('no manifest — encoding everything (a first run, or a cache that was not restored)');\n\n return { jobs, notes };\n}\n\n/** The jobs that will actually run. */\nexport const pendingJobs = (plan: MediaPlan): MediaJob[] => plan.jobs.filter((j) => !j.skip);\n\n/** Human-readable account of a plan, for `--dry` and for the run summary. */\nexport function formatPlan(plan: MediaPlan): string {\n const lines: string[] = [];\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n\n lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? '' : 's'}`);\n lines.push(` encode: ${pending.length}`);\n lines.push(` skipped: ${skipped} (unchanged)`);\n\n for (const job of pending)\n lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(', ')}`);\n\n for (const note of plan.notes) lines.push(` ! ${note}`);\n return lines.join('\\n');\n}\n","/**\n * `vitops media` — turning a directory of raw video into web-ready assets.\n *\n * A separate subpath (`@getvitops/utils/media`) rather than part of the package\n * index, matching `./favicon` and `./indexing`: this module shells out to an\n * external encoder, and a consumer importing the content helpers shouldn't carry\n * it.\n *\n * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`\n * decides everything and touches nothing; `ffmpeg.ts` executes and decides\n * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is\n * what lets `--dry` be a complete account of a run, and what lets the cache — the\n * part with real consequences, since getting it wrong means either minutes of\n * needless encoding or a stale video shipped — be tested with no ffmpeg installed.\n */\nimport { existsSync, mkdirSync, statSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { discoverVideos } from './discover.ts';\nimport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nimport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nimport { pendingJobs, planMedia, resolveConfig } from './plan.ts';\nimport type { MediaConfig, MediaManifestEntry, MediaPlan } from './types.ts';\n\nexport interface ProcessMediaOptions {\n /** Directory of unprocessed sources. Walked recursively. */\n raw: string;\n /** Where the encoded outputs go. Structure under `raw` is preserved. */\n out: string;\n config?: MediaConfig | undefined;\n /** Cache location. Default `.vitops/media-manifest.json`. */\n manifest?: string | undefined;\n /** Re-encode regardless of the cache. */\n force?: boolean | undefined;\n /** Decide everything, run nothing. */\n dry?: boolean | undefined;\n /** Called before each source is encoded — these take minutes, so say so. */\n onProgress?: ((message: string) => void) | undefined;\n}\n\nexport interface ProcessMediaResult {\n plan: MediaPlan;\n /** Outputs written this run, relative to `out`. */\n written: string[];\n /** Sources that needed no work. */\n skipped: number;\n}\n\n/**\n * Encode everything under `raw` that has changed since the last run.\n *\n * Callers get the cache for free because it lives here rather than in either of\n * them: `vitops media` and the Astro integration are equally incremental, since\n * they call this same function.\n */\nexport async function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult> {\n const rawDir = resolve(options.raw);\n const outDir = resolve(options.out);\n const manifestPath = resolve(options.manifest ?? MEDIA_MANIFEST_PATH);\n\n if (!existsSync(rawDir) || !statSync(rawDir).isDirectory())\n throw new Error(`media: raw directory not found: ${rawDir}`);\n // Sharing one directory would make each run's outputs the next run's inputs —\n // re-encoding a re-encode, forever, losing quality every time.\n if (rawDir === outDir)\n throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);\n\n const config = resolveConfig(options.config);\n const sources = await discoverVideos(rawDir);\n const previous = readManifest(manifestPath);\n const plan = planMedia({\n sources,\n ...(options.config ? { config: options.config } : {}),\n ...(previous ? { previous } : {}),\n ...(options.force ? { force: options.force } : {}),\n probe: (file) => existsSync(join(outDir, file)),\n });\n\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n if (options.dry) return { plan, written: [], skipped };\n\n // Carry forward what is still valid, so the manifest on disk is a truthful\n // account at every moment — including after a crash halfway through.\n const entries: Record<string, MediaManifestEntry> = {};\n for (const job of plan.jobs) {\n const prev = previous?.entries[job.source.id];\n if (job.skip && prev) entries[job.source.id] = prev;\n }\n\n if (pending.length > 0) await requireFfmpeg();\n\n const written: string[] = [];\n for (const job of pending) {\n options.onProgress?.(`encoding ${job.source.id}`);\n for (const out of job.outputs) {\n const target = join(outDir, out.file);\n mkdirSync(dirname(target), { recursive: true });\n if (out.kind === 'webm') await encodeWebm(job.source.path, target, config);\n else if (out.kind === 'mp4') await encodeMp4(job.source.path, target, config);\n else await extractPoster(job.source.path, target, config);\n written.push(out.file);\n }\n entries[job.source.id] = {\n source: job.source.hash,\n settings: job.settings,\n outputs: job.outputs.map((o) => o.file),\n };\n writeManifest(manifestPath, toManifest(entries));\n }\n\n // Also write when nothing encoded, which is what prunes entries for sources\n // that have since been deleted. Skipped when there is nothing to record either\n // way, so an empty raw directory doesn't create a manifest.\n if (pending.length === 0 && (previous || Object.keys(entries).length > 0))\n writeManifest(manifestPath, toManifest(entries));\n\n return { plan, written, skipped };\n}\n\nexport { DISCOVER_SKIP, VIDEO_EXT, discoverVideos, hashFile } from './discover.ts';\nexport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nexport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nexport { formatPlan, pendingJobs, planMedia, resolveConfig, settingsHash } from './plan.ts';\nexport type { MediaPlanInput, OutputProbe, ResolvedMediaConfig } from './plan.ts';\nexport { DEFAULT_OUTPUTS, ENCODER_VERSION, OUTPUT_EXT } from './types.ts';\nexport type {\n MediaConfig,\n MediaJob,\n MediaManifest,\n MediaManifestEntry,\n MediaOutput,\n MediaPlan,\n MediaSource,\n OutputKind,\n} from './types.ts';\n"],"mappings":"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"}
|