@getvitops/utils 7.0.0 → 8.0.1
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 +133 -13
- package/dist/adc-BFrzpMHq.d.mts +25 -0
- package/dist/adc-BFrzpMHq.d.mts.map +1 -0
- package/dist/adc-D6aw2qgq.mjs +45 -0
- package/dist/adc-D6aw2qgq.mjs.map +1 -0
- package/dist/ads/index.d.mts +0 -1
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +4 -13
- package/dist/ads/index.mjs.map +1 -1
- package/dist/{cloudflare-CD8vwrgj.mjs → cloudflare-CgAsXiqe.mjs} +24 -2
- package/dist/cloudflare-CgAsXiqe.mjs.map +1 -0
- package/dist/color/index.d.mts +0 -3
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +0 -3
- package/dist/color/index.mjs.map +1 -1
- package/dist/index.d.mts +5 -190
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -240
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +2 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +7 -69
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/json-store-30CFCbZZ.mjs +38 -0
- package/dist/json-store-30CFCbZZ.mjs.map +1 -0
- package/dist/media/index.d.mts +0 -2
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +4 -20
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +1 -2
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/onboarding/index.d.mts +1 -1
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +7 -17
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +154 -0
- package/dist/reviews/index.d.mts.map +1 -0
- package/dist/reviews/index.mjs +218 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/schema/index.d.mts +191 -0
- package/dist/schema/index.d.mts.map +1 -0
- package/dist/schema/index.mjs +241 -0
- package/dist/schema/index.mjs.map +1 -0
- package/dist/security/index.d.mts +48 -0
- package/dist/security/index.d.mts.map +1 -0
- package/dist/security/index.mjs +80 -0
- package/dist/security/index.mjs.map +1 -0
- package/dist/{token-BlShEM6J.d.mts → token-Bz_-igw-.d.mts} +3 -2
- package/dist/{token-BlShEM6J.d.mts.map → token-Bz_-igw-.d.mts.map} +1 -1
- package/dist/{token-CuyjDo6I.mjs → token-DkKC5m23.mjs} +16 -3
- package/dist/{token-CuyjDo6I.mjs.map → token-DkKC5m23.mjs.map} +1 -1
- package/dist/tracking/index.d.mts +1 -1
- package/dist/{types-C3lHaSrU.d.mts → types-CHHjzRrv.d.mts} +1 -2
- package/dist/types-CHHjzRrv.d.mts.map +1 -0
- package/package.json +25 -2
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/types-C3lHaSrU.d.mts.map +0 -1
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { MediaManifest, MediaManifestEntry } from './types.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 if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as MediaManifest;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\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 mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\\n`);\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 /** Absolute path on disk. */\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 hash at the time of encoding. */\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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"}
|
package/dist/notify/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as ConversionEvent } from "../types-
|
|
1
|
+
import { t as ConversionEvent } from "../types-CHHjzRrv.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/notify/types.d.ts
|
|
4
4
|
/**
|
|
@@ -77,7 +77,6 @@ interface NotificationPlan {
|
|
|
77
77
|
/** True when no channel is enabled — the caller can say so rather than guess. */
|
|
78
78
|
empty: boolean;
|
|
79
79
|
}
|
|
80
|
-
/** What one channel's dispatch produced. */
|
|
81
80
|
interface ChannelResult {
|
|
82
81
|
channel: 'email';
|
|
83
82
|
sent: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/index.ts"],"mappings":";;;;;;AAeA;;;;;;;;AAGM;AASN;;UAZiB,eAAA;EACf,OAAA;EACA,IAAA;EACA,IAAA;AAAA;;;;;AAsBO;AAGT;UAhBiB,kBAAA;EACf,QAAA;EAiBA;EAfA,EAAA;EAsBe;EApBf,IAAA;;EAEA,QAAA;EACA,OAAA;EAkBgB;;;;EAbhB,OAAA;AAAA;AAAA,UAGe,mBAAA;EAuBW;EArB1B,KAAA,YAAiB,kBAAkB;AAAA;AAwB/B;AAAA,UAjBW,aAAA;EACf,aAAA,GAAgB,mBAAmB;;EAEnC,SAAA;EAiBiC;EAfjC,gBAAA;EAiBA;;;;EAZA,cAAA;AAAA;AAgBO;AAAA,UAZQ,WAAA;EACf,OAAA;;EAEA,IAAI;AAAA;AAAA,UAGW,SAAA,SAAkB,WAAW;EAC5C,QAAA;EACA,EAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,gBAAA;EACf,KAAA,EAAO,SAAS;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/index.ts"],"mappings":";;;;;;AAeA;;;;;;;;AAGM;AASN;;UAZiB,eAAA;EACf,OAAA;EACA,IAAA;EACA,IAAA;AAAA;;;;;AAsBO;AAGT;UAhBiB,kBAAA;EACf,QAAA;EAiBA;EAfA,EAAA;EAsBe;EApBf,IAAA;;EAEA,QAAA;EACA,OAAA;EAkBgB;;;;EAbhB,OAAA;AAAA;AAAA,UAGe,mBAAA;EAuBW;EArB1B,KAAA,YAAiB,kBAAkB;AAAA;AAwB/B;AAAA,UAjBW,aAAA;EACf,aAAA,GAAgB,mBAAmB;;EAEnC,SAAA;EAiBiC;EAfjC,gBAAA;EAiBA;;;;EAZA,cAAA;AAAA;AAgBO;AAAA,UAZQ,WAAA;EACf,OAAA;;EAEA,IAAI;AAAA;AAAA,UAGW,SAAA,SAAkB,WAAW;EAC5C,QAAA;EACA,EAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,gBAAA;EACf,KAAA,EAAO,SAAS;EAShB;EAPA,KAAA;AAAA;AAAA,UAGe,aAAA;EACf,OAAA;EACA,IAAA;;EAEA,MAAA;EAcA;EAZA,IAAA;AAAA;;;;;;;;;UAWe,YAAA;EACf,IAAA,CAAK,OAAA;IACH,EAAA;IACA,IAAA;MAAiB,KAAA;MAAe,IAAA;IAAA;IAChC,OAAA;IACA,IAAA;IACA,IAAA;IACA,OAAA;EAAA,IACE,OAAO;AAAA;;;;cC9FA,qBAAA;;iBAGG,qBAAA,CACd,KAAA,WAAgB,kBAAA,eACf,kBAAkB;;;;ADiBZ;AAGT;;;iBCRgB,gBAAA,CACd,OAAA,EAAS,kBAAA,EACT,GAAA,EAAK,aAAa;ADQiB;AAOrC;;;;;;AAPqC,iBCKrB,aAAA,CAAc,OAAA,EAAS,kBAAA,EAAoB,GAAA,EAAK,aAAa;;;;ADY7D;AAIhB;;iBCGgB,iBAAA,CAAkB,GAAA,EAAK,aAAA,GAAgB,gBAAgB;;;;UC9B7D,OAAA;EACR,OAAA;EACA,KAAK;AAAA;;;;;AFvBD;AASN;iBEiDgB,aAAA,CAAc,KAAA,EAAO,eAAA;EAAoB,OAAA;EAAiB,QAAA,EAAU,OAAO;AAAA;;iBAwD3E,WAAA,CAAY,KAAA,EAAO,eAAA,GAAkB,eAAe;;;UC1FnD,gBAAA;EHPf;EGSA,QAAA;EHJO;AAAA;AAGT;;;;AAEqC;AAOrC;;;;;EGKE,SAAA;EHFA;EGIA,KAAA,IAAS,EAAA,aAAe,OAAO;AAAA;;AHGjB;AAIhB;;;;AAGM;AAGN;iBG6BsB,SAAA,CACpB,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,OAAA,EAAS,YAAA,EACT,OAAA,GAAS,gBAAA,GACR,OAAA,CAAQ,aAAA;;;;;;;;iBAkDK,QAAA,CACd,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,GAAA,IAAM,IAAA,oBACL,aAAA;;;UC/Hc,UAAA;EJmBa;;;;EId5B,KAAA,GAAQ,YAAY;AAAA;AAAA,UAGL,YAAA;EACf,OAAA,EAAS,aAAa;EJoBR;EIlBd,SAAA;AAAA;;;;AJyBI;AAGN;;;iBIlBsB,MAAA,CACpB,KAAA,EAAO,eAAA,EACP,GAAA,EAAK,aAAA,EACL,IAAA,GAAM,UAAA,GACL,OAAA,CAAQ,YAAA"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BiK61Oq4.mjs";
|
|
2
|
-
import { i as GoogleOAuth, n as GoogleAuthLike } from "../token-
|
|
2
|
+
import { i as GoogleOAuth, n as GoogleAuthLike } from "../token-Bz_-igw-.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/onboarding/types.d.ts
|
|
5
5
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/types.ts","../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"mappings":";;;;;;;;AAeA;;;;;;;;AAYe;AAIf;AAAA,UAhBiB,WAAA;;EAEf,MAAA;EAgBoB;AAiBtB;;;EA5BE,eAAA;EA8BA;;;;EAzBA,aAAA;AAAA;AAiCc;AAAA,UA7BC,gBAAA;;EAEf,OAAA,EAAS,WAAW;AAAA;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/types.ts","../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"mappings":";;;;;;;;AAeA;;;;;;;;AAYe;AAIf;AAAA,UAhBiB,WAAA;;EAEf,MAAA;EAgBoB;AAiBtB;;;EA5BE,eAAA;EA8BA;;;;EAzBA,aAAA;AAAA;AAiCc;AAAA,UA7BC,gBAAA;;EAEf,OAAA,EAAS,WAAW;AAAA;;;;;;;ACXd;UD4BS,WAAA;ECxBU;ED0BzB,MAAA;ECrBK;EDuBL,UAAA;ECnBU;EDqBV,QAAA;ECnBgB;EDqBhB,aAAA;EC/BA;EDiCA,cAAA;AAAA;;;;KC3CU,UAAA;AAAA,UAEK,QAAA;EACf,MAAA,EAAQ,UAAU;ED8BH;EC5Bf,MAAA;AAAA;;UAIe,UAAA;EACf,MAAA;ED6BA;EC3BA,OAAA;ED+BA;EC7BA,GAAA,EAAK,QAAA;ED6BS;EC3Bd,MAAA,EAAQ,QAAA;;EAER,QAAA,EAAU,QAAA;EAlBA;EAoBV,MAAA,EAAQ,QAAA;;EAER,WAAA;EAtBoB;EAwBpB,SAAA;AAAA;AAAA,UAGe,cAAA;EACf,OAAA,EAAS,UAAU;EAzBnB;EA2BA,KAAA;AAAA;;cAIW,UAAA,GAAc,MAAc;AAzBzC;;;;;;;;;AAAA,iBAoCgB,eAAA,CAAgB,WAAe;;iBAO/B,UAAA,CAAW,KAAA,EAAO,WAAA,EAAa,KAAA,EAAO,WAAA,GAAc,UAAA;;;;;;;;;;;AA5BzD;AAGX;;;iBAqFgB,UAAA,CAAW,OAAA,YAAmB,KAAe;;iBAa7C,IAAA,CAAK,MAAA,EAAQ,gBAAA,EAAkB,MAAA,EAAQ,GAAA,SAAY,WAAA,IAAe,cAAA;;iBAiBlE,QAAA,CAAS,CAAiB,EAAd,cAAc;AAhHnC;AAAA,iBA2HS,UAAA,CAAW,CAAiB,EAAd,cAAc;;UAgB3B,YAAA;EACf,MAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,SAAA;AAAA;AAjI6C;AAAA,iBAqI/B,aAAA,CAAc,OAAuB,EAAd,YAAY;;;;iBCpKnC,iBAAA,CACd,KAAA,EAAO,WAAA,EACP,SAAA,UAAkB,KAAA,GACjB,OAAA;;;;;;;;iBAamB,oBAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,KAAA;EAAgB,OAAA;AAAA;;;ADlCpC;AAEtB;;;;;iBCoDsB,cAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EACD,EAAA;EACA,MAAA;EACA,MAAA;EACA,EAAA;EACA,MAAA;EACA,OAAA;AAAA;;iBAwBoB,iBAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,EAAA;EAAa,MAAA;EAAkB,OAAA;AAAA;;;;;;;;iBAuBnD,YAAA,CACpB,IAAA,EAAM,cAAA,EACN,EAAA,UACA,MAAA,UACA,MAAA,YACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA;;iBAWpB,OAAA,CACpB,IAAA,EAAM,cAAA,EACN,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,MAAA;EAAiB,OAAA;AAAA;AD1GpD;AAIP;;;;AAAyC;AAJlC,iBCyHe,OAAA,CACpB,IAAA,EAAM,cAAA,EACN,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-
|
|
1
|
+
import { a as googleHeaders, i as googleAccessToken, n as encodeSite, r as errorDetail } from "../token-DkKC5m23.mjs";
|
|
2
|
+
import { i as table, n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CgAsXiqe.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/onboarding/plan.ts
|
|
5
5
|
/** The `sc-domain:` property identifier for a bare domain. */
|
|
@@ -128,28 +128,19 @@ function formatPlan(p) {
|
|
|
128
128
|
for (const n of p.notes) lines.push(`! ${n}`);
|
|
129
129
|
return lines.join("\n");
|
|
130
130
|
}
|
|
131
|
-
const pad = (s, n) => s.padEnd(n);
|
|
132
131
|
/** Render the end-of-run summary: one row per domain, plus any reminders. */
|
|
133
132
|
function formatSummary(results) {
|
|
134
|
-
|
|
133
|
+
return table([
|
|
135
134
|
"DOMAIN",
|
|
136
135
|
"TXT",
|
|
137
136
|
"VERIFIED",
|
|
138
137
|
"PROPERTY"
|
|
139
|
-
]
|
|
140
|
-
const rows = results.map((r) => [
|
|
138
|
+
], results.map((r) => [
|
|
141
139
|
r.domain,
|
|
142
140
|
r.txt,
|
|
143
141
|
r.verified,
|
|
144
142
|
r.property
|
|
145
|
-
]);
|
|
146
|
-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
147
|
-
const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
|
|
148
|
-
const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
|
|
149
|
-
for (const row of rows) out.push(line(row));
|
|
150
|
-
const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));
|
|
151
|
-
if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
|
|
152
|
-
return out.join("\n");
|
|
143
|
+
]), results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`)));
|
|
153
144
|
}
|
|
154
145
|
|
|
155
146
|
//#endregion
|
|
@@ -301,7 +292,6 @@ async function updateOwners(auth, id, domain, owners, fetchImpl = fetch) {
|
|
|
301
292
|
status: res.status
|
|
302
293
|
};
|
|
303
294
|
}
|
|
304
|
-
const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
|
|
305
295
|
/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
|
|
306
296
|
async function getSite(auth, siteUrl, fetchImpl = fetch) {
|
|
307
297
|
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: googleHeaders(auth) });
|
|
@@ -337,11 +327,11 @@ async function addSite(auth, siteUrl, fetchImpl = fetch) {
|
|
|
337
327
|
ok: true,
|
|
338
328
|
status: res.status
|
|
339
329
|
};
|
|
340
|
-
const
|
|
330
|
+
const message = await errorDetail(res, (status) => status === 403 ? `403 — "${siteUrl}" is not verified for this account yet, or the OAuth token lacks the webmasters scope` : void 0);
|
|
341
331
|
return {
|
|
342
332
|
ok: false,
|
|
343
333
|
status: res.status,
|
|
344
|
-
message
|
|
334
|
+
message
|
|
345
335
|
};
|
|
346
336
|
}
|
|
347
337
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n if (reminders.length) {\n out.push('', 'Reminders (manual — no API):', ...reminders);\n }\n return out.join('\\n');\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport {\n googleAccessToken,\n googleHeaders,\n type GoogleAuthLike,\n type GoogleOAuth,\n} from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: googleHeaders(auth),\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n auth: GoogleAuthLike,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n return table(headers, rows, reminders);\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport {\n encodeSite,\n errorDetail,\n googleAccessToken,\n googleHeaders,\n type GoogleAuthLike,\n type GoogleOAuth,\n} from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: googleHeaders(auth),\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n auth: GoogleAuthLike,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\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 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : undefined,\n );\n return { ok: false, status: res.status, message };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { a as SCOPES, c as googleHeaders, i as GoogleOAuth, n as GoogleAuthLike, r as GoogleCredential, s as googleAccessToken, t as GoogleAuth } from "../token-Bz_-igw-.mjs";
|
|
2
|
+
import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-BFrzpMHq.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/reviews/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The reviews module's own option surface.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately structural rather than `Config['site']['reviews']` imported from
|
|
9
|
+
* `@getvitops/generator` — the generator already depends on this package, so
|
|
10
|
+
* importing back would be a cycle. The vocabulary mirrors that block field for
|
|
11
|
+
* field, the same arrangement as `IndexingConfig` in `../indexing/types.ts`.
|
|
12
|
+
*/
|
|
13
|
+
/** How one location's reviews should be selected and ordered for display. */
|
|
14
|
+
interface ReviewLocationConfig {
|
|
15
|
+
/** GBP account resource id (`accounts/…`), overriding the top-level default. */
|
|
16
|
+
accountId?: string | undefined;
|
|
17
|
+
/** GBP location resource id (`locations/…`) — required to fetch this location. */
|
|
18
|
+
locationId: string;
|
|
19
|
+
/** Explicit review ids, in order. These are always included, ahead of everything else. */
|
|
20
|
+
featured?: string[] | undefined;
|
|
21
|
+
/** Drop reviews below this star rating (default 4). */
|
|
22
|
+
minRating?: number | undefined;
|
|
23
|
+
/** Keep at most this many reviews after filtering. */
|
|
24
|
+
limit?: number | undefined;
|
|
25
|
+
/** Ordering for the non-featured remainder (default `recent`). */
|
|
26
|
+
sort?: 'recent' | 'rating' | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Everything `plan()` needs to decide what to fetch. */
|
|
29
|
+
interface ReviewsConfig {
|
|
30
|
+
/** Default GBP account resource id for locations that don't override it. */
|
|
31
|
+
accountId?: string | undefined;
|
|
32
|
+
/** Keyed by `organization.locations[].key`. */
|
|
33
|
+
locations: Record<string, ReviewLocationConfig>;
|
|
34
|
+
}
|
|
35
|
+
/** One review, as GBP returns it and the cache stores it. */
|
|
36
|
+
interface Review {
|
|
37
|
+
id: string;
|
|
38
|
+
/** The `organization.locations[]` key this review belongs to. */
|
|
39
|
+
location: string;
|
|
40
|
+
/** 1–5. */
|
|
41
|
+
rating: number;
|
|
42
|
+
author: string;
|
|
43
|
+
authorPhoto?: string | undefined;
|
|
44
|
+
text?: string | undefined;
|
|
45
|
+
publishedAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
reply?: {
|
|
48
|
+
text: string;
|
|
49
|
+
at: string;
|
|
50
|
+
} | undefined;
|
|
51
|
+
/** Link back to the review on Google — never omit when linking to it. */
|
|
52
|
+
url?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
/** What a fetch of one location produced. */
|
|
55
|
+
interface LocationReviews {
|
|
56
|
+
locationId: string;
|
|
57
|
+
accountId: string;
|
|
58
|
+
fetchedAt: string;
|
|
59
|
+
averageRating?: number | undefined;
|
|
60
|
+
totalReviewCount?: number | undefined;
|
|
61
|
+
reviews: Review[];
|
|
62
|
+
}
|
|
63
|
+
/** The on-disk cache, as written by `writeCache`. */
|
|
64
|
+
interface ReviewCache {
|
|
65
|
+
version: 1;
|
|
66
|
+
locations: Record<string, LocationReviews>;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/reviews/plan.d.ts
|
|
70
|
+
/** One location's fetch plan, or the reason it was skipped. */
|
|
71
|
+
interface LocationPlan {
|
|
72
|
+
key: string;
|
|
73
|
+
accountId?: string;
|
|
74
|
+
locationId?: string;
|
|
75
|
+
/** Present iff this location will not be fetched. */
|
|
76
|
+
skip?: string;
|
|
77
|
+
}
|
|
78
|
+
interface ReviewsPlan {
|
|
79
|
+
/** Set when the whole run is refused — nothing is fetched. */
|
|
80
|
+
blocked?: string;
|
|
81
|
+
locations: LocationPlan[];
|
|
82
|
+
notes: string[];
|
|
83
|
+
}
|
|
84
|
+
interface PlanInput {
|
|
85
|
+
config: ReviewsConfig | undefined;
|
|
86
|
+
/** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
|
|
87
|
+
knownLocations: Set<string>;
|
|
88
|
+
/** `--location`: fetch exactly this one, skipping the rest. */
|
|
89
|
+
onlyLocation?: string | undefined;
|
|
90
|
+
/** Whether a Google credential was resolved. */
|
|
91
|
+
hasCredential: boolean;
|
|
92
|
+
}
|
|
93
|
+
/** Decide which locations to fetch, and why any are skipped. */
|
|
94
|
+
declare function plan(input: PlanInput): ReviewsPlan;
|
|
95
|
+
/** Render a plan as the human-readable block `--dry` prints. */
|
|
96
|
+
declare function formatPlan(p: ReviewsPlan): string;
|
|
97
|
+
/**
|
|
98
|
+
* Filter and order one location's reviews for display.
|
|
99
|
+
*
|
|
100
|
+
* Featured ids always win and keep the author's order — an explicit choice
|
|
101
|
+
* overrides any automatic ranking. The remainder is filtered by `minRating`
|
|
102
|
+
* (default 4 — a star-rating widget showing a 1-star review it did not have to
|
|
103
|
+
* is a design choice a config field should not have to spell out every time),
|
|
104
|
+
* sorted, then capped by `limit`.
|
|
105
|
+
*/
|
|
106
|
+
declare function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[];
|
|
107
|
+
/**
|
|
108
|
+
* Merge a fresh fetch into the existing cache.
|
|
109
|
+
*
|
|
110
|
+
* Only the locations actually fetched this run are replaced; every other
|
|
111
|
+
* location's prior reviews pass through untouched. This is what makes a partial
|
|
112
|
+
* failure (one location's fetch errors) non-destructive — `cmdReviews` merges
|
|
113
|
+
* only the locations that succeeded and leaves the rest of the cache alone.
|
|
114
|
+
*/
|
|
115
|
+
declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/reviews/cache.d.ts
|
|
118
|
+
/** Default location, relative to the consumer's project root. */
|
|
119
|
+
declare const REVIEWS_CACHE_PATH = ".vitops/reviews.json";
|
|
120
|
+
/**
|
|
121
|
+
* Read the cache, or `undefined` when there isn't a usable one.
|
|
122
|
+
*
|
|
123
|
+
* A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:
|
|
124
|
+
* the alternative — reading it as an empty cache — would be indistinguishable
|
|
125
|
+
* from "this business genuinely has no reviews" and silently ship an empty
|
|
126
|
+
* carousel.
|
|
127
|
+
*/
|
|
128
|
+
declare function readCache(path: string): ReviewCache | undefined;
|
|
129
|
+
/** Build a cache with its keys and each location's reviews in a stable order. */
|
|
130
|
+
declare function toCache(locations: Record<string, LocationReviews>): ReviewCache;
|
|
131
|
+
/** Write the cache, creating its directory. */
|
|
132
|
+
declare function writeCache(path: string, cache: ReviewCache): void;
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/reviews/gbp.d.ts
|
|
135
|
+
/** One page (or, having paginated, the whole set) of a location's reviews. */
|
|
136
|
+
interface FetchReviewsResult {
|
|
137
|
+
ok: boolean;
|
|
138
|
+
reviews: Review[];
|
|
139
|
+
averageRating?: number;
|
|
140
|
+
totalReviewCount?: number;
|
|
141
|
+
status?: number;
|
|
142
|
+
message?: string;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Fetch every review for one location, paginating to completion.
|
|
146
|
+
*
|
|
147
|
+
* Never throws — a per-location failure is a structured result so a run across
|
|
148
|
+
* several locations can fetch what it can and report the rest, the same contract
|
|
149
|
+
* `../indexing/gsc.ts`'s `inspectUrl` keeps.
|
|
150
|
+
*/
|
|
151
|
+
declare function fetchReviews(auth: GoogleAuthLike, accountId: string, locationId: string, fetchImpl?: typeof fetch): Promise<FetchReviewsResult>;
|
|
152
|
+
//#endregion
|
|
153
|
+
export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, REVIEWS_CACHE_PATH, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, fetchReviews, formatPlan, googleAccessToken, googleHeaders, mergeCache, parseAdcUser, plan, readCache, selectReviews, toCache, writeCache };
|
|
154
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/types.ts","../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"mappings":";;;;;;;;AAgBA;;;;;UAAiB,oBAAA;EAMf;EAJA,SAAA;EAQA;EANA,UAAA;EAQI;EANJ,QAAA;EAUe;EARf,SAAA;;EAEA,KAAA;EAQA;EANA,IAAA;AAAA;;UAIe,aAAA;EAI+B;EAF9C,SAAA;EAMqB;EAJrB,SAAA,EAAW,MAAM,SAAS,oBAAA;AAAA;;UAIX,MAAA;EACf,EAAA;EAKA;EAHA,QAAA;EAKA;EAHA,MAAA;EACA,MAAA;EACA,WAAA;EACA,IAAA;EACA,WAAA;EACA,SAAA;EACA,KAAA;IAAU,IAAA;IAAc,EAAA;EAAA;;EAExB,GAAA;AAAA;;UAIe,eAAA;EACf,UAAA;EACA,SAAA;EACA,SAAA;EACA,aAAA;EACA,gBAAA;EACA,OAAA,EAAS,MAAM;AAAA;;UAIA,WAAA;EACf,OAAA;EACA,SAAA,EAAW,MAAM,SAAS,eAAA;AAAA;;;;UClDX,YAAA;EACf,GAAA;EACA,SAAA;EACA,UAAA;EDUe;ECRf,IAAA;AAAA;AAAA,UAGe,WAAA;EDOf;ECLA,OAAA;EACA,SAAA,EAAW,YAAY;EACvB,KAAA;AAAA;AAAA,UAGe,SAAA;EACf,MAAA,EAAQ,aAAA;EDKa;ECHrB,cAAA,EAAgB,GAAG;EDGE;ECDrB,YAAA;EDIA;ECFA,aAAA;AAAA;;iBAIc,IAAA,CAAK,KAAA,EAAO,SAAA,GAAY,WAAW;;iBAuDnC,UAAA,CAAW,CAAc,EAAX,WAAW;;;;;;AD/CpC;AAIL;;;iBC8DgB,aAAA,CAAc,OAAA,EAAS,MAAA,IAAU,MAAA,EAAQ,oBAAA,GAAuB,MAAA;;;;;;;;;iBA2BhE,UAAA,CACd,QAAA,EAAU,WAAA,cACV,OAAA,EAAS,MAAA,SAAe,eAAA,IACvB,WAAA;;;;cCvIU,kBAAA;;;AFcP;AAIN;;;;;iBERgB,SAAA,CAAU,IAAA,WAAe,WAAW;;iBAKpC,OAAA,CAAQ,SAAA,EAAW,MAAA,SAAe,eAAA,IAAmB,WAAA;;iBAarD,UAAA,CAAW,IAAA,UAAc,KAAA,EAAO,WAAW;;;;UCoB1C,kBAAA;EACf,EAAA;EACA,OAAA,EAAS,MAAM;EACf,aAAA;EACA,gBAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;;;iBAUoB,YAAA,CACpB,IAAA,EAAM,cAAA,EACN,SAAA,UACA,UAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA,CAAQ,kBAAA"}
|