@getvitops/utils 8.1.0 → 8.2.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +264 -8
- package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
- package/dist/adc-BoEoogyo.d.mts.map +1 -0
- package/dist/ads/index.d.mts +50 -17
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +75 -7
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- package/dist/color/index.d.mts +34 -35
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +3 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +288 -0
- package/dist/crm/index.d.mts.map +1 -0
- package/dist/crm/index.mjs +731 -0
- package/dist/crm/index.mjs.map +1 -0
- package/dist/email/index.d.mts +216 -0
- package/dist/email/index.d.mts.map +1 -0
- package/dist/email/index.mjs +331 -0
- package/dist/email/index.mjs.map +1 -0
- package/dist/favicon.d.mts +8 -9
- package/dist/favicon.d.mts.map +1 -1
- package/dist/git/index.d.mts +65 -0
- package/dist/git/index.d.mts.map +1 -0
- package/dist/git/index.mjs +107 -0
- package/dist/git/index.mjs.map +1 -0
- package/dist/hosting/index.d.mts +29 -29
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +7 -4
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/identity-BoRddTx3.mjs +35 -0
- package/dist/identity-BoRddTx3.mjs.map +1 -0
- package/dist/index.d.mts +24 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +23 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/media/index.d.mts +24 -24
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +26 -15
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +50 -20
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +10 -141
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +1 -144
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-BLKikoOG.mjs +150 -0
- package/dist/plan-BLKikoOG.mjs.map +1 -0
- package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
- package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
- package/dist/reviews/index.d.mts +12 -13
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/schema/index.d.mts +7 -7
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/security/index.d.mts +2 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
- package/dist/token-DmmoQ7um.d.mts.map +1 -0
- package/dist/tracking/index.d.mts +43 -17
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +2 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
- package/dist/types-7ELci8RQ.d.mts.map +1 -0
- package/package.json +14 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/media/types.ts","../../src/media/discover.ts","../../src/media/plan.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/index.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/media/types.ts","../../src/media/discover.ts","../../src/media/plan.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/index.ts"],"mappings":";;;;;;;;;;KAUY;;qBAGC,iBAAiB;;qBAGjB,YAAY,OAAO;;;;;;;;;;qBAenB;;UAGI;;;;;;;EAOf;;;;;;;;;EASA;;;;;;;EAOA;;;;;EAKA;;;;;;;EAOA;;EAEA,UAAU;;;UAIK;EACf;;;;;;EAMA;;EAEA;;;UAIe;EACf,MAAM;;EAEN;;;UAIe;EACf,QAAQ;EACR,SAAS;;EAET;;EAEA;;;UAIe;EACf,MAAM;;EAEN;;;UAIe;;EAEf;EACA;;EAEA;;;;;;;UAQe;EACf;EACA,SAAS,eAAe;;;;;qBCjHb,WAAW;;qBAYX,eAAe;;wBAGZ,SAAS,eAAe;;;;;;;wBA4BlB,eAAe,cAAc,QAAQ;;;;UC9B1C;EACf;EACA;EACA;EACA;EACA;EACA,SAAS;;;;;;;;KASC,eAAe;UAEV;EACf,SAAS;EACT,SAAS;;EAET,WAAW;;EAEX,QAAQ;;EAER;;;wBAMc,cAAc,SAAQ,cAAmB;;;;;;;wBAiBzC,aAAa,QAAQ;;;;;;;;;;;;;;wBAuCrB,UAAU,OAAO,iBAAiB;;qBA0CrC,cAAe,MAAM,cAAY;;wBAG9B,WAAW,MAAM;;;;;;;;;;;;wBC1HjB,iBAAiB;;;;;;;;;wBAqEX,WACpB,eACA,gBACA,QAAQ,sBACP;;;;;;;;;wBA2DmB,UACpB,eACA,gBACA,QAAQ,sBACP;;wBAyBmB,cACpB,eACA,gBACA,QAAQ,sBACP;;;;qBC7LU;;;;;;;;;wBAUG,aAAa,eAAe;;wBAK5B,WAAW,SAAS,eAAe,sBAAsB;;;;;;;;wBAgBzD,cAAc,cAAc,UAAU;;;iBCrBrC;;EAEf;;EAEA;EACA,SAAS;;EAET;;EAEA;;EAEA;;EAEA,eAAe;;iBAGA;EACf,MAAM;;EAEN;;EAEA;;;;;;;;;wBAUoB,aAAa,SAAS,sBAAsB,QAAQ"}
|
package/dist/media/index.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { tmpdir } from "node:os";
|
|
|
16
16
|
* why the hash is streamed rather than taken over a `readFileSync`.
|
|
17
17
|
*/
|
|
18
18
|
/** Container formats worth accepting as input. */
|
|
19
|
-
const VIDEO_EXT = new Set([
|
|
19
|
+
const VIDEO_EXT = /* @__PURE__ */ new Set([
|
|
20
20
|
".mp4",
|
|
21
21
|
".mov",
|
|
22
22
|
".m4v",
|
|
@@ -27,7 +27,7 @@ const VIDEO_EXT = new Set([
|
|
|
27
27
|
".mpeg"
|
|
28
28
|
]);
|
|
29
29
|
/** Directories that never hold hand-managed source assets. */
|
|
30
|
-
const DISCOVER_SKIP = new Set([
|
|
30
|
+
const DISCOVER_SKIP = /* @__PURE__ */ new Set([
|
|
31
31
|
"node_modules",
|
|
32
32
|
"dist",
|
|
33
33
|
".git"
|
|
@@ -78,7 +78,7 @@ async function discoverVideos(dir) {
|
|
|
78
78
|
*/
|
|
79
79
|
const exec = promisify(execFile);
|
|
80
80
|
/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */
|
|
81
|
-
const MAX_BUFFER =
|
|
81
|
+
const MAX_BUFFER = 16777216;
|
|
82
82
|
/**
|
|
83
83
|
* x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has
|
|
84
84
|
* to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the
|
package/dist/media/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/media/discover.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/types.ts","../../src/media/plan.ts","../../src/media/index.ts"],"sourcesContent":["/**\n * Finding the source videos and fingerprinting them.\n *\n * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into\n * memory as a string. That is right for templates and catastrophic for a video —\n * the files this walks are routinely tens or hundreds of megabytes, which is also\n * why the hash is streamed rather than taken over a `readFileSync`.\n */\nimport { createHash } from 'node:crypto';\nimport { createReadStream, readdirSync } from 'node:fs';\nimport { extname, join, relative, sep } from 'node:path';\nimport type { MediaSource } from './types.ts';\n\n/** Container formats worth accepting as input. */\nexport const VIDEO_EXT: ReadonlySet<string> = new Set([\n '.mp4',\n '.mov',\n '.m4v',\n '.mkv',\n '.avi',\n '.webm',\n '.mpg',\n '.mpeg',\n]);\n\n/** Directories that never hold hand-managed source assets. */\nexport const DISCOVER_SKIP: ReadonlySet<string> = new Set(['node_modules', 'dist', '.git']);\n\n/** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */\nexport function hashFile(path: string): Promise<string> {\n return new Promise((resolve, reject) => {\n const hash = createHash('sha256');\n const stream = createReadStream(path);\n stream.on('error', reject);\n stream.on('data', (chunk) => hash.update(chunk));\n stream.on('end', () => resolve(hash.digest('hex').slice(0, 16)));\n });\n}\n\nfunction walk(dir: string, acc: string[]): void {\n for (const e of readdirSync(dir, { withFileTypes: true })) {\n if (e.name.startsWith('.')) continue;\n const p = join(dir, e.name);\n if (e.isDirectory()) {\n if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);\n } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) {\n acc.push(p);\n }\n }\n}\n\n/**\n * Every video under `dir`, with its content hash.\n *\n * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in\n * the same order on every machine, regardless of what the filesystem hands back.\n */\nexport async function discoverVideos(dir: string): Promise<MediaSource[]> {\n const paths: string[] = [];\n walk(dir, paths);\n const sources = await Promise.all(\n paths.map(async (path) => ({\n path,\n id: relative(dir, path).split(sep).join('/'),\n hash: await hashFile(path),\n })),\n );\n return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n","/**\n * The encoder. Executes a job and decides nothing.\n *\n * Every invocation goes through `execFile` with an argv array rather than a shell\n * string: a source path containing a space or a quote is ordinary, and the\n * shell-interpolated form these commands are usually written in breaks on both.\n */\nimport { execFile } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { promisify } from 'node:util';\nimport type { ResolvedMediaConfig } from './plan.ts';\n\nconst exec = promisify(execFile);\n\n/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */\nconst MAX_BUFFER = 16 * 1024 * 1024;\n\n/**\n * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has\n * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the\n * pairing VP9's encoding guide uses for comparable quality.\n */\nconst X264_CRF_RATIO = 0.72;\n\nlet probe: Promise<void> | undefined;\n\n/**\n * Fail early and clearly when ffmpeg isn't installed.\n *\n * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a\n * missing `oxipng` and warns once. That is right for a best-effort lossless crush,\n * where skipping costs a few kilobytes. It is wrong here: skipping produces no\n * output at all, and a build that carries on would ship the page referencing a\n * video that was never encoded.\n */\nexport function requireFfmpeg(): Promise<void> {\n probe ??= exec('ffmpeg', ['-version'], { maxBuffer: MAX_BUFFER }).then(\n () => undefined,\n () => {\n probe = undefined; // so a later call re-probes rather than caching a stale failure\n throw new Error(\n 'media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — ' +\n 'install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, ' +\n 'Windows: `winget install Gyan.FFmpeg`) and run this again.',\n );\n },\n );\n return probe;\n}\n\nasync function run(args: string[]): Promise<void> {\n try {\n await exec('ffmpeg', args, { maxBuffer: MAX_BUFFER });\n } catch (err) {\n const stderr = String((err as { stderr?: string }).stderr ?? '').trim();\n const tail = stderr.split('\\n').slice(-6).join('\\n');\n throw new Error(`media: ffmpeg failed${tail ? `\\n${tail}` : ''}`);\n }\n}\n\n/**\n * An encode that \"succeeds\" but writes nothing is the failure mode worth guarding:\n * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.\n * Without this the manifest would record the job as done and never retry it.\n */\nfunction assertWritten(file: string, what: string): void {\n if (!existsSync(file) || statSync(file).size === 0)\n throw new Error(\n `media: ${what} produced no output (${file}). Check --poster-time and the source.`,\n );\n}\n\n/** Scale filter, capping width and keeping the height even (yuv420p requires it). */\nfunction scaleArgs(maxWidth: number): string[] {\n if (maxWidth <= 0) return [];\n // ffmpeg parses the quotes itself, so the commas inside min() aren't read as a\n // filter separator. There is no shell here to strip them.\n return ['-vf', `scale='min(${maxWidth},iw)':-2`];\n}\n\n/** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */\nfunction bufsize(maxBitrate: string): string {\n const m = /^(\\d+(?:\\.\\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());\n if (!m)\n throw new Error(`media: maxBitrate \"${maxBitrate}\" is not a bitrate (try \"2M\" or \"800k\").`);\n return `${Number(m[1]) * 2}${m[2] ?? ''}`;\n}\n\nconst rateArgs = (maxBitrate: string | undefined): string[] =>\n maxBitrate ? ['-maxrate', maxBitrate, '-bufsize', bufsize(maxBitrate)] : [];\n\nconst audioArgs = (config: ResolvedMediaConfig, codec: string): string[] =>\n config.audio ? ['-c:a', codec, '-b:a', '128k'] : ['-an'];\n\nconst BASE = ['-y', '-nostdin', '-loglevel', 'error'];\n\n/**\n * VP9/WebM, two passes.\n *\n * The pass log goes into a fresh temp directory rather than the working directory.\n * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and\n * makes two concurrent encodes silently share one log — the second encode's rate\n * control then reads the first clip's statistics.\n */\nexport async function encodeWebm(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n const dir = mkdtempSync(join(tmpdir(), 'vitops-media-'));\n const passlog = join(dir, 'pass');\n const video = [\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libvpx-vp9',\n // -b:v 0 with -crf is VP9's constant-quality mode; with -maxrate it becomes\n // constrained quality. A fixed bitrate would be right at one resolution only.\n '-b:v',\n '0',\n '-crf',\n String(config.crf),\n '-row-mt',\n '1',\n ...rateArgs(config.maxBitrate),\n ];\n try {\n // Pass 1 only gathers statistics, so it never encodes audio and writes no file.\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n '-an',\n '-pass',\n '1',\n '-passlogfile',\n passlog,\n '-f',\n 'null',\n '-',\n ]);\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n ...audioArgs(config, 'libopus'),\n '-pass',\n '2',\n '-passlogfile',\n passlog,\n output,\n ]);\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n assertWritten(output, 'webm encode');\n}\n\n/**\n * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the\n * in-app webviews that still don't decode VP9.\n *\n * `+faststart` moves the moov atom to the front. Without it the browser has to\n * download the whole file before it can show frame one, which on a hero video\n * looks exactly like the optimisation didn't work.\n */\nexport async function encodeMp4(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libx264',\n '-preset',\n 'slow',\n '-crf',\n String(Math.round(config.crf * X264_CRF_RATIO)),\n // Required by Safari and by every hardware decoder worth naming.\n '-pix_fmt',\n 'yuv420p',\n '-movflags',\n '+faststart',\n ...rateArgs(config.maxBitrate),\n ...audioArgs(config, 'aac'),\n output,\n ]);\n assertWritten(output, 'mp4 encode');\n}\n\n/** The poster frame. `-ss` before `-i` so the seek is a fast one. */\nexport async function extractPoster(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n ...(config.posterTime > 0 ? ['-ss', String(config.posterTime)] : []),\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-frames:v',\n '1',\n '-q:v',\n '2',\n output,\n ]);\n assertWritten(output, 'poster extraction');\n}\n","/**\n * The cache — what makes a second run free.\n *\n * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout\n * checks in both the processed outputs and this file, so CI never needs ffmpeg\n * and the outputs only change when someone deliberately re-encodes. That is why\n * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce\n * a byte-identical file, or every build shows up as a diff.\n */\nimport type { MediaManifest, MediaManifestEntry } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const MEDIA_MANIFEST_PATH = '.vitops/media-manifest.json';\n\n/**\n * Read the manifest, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, which re-encodes everything.\n * The opposite reading — treating it as an empty but valid manifest — is the same\n * shape of bug `readSnapshot` avoids, except here it would be silent: nothing\n * would encode, and the build would reference outputs that were never written.\n */\nexport function readManifest(path: string): MediaManifest | undefined {\n return readVersioned<MediaManifest, 'entries'>(path, 'entries');\n}\n\n/** Build a manifest with its keys and output lists in a stable order. */\nexport function toManifest(entries: Record<string, MediaManifestEntry>): MediaManifest {\n const sorted: Record<string, MediaManifestEntry> = {};\n for (const key of Object.keys(entries).sort()) {\n const entry = entries[key] as MediaManifestEntry;\n sorted[key] = { ...entry, outputs: [...entry.outputs].sort() };\n }\n return { version: 1, entries: sorted };\n}\n\n/**\n * Write the manifest, creating its directory.\n *\n * Record a job only once its outputs are on disk. Writing eagerly would mark a\n * source as encoded that isn't, and because the next run trusts this file, the\n * result is a missing video that no rebuild ever fixes.\n */\nexport function writeManifest(path: string, manifest: MediaManifest): void {\n writeVersioned(path, manifest);\n}\n","/**\n * The media module's own option surface.\n *\n * Everything here describes *how to encode*, never *what the site is* — which is\n * why there is no `Config` block behind it. `legal`, `icons` and `indexing`\n * are anchored to a site config because what they emit describes the site; an\n * encoder setting describes a build step.\n */\n\n/** What a source video is turned into. */\nexport type OutputKind = 'webm' | 'mp4' | 'poster';\n\n/** The default set: a modern codec, a universal fallback, and a poster frame. */\nexport const DEFAULT_OUTPUTS: OutputKind[] = ['webm', 'mp4', 'poster'];\n\n/** File extension per output kind. */\nexport const OUTPUT_EXT: Record<OutputKind, string> = {\n webm: '.webm',\n mp4: '.mp4',\n poster: '.jpg',\n};\n\n/**\n * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the\n * output bytes. It is part of the settings hash, so bumping it re-encodes every\n * source on the next run.\n *\n * Bump it deliberately and rarely. Consumers commit their processed outputs (see\n * the README), so every bump is a large binary diff in someone's repository —\n * that cost is worth paying for a real encoder fix and not for a tidy-up.\n */\nexport const ENCODER_VERSION = 1;\n\n/** How to encode. Every field here feeds the settings hash. */\nexport interface MediaConfig {\n /**\n * Cap the output width in pixels, preserving aspect ratio. Default 1920.\n *\n * A source larger than its display size is the usual reason a hero video is\n * enormous, so this is on by default rather than opt-in. `0` disables scaling.\n */\n maxWidth?: number | undefined;\n /**\n * Constant-quality level, on **VP9's CRF scale (0–63, lower is better)**.\n * Default 32, which is the range VP9's own guidance recommends for 1080p.\n *\n * H.264 uses a different scale (0–51), so the MP4 fallback is encoded at\n * `round(crf * 0.72)` — 23 at the default, x264's own default. One knob, because\n * two would drift apart and nobody would know which one mattered.\n */\n crf?: number | undefined;\n /**\n * Optional ceiling, e.g. `'2M'`. Absent by default: a bitrate cap makes the\n * encode constrained-quality rather than constant-quality, which is right when\n * you are protecting a data budget and wrong as a default, because the correct\n * cap depends on the resolution you are capping.\n */\n maxBitrate?: string | undefined;\n /**\n * Keep the audio track. Default false — the common case is a muted autoplay\n * loop, where the audio stream is bytes nobody will ever hear.\n */\n audio?: boolean | undefined;\n /**\n * Timestamp (seconds) the poster frame is taken from. Default 0.\n *\n * Frame 0 is often black on a clip that fades in, which is worth knowing before\n * concluding the poster is broken.\n */\n posterTime?: number | undefined;\n /** Which outputs to produce. Default `DEFAULT_OUTPUTS`. */\n outputs?: OutputKind[] | undefined;\n}\n\n/** A discovered source video. */\nexport interface MediaSource {\n path: string;\n /**\n * Path relative to the raw directory, POSIX-separated — the manifest key.\n * Relative so a manifest stays valid when the project moves or is cloned\n * somewhere else, which is the whole point of committing it.\n */\n id: string;\n /** sha256 of the file contents, truncated. */\n hash: string;\n}\n\n/** One file an encode will write. */\nexport interface MediaOutput {\n kind: OutputKind;\n /** Path relative to the output directory, POSIX-separated. */\n file: string;\n}\n\n/** One source and everything decided about it. */\nexport interface MediaJob {\n source: MediaSource;\n outputs: MediaOutput[];\n /** Hash of the settings these outputs were produced under. */\n settings: string;\n /** Why this job needs no work. Present iff it is being skipped. */\n skip?: string | undefined;\n}\n\n/** The complete account of a run, decided before any of it happens. */\nexport interface MediaPlan {\n jobs: MediaJob[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\n/** One manifest record: what these outputs were made from, and under what settings. */\nexport interface MediaManifestEntry {\n /** `MediaSource.hash` at the time of encoding. */\n source: string;\n settings: string;\n /** Output paths relative to the output directory. */\n outputs: string[];\n}\n\n/**\n * The cache. A committed artifact, so it carries no timestamp and its keys are\n * sorted — a rebuild of unchanged sources must produce a byte-identical file, or\n * it shows up as noise in every diff.\n */\nexport interface MediaManifest {\n version: 1;\n entries: Record<string, MediaManifestEntry>;\n}\n","/**\n * Every decision `vitops media` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a\n * source needs re-encoding — the only thing here that can be wrong in a way that\n * costs minutes or ships a stale video — is decided from data passed in, and\n * asserted in `plan.test.ts` on a machine with no ffmpeg installed.\n *\n * That split is the same one `indexing/plan.ts` makes against its I/O modules, and\n * for the same reason: `--dry` should be a complete account of a run rather than\n * an approximation of one.\n */\nimport { createHash } from 'node:crypto';\nimport {\n DEFAULT_OUTPUTS,\n ENCODER_VERSION,\n type MediaConfig,\n type MediaJob,\n type MediaManifest,\n type MediaOutput,\n type MediaPlan,\n type MediaSource,\n OUTPUT_EXT,\n type OutputKind,\n} from './types.ts';\n\n/** Defaults, resolved once so the settings hash is computed over real values. */\nexport interface ResolvedMediaConfig {\n maxWidth: number;\n crf: number;\n maxBitrate: string | undefined;\n audio: boolean;\n posterTime: number;\n outputs: OutputKind[];\n}\n\n/**\n * Injected existence probe — `plan.ts` touches no filesystem itself.\n *\n * Takes a path relative to the output directory. The same injection shape as\n * `collectEntries(source, read)` in `indexing/sitemap.ts`.\n */\nexport type OutputProbe = (relativePath: string) => boolean;\n\nexport interface MediaPlanInput {\n sources: MediaSource[];\n config?: MediaConfig | undefined;\n /** The previous run's manifest, if there is a usable one. */\n previous?: MediaManifest | undefined;\n /** Whether an output file is already on disk. Defaults to \"nothing exists\". */\n probe?: OutputProbe | undefined;\n /** `--force`: re-encode regardless of the cache. */\n force?: boolean | undefined;\n}\n\nconst sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16);\n\n/** Fill in defaults. Exported because the encoder and the hash must agree on them. */\nexport function resolveConfig(config: MediaConfig = {}): ResolvedMediaConfig {\n return {\n maxWidth: config.maxWidth ?? 1920,\n crf: config.crf ?? 32,\n maxBitrate: config.maxBitrate,\n audio: config.audio ?? false,\n posterTime: config.posterTime ?? 0,\n outputs: config.outputs ?? DEFAULT_OUTPUTS,\n };\n}\n\n/**\n * Hash the settings that decide the output bytes.\n *\n * Order-independent in the one place it could bite (`outputs` is sorted), so\n * re-ordering the list in a config doesn't re-encode a whole library.\n */\nexport function settingsHash(config: ResolvedMediaConfig): string {\n return sha(\n JSON.stringify({\n v: ENCODER_VERSION,\n maxWidth: config.maxWidth,\n crf: config.crf,\n maxBitrate: config.maxBitrate ?? null,\n audio: config.audio,\n posterTime: config.posterTime,\n outputs: [...config.outputs].sort(),\n }),\n );\n}\n\n/** `a/b/clip.mp4` → `a/b/clip` */\nfunction stem(id: string): string {\n const dot = id.lastIndexOf('.');\n const slash = id.lastIndexOf('/');\n return dot > slash ? id.slice(0, dot) : id;\n}\n\nfunction outputsFor(source: MediaSource, config: ResolvedMediaConfig): MediaOutput[] {\n const base = stem(source.id);\n return config.outputs.map((kind) => ({ kind, file: `${base}${OUTPUT_EXT[kind]}` }));\n}\n\n/**\n * Decide what to encode.\n *\n * A job is skipped only when all three hold: the source contents hash the same as\n * last time, the settings hash the same, and every declared output is still on\n * disk. Anything else re-encodes — including an absent or unreadable manifest,\n * which reads as \"encode everything\" and never as \"everything is done\".\n *\n * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both\n * want `hero.webm`). That is a layout mistake rather than a runtime condition, and\n * the failure it would otherwise cause — two sources overwriting each other every\n * run, each invalidating the other's cache entry — is invisible from the outside.\n */\nexport function planMedia(input: MediaPlanInput): MediaPlan {\n const config = resolveConfig(input.config);\n const settings = settingsHash(config);\n const probe = input.probe ?? (() => false);\n const notes: string[] = [];\n\n const jobs: MediaJob[] = [];\n const claimed = new Map<string, string>();\n\n for (const source of input.sources) {\n const outputs = outputsFor(source, config);\n for (const out of outputs) {\n const owner = claimed.get(out.file);\n if (owner !== undefined && owner !== source.id)\n throw new Error(\n `media: \"${source.id}\" and \"${owner}\" both produce \"${out.file}\". ` +\n 'Two sources with the same name in one directory overwrite each other — rename one.',\n );\n claimed.set(out.file, source.id);\n }\n\n const prev = input.previous?.entries[source.id];\n const cached =\n !input.force &&\n prev !== undefined &&\n prev.source === source.hash &&\n prev.settings === settings &&\n outputs.every((o) => probe(o.file));\n\n jobs.push({ source, outputs, settings, ...(cached ? { skip: 'unchanged' } : {}) });\n }\n\n const pending = jobs.filter((j) => !j.skip).length;\n if (input.sources.length === 0) notes.push('no video files found');\n else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);\n else if (!input.previous && pending > 0)\n notes.push('no manifest — encoding everything (a first run, or a cache that was not restored)');\n\n return { jobs, notes };\n}\n\n/** The jobs that will actually run. */\nexport const pendingJobs = (plan: MediaPlan): MediaJob[] => plan.jobs.filter((j) => !j.skip);\n\n/** Human-readable account of a plan, for `--dry` and for the run summary. */\nexport function formatPlan(plan: MediaPlan): string {\n const lines: string[] = [];\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n\n lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? '' : 's'}`);\n lines.push(` encode: ${pending.length}`);\n lines.push(` skipped: ${skipped} (unchanged)`);\n\n for (const job of pending)\n lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(', ')}`);\n\n for (const note of plan.notes) lines.push(` ! ${note}`);\n return lines.join('\\n');\n}\n","/**\n * `vitops media` — turning a directory of raw video into web-ready assets.\n *\n * A separate subpath (`@getvitops/utils/media`) rather than part of the package\n * index, matching `./favicon` and `./indexing`: this module shells out to an\n * external encoder, and a consumer importing the content helpers shouldn't carry\n * it.\n *\n * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`\n * decides everything and touches nothing; `ffmpeg.ts` executes and decides\n * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is\n * what lets `--dry` be a complete account of a run, and what lets the cache — the\n * part with real consequences, since getting it wrong means either minutes of\n * needless encoding or a stale video shipped — be tested with no ffmpeg installed.\n */\nimport { existsSync, mkdirSync, statSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { discoverVideos } from './discover.ts';\nimport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nimport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nimport { pendingJobs, planMedia, resolveConfig } from './plan.ts';\nimport type { MediaConfig, MediaManifestEntry, MediaPlan } from './types.ts';\n\nexport interface ProcessMediaOptions {\n /** Directory of unprocessed sources. Walked recursively. */\n raw: string;\n /** Where the encoded outputs go. Structure under `raw` is preserved. */\n out: string;\n config?: MediaConfig | undefined;\n /** Cache location. Default `.vitops/media-manifest.json`. */\n manifest?: string | undefined;\n /** Re-encode regardless of the cache. */\n force?: boolean | undefined;\n /** Decide everything, run nothing. */\n dry?: boolean | undefined;\n /** Called before each source is encoded — these take minutes, so say so. */\n onProgress?: ((message: string) => void) | undefined;\n}\n\nexport interface ProcessMediaResult {\n plan: MediaPlan;\n /** Outputs written this run, relative to `out`. */\n written: string[];\n /** Sources that needed no work. */\n skipped: number;\n}\n\n/**\n * Encode everything under `raw` that has changed since the last run.\n *\n * Callers get the cache for free because it lives here rather than in either of\n * them: `vitops media` and the Astro integration are equally incremental, since\n * they call this same function.\n */\nexport async function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult> {\n const rawDir = resolve(options.raw);\n const outDir = resolve(options.out);\n const manifestPath = resolve(options.manifest ?? MEDIA_MANIFEST_PATH);\n\n if (!existsSync(rawDir) || !statSync(rawDir).isDirectory())\n throw new Error(`media: raw directory not found: ${rawDir}`);\n // Sharing one directory would make each run's outputs the next run's inputs —\n // re-encoding a re-encode, forever, losing quality every time.\n if (rawDir === outDir)\n throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);\n\n const config = resolveConfig(options.config);\n const sources = await discoverVideos(rawDir);\n const previous = readManifest(manifestPath);\n const plan = planMedia({\n sources,\n ...(options.config ? { config: options.config } : {}),\n ...(previous ? { previous } : {}),\n ...(options.force ? { force: options.force } : {}),\n probe: (file) => existsSync(join(outDir, file)),\n });\n\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n if (options.dry) return { plan, written: [], skipped };\n\n // Carry forward what is still valid, so the manifest on disk is a truthful\n // account at every moment — including after a crash halfway through.\n const entries: Record<string, MediaManifestEntry> = {};\n for (const job of plan.jobs) {\n const prev = previous?.entries[job.source.id];\n if (job.skip && prev) entries[job.source.id] = prev;\n }\n\n if (pending.length > 0) await requireFfmpeg();\n\n const written: string[] = [];\n for (const job of pending) {\n options.onProgress?.(`encoding ${job.source.id}`);\n for (const out of job.outputs) {\n const target = join(outDir, out.file);\n mkdirSync(dirname(target), { recursive: true });\n if (out.kind === 'webm') await encodeWebm(job.source.path, target, config);\n else if (out.kind === 'mp4') await encodeMp4(job.source.path, target, config);\n else await extractPoster(job.source.path, target, config);\n written.push(out.file);\n }\n entries[job.source.id] = {\n source: job.source.hash,\n settings: job.settings,\n outputs: job.outputs.map((o) => o.file),\n };\n writeManifest(manifestPath, toManifest(entries));\n }\n\n // Also write when nothing encoded, which is what prunes entries for sources\n // that have since been deleted. Skipped when there is nothing to record either\n // way, so an empty raw directory doesn't create a manifest.\n if (pending.length === 0 && (previous || Object.keys(entries).length > 0))\n writeManifest(manifestPath, toManifest(entries));\n\n return { plan, written, skipped };\n}\n\nexport { DISCOVER_SKIP, VIDEO_EXT, discoverVideos, hashFile } from './discover.ts';\nexport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nexport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nexport { formatPlan, pendingJobs, planMedia, resolveConfig, settingsHash } from './plan.ts';\nexport type { MediaPlanInput, OutputProbe, ResolvedMediaConfig } from './plan.ts';\nexport { DEFAULT_OUTPUTS, ENCODER_VERSION, OUTPUT_EXT } from './types.ts';\nexport type {\n MediaConfig,\n MediaJob,\n MediaManifest,\n MediaManifestEntry,\n MediaOutput,\n MediaPlan,\n MediaSource,\n OutputKind,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/media/discover.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/types.ts","../../src/media/plan.ts","../../src/media/index.ts"],"sourcesContent":["/**\n * Finding the source videos and fingerprinting them.\n *\n * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into\n * memory as a string. That is right for templates and catastrophic for a video —\n * the files this walks are routinely tens or hundreds of megabytes, which is also\n * why the hash is streamed rather than taken over a `readFileSync`.\n */\nimport { createHash } from 'node:crypto';\nimport { createReadStream, readdirSync } from 'node:fs';\nimport { extname, join, relative, sep } from 'node:path';\nimport type { MediaSource } from './types.ts';\n\n/** Container formats worth accepting as input. */\nexport const VIDEO_EXT: ReadonlySet<string> = new Set([\n '.mp4',\n '.mov',\n '.m4v',\n '.mkv',\n '.avi',\n '.webm',\n '.mpg',\n '.mpeg',\n]);\n\n/** Directories that never hold hand-managed source assets. */\nexport const DISCOVER_SKIP: ReadonlySet<string> = new Set(['node_modules', 'dist', '.git']);\n\n/** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */\nexport function hashFile(path: string): Promise<string> {\n return new Promise((resolve, reject) => {\n const hash = createHash('sha256');\n const stream = createReadStream(path);\n stream.on('error', reject);\n stream.on('data', (chunk) => hash.update(chunk));\n stream.on('end', () => resolve(hash.digest('hex').slice(0, 16)));\n });\n}\n\nfunction walk(dir: string, acc: string[]): void {\n for (const e of readdirSync(dir, { withFileTypes: true })) {\n if (e.name.startsWith('.')) continue;\n const p = join(dir, e.name);\n if (e.isDirectory()) {\n if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);\n } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) {\n acc.push(p);\n }\n }\n}\n\n/**\n * Every video under `dir`, with its content hash.\n *\n * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in\n * the same order on every machine, regardless of what the filesystem hands back.\n */\nexport async function discoverVideos(dir: string): Promise<MediaSource[]> {\n const paths: string[] = [];\n walk(dir, paths);\n const sources = await Promise.all(\n paths.map(async (path) => ({\n path,\n id: relative(dir, path).split(sep).join('/'),\n hash: await hashFile(path),\n })),\n );\n return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n","/**\n * The encoder. Executes a job and decides nothing.\n *\n * Every invocation goes through `execFile` with an argv array rather than a shell\n * string: a source path containing a space or a quote is ordinary, and the\n * shell-interpolated form these commands are usually written in breaks on both.\n */\nimport { execFile } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { promisify } from 'node:util';\nimport type { ResolvedMediaConfig } from './plan.ts';\n\nconst exec = promisify(execFile);\n\n/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */\nconst MAX_BUFFER = 16 * 1024 * 1024;\n\n/**\n * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has\n * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the\n * pairing VP9's encoding guide uses for comparable quality.\n */\nconst X264_CRF_RATIO = 0.72;\n\nlet probe: Promise<void> | undefined;\n\n/**\n * Fail early and clearly when ffmpeg isn't installed.\n *\n * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a\n * missing `oxipng` and warns once. That is right for a best-effort lossless crush,\n * where skipping costs a few kilobytes. It is wrong here: skipping produces no\n * output at all, and a build that carries on would ship the page referencing a\n * video that was never encoded.\n */\nexport function requireFfmpeg(): Promise<void> {\n probe ??= exec('ffmpeg', ['-version'], { maxBuffer: MAX_BUFFER }).then(\n () => undefined,\n () => {\n probe = undefined; // so a later call re-probes rather than caching a stale failure\n throw new Error(\n 'media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — ' +\n 'install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, ' +\n 'Windows: `winget install Gyan.FFmpeg`) and run this again.',\n );\n },\n );\n return probe;\n}\n\nasync function run(args: string[]): Promise<void> {\n try {\n await exec('ffmpeg', args, { maxBuffer: MAX_BUFFER });\n } catch (err) {\n const stderr = String((err as { stderr?: string }).stderr ?? '').trim();\n const tail = stderr.split('\\n').slice(-6).join('\\n');\n throw new Error(`media: ffmpeg failed${tail ? `\\n${tail}` : ''}`);\n }\n}\n\n/**\n * An encode that \"succeeds\" but writes nothing is the failure mode worth guarding:\n * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.\n * Without this the manifest would record the job as done and never retry it.\n */\nfunction assertWritten(file: string, what: string): void {\n if (!existsSync(file) || statSync(file).size === 0)\n throw new Error(\n `media: ${what} produced no output (${file}). Check --poster-time and the source.`,\n );\n}\n\n/** Scale filter, capping width and keeping the height even (yuv420p requires it). */\nfunction scaleArgs(maxWidth: number): string[] {\n if (maxWidth <= 0) return [];\n // ffmpeg parses the quotes itself, so the commas inside min() aren't read as a\n // filter separator. There is no shell here to strip them.\n return ['-vf', `scale='min(${maxWidth},iw)':-2`];\n}\n\n/** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */\nfunction bufsize(maxBitrate: string): string {\n const m = /^(\\d+(?:\\.\\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());\n if (!m)\n throw new Error(`media: maxBitrate \"${maxBitrate}\" is not a bitrate (try \"2M\" or \"800k\").`);\n return `${Number(m[1]) * 2}${m[2] ?? ''}`;\n}\n\nconst rateArgs = (maxBitrate: string | undefined): string[] =>\n maxBitrate ? ['-maxrate', maxBitrate, '-bufsize', bufsize(maxBitrate)] : [];\n\nconst audioArgs = (config: ResolvedMediaConfig, codec: string): string[] =>\n config.audio ? ['-c:a', codec, '-b:a', '128k'] : ['-an'];\n\nconst BASE = ['-y', '-nostdin', '-loglevel', 'error'];\n\n/**\n * VP9/WebM, two passes.\n *\n * The pass log goes into a fresh temp directory rather than the working directory.\n * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and\n * makes two concurrent encodes silently share one log — the second encode's rate\n * control then reads the first clip's statistics.\n */\nexport async function encodeWebm(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n const dir = mkdtempSync(join(tmpdir(), 'vitops-media-'));\n const passlog = join(dir, 'pass');\n const video = [\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libvpx-vp9',\n // -b:v 0 with -crf is VP9's constant-quality mode; with -maxrate it becomes\n // constrained quality. A fixed bitrate would be right at one resolution only.\n '-b:v',\n '0',\n '-crf',\n String(config.crf),\n '-row-mt',\n '1',\n ...rateArgs(config.maxBitrate),\n ];\n try {\n // Pass 1 only gathers statistics, so it never encodes audio and writes no file.\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n '-an',\n '-pass',\n '1',\n '-passlogfile',\n passlog,\n '-f',\n 'null',\n '-',\n ]);\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n ...audioArgs(config, 'libopus'),\n '-pass',\n '2',\n '-passlogfile',\n passlog,\n output,\n ]);\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n assertWritten(output, 'webm encode');\n}\n\n/**\n * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the\n * in-app webviews that still don't decode VP9.\n *\n * `+faststart` moves the moov atom to the front. Without it the browser has to\n * download the whole file before it can show frame one, which on a hero video\n * looks exactly like the optimisation didn't work.\n */\nexport async function encodeMp4(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libx264',\n '-preset',\n 'slow',\n '-crf',\n String(Math.round(config.crf * X264_CRF_RATIO)),\n // Required by Safari and by every hardware decoder worth naming.\n '-pix_fmt',\n 'yuv420p',\n '-movflags',\n '+faststart',\n ...rateArgs(config.maxBitrate),\n ...audioArgs(config, 'aac'),\n output,\n ]);\n assertWritten(output, 'mp4 encode');\n}\n\n/** The poster frame. `-ss` before `-i` so the seek is a fast one. */\nexport async function extractPoster(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n ...(config.posterTime > 0 ? ['-ss', String(config.posterTime)] : []),\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-frames:v',\n '1',\n '-q:v',\n '2',\n output,\n ]);\n assertWritten(output, 'poster extraction');\n}\n","/**\n * The cache — what makes a second run free.\n *\n * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout\n * checks in both the processed outputs and this file, so CI never needs ffmpeg\n * and the outputs only change when someone deliberately re-encodes. That is why\n * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce\n * a byte-identical file, or every build shows up as a diff.\n */\nimport type { MediaManifest, MediaManifestEntry } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const MEDIA_MANIFEST_PATH = '.vitops/media-manifest.json';\n\n/**\n * Read the manifest, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, which re-encodes everything.\n * The opposite reading — treating it as an empty but valid manifest — is the same\n * shape of bug `readSnapshot` avoids, except here it would be silent: nothing\n * would encode, and the build would reference outputs that were never written.\n */\nexport function readManifest(path: string): MediaManifest | undefined {\n return readVersioned<MediaManifest, 'entries'>(path, 'entries');\n}\n\n/** Build a manifest with its keys and output lists in a stable order. */\nexport function toManifest(entries: Record<string, MediaManifestEntry>): MediaManifest {\n const sorted: Record<string, MediaManifestEntry> = {};\n for (const key of Object.keys(entries).sort()) {\n const entry = entries[key] as MediaManifestEntry;\n sorted[key] = { ...entry, outputs: [...entry.outputs].sort() };\n }\n return { version: 1, entries: sorted };\n}\n\n/**\n * Write the manifest, creating its directory.\n *\n * Record a job only once its outputs are on disk. Writing eagerly would mark a\n * source as encoded that isn't, and because the next run trusts this file, the\n * result is a missing video that no rebuild ever fixes.\n */\nexport function writeManifest(path: string, manifest: MediaManifest): void {\n writeVersioned(path, manifest);\n}\n","/**\n * The media module's own option surface.\n *\n * Everything here describes *how to encode*, never *what the site is* — which is\n * why there is no `Config` block behind it. `legal`, `icons` and `indexing`\n * are anchored to a site config because what they emit describes the site; an\n * encoder setting describes a build step.\n */\n\n/** What a source video is turned into. */\nexport type OutputKind = 'webm' | 'mp4' | 'poster';\n\n/** The default set: a modern codec, a universal fallback, and a poster frame. */\nexport const DEFAULT_OUTPUTS: OutputKind[] = ['webm', 'mp4', 'poster'];\n\n/** File extension per output kind. */\nexport const OUTPUT_EXT: Record<OutputKind, string> = {\n webm: '.webm',\n mp4: '.mp4',\n poster: '.jpg',\n};\n\n/**\n * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the\n * output bytes. It is part of the settings hash, so bumping it re-encodes every\n * source on the next run.\n *\n * Bump it deliberately and rarely. Consumers commit their processed outputs (see\n * the README), so every bump is a large binary diff in someone's repository —\n * that cost is worth paying for a real encoder fix and not for a tidy-up.\n */\nexport const ENCODER_VERSION = 1;\n\n/** How to encode. Every field here feeds the settings hash. */\nexport interface MediaConfig {\n /**\n * Cap the output width in pixels, preserving aspect ratio. Default 1920.\n *\n * A source larger than its display size is the usual reason a hero video is\n * enormous, so this is on by default rather than opt-in. `0` disables scaling.\n */\n maxWidth?: number | undefined;\n /**\n * Constant-quality level, on **VP9's CRF scale (0–63, lower is better)**.\n * Default 32, which is the range VP9's own guidance recommends for 1080p.\n *\n * H.264 uses a different scale (0–51), so the MP4 fallback is encoded at\n * `round(crf * 0.72)` — 23 at the default, x264's own default. One knob, because\n * two would drift apart and nobody would know which one mattered.\n */\n crf?: number | undefined;\n /**\n * Optional ceiling, e.g. `'2M'`. Absent by default: a bitrate cap makes the\n * encode constrained-quality rather than constant-quality, which is right when\n * you are protecting a data budget and wrong as a default, because the correct\n * cap depends on the resolution you are capping.\n */\n maxBitrate?: string | undefined;\n /**\n * Keep the audio track. Default false — the common case is a muted autoplay\n * loop, where the audio stream is bytes nobody will ever hear.\n */\n audio?: boolean | undefined;\n /**\n * Timestamp (seconds) the poster frame is taken from. Default 0.\n *\n * Frame 0 is often black on a clip that fades in, which is worth knowing before\n * concluding the poster is broken.\n */\n posterTime?: number | undefined;\n /** Which outputs to produce. Default `DEFAULT_OUTPUTS`. */\n outputs?: OutputKind[] | undefined;\n}\n\n/** A discovered source video. */\nexport interface MediaSource {\n path: string;\n /**\n * Path relative to the raw directory, POSIX-separated — the manifest key.\n * Relative so a manifest stays valid when the project moves or is cloned\n * somewhere else, which is the whole point of committing it.\n */\n id: string;\n /** sha256 of the file contents, truncated. */\n hash: string;\n}\n\n/** One file an encode will write. */\nexport interface MediaOutput {\n kind: OutputKind;\n /** Path relative to the output directory, POSIX-separated. */\n file: string;\n}\n\n/** One source and everything decided about it. */\nexport interface MediaJob {\n source: MediaSource;\n outputs: MediaOutput[];\n /** Hash of the settings these outputs were produced under. */\n settings: string;\n /** Why this job needs no work. Present iff it is being skipped. */\n skip?: string | undefined;\n}\n\n/** The complete account of a run, decided before any of it happens. */\nexport interface MediaPlan {\n jobs: MediaJob[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\n/** One manifest record: what these outputs were made from, and under what settings. */\nexport interface MediaManifestEntry {\n /** `MediaSource.hash` at the time of encoding. */\n source: string;\n settings: string;\n /** Output paths relative to the output directory. */\n outputs: string[];\n}\n\n/**\n * The cache. A committed artifact, so it carries no timestamp and its keys are\n * sorted — a rebuild of unchanged sources must produce a byte-identical file, or\n * it shows up as noise in every diff.\n */\nexport interface MediaManifest {\n version: 1;\n entries: Record<string, MediaManifestEntry>;\n}\n","/**\n * Every decision `vitops media` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a\n * source needs re-encoding — the only thing here that can be wrong in a way that\n * costs minutes or ships a stale video — is decided from data passed in, and\n * asserted in `plan.test.ts` on a machine with no ffmpeg installed.\n *\n * That split is the same one `indexing/plan.ts` makes against its I/O modules, and\n * for the same reason: `--dry` should be a complete account of a run rather than\n * an approximation of one.\n */\nimport { createHash } from 'node:crypto';\nimport {\n DEFAULT_OUTPUTS,\n ENCODER_VERSION,\n type MediaConfig,\n type MediaJob,\n type MediaManifest,\n type MediaOutput,\n type MediaPlan,\n type MediaSource,\n OUTPUT_EXT,\n type OutputKind,\n} from './types.ts';\n\n/** Defaults, resolved once so the settings hash is computed over real values. */\nexport interface ResolvedMediaConfig {\n maxWidth: number;\n crf: number;\n maxBitrate: string | undefined;\n audio: boolean;\n posterTime: number;\n outputs: OutputKind[];\n}\n\n/**\n * Injected existence probe — `plan.ts` touches no filesystem itself.\n *\n * Takes a path relative to the output directory. The same injection shape as\n * `collectEntries(source, read)` in `indexing/sitemap.ts`.\n */\nexport type OutputProbe = (relativePath: string) => boolean;\n\nexport interface MediaPlanInput {\n sources: MediaSource[];\n config?: MediaConfig | undefined;\n /** The previous run's manifest, if there is a usable one. */\n previous?: MediaManifest | undefined;\n /** Whether an output file is already on disk. Defaults to \"nothing exists\". */\n probe?: OutputProbe | undefined;\n /** `--force`: re-encode regardless of the cache. */\n force?: boolean | undefined;\n}\n\nconst sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16);\n\n/** Fill in defaults. Exported because the encoder and the hash must agree on them. */\nexport function resolveConfig(config: MediaConfig = {}): ResolvedMediaConfig {\n return {\n maxWidth: config.maxWidth ?? 1920,\n crf: config.crf ?? 32,\n maxBitrate: config.maxBitrate,\n audio: config.audio ?? false,\n posterTime: config.posterTime ?? 0,\n outputs: config.outputs ?? DEFAULT_OUTPUTS,\n };\n}\n\n/**\n * Hash the settings that decide the output bytes.\n *\n * Order-independent in the one place it could bite (`outputs` is sorted), so\n * re-ordering the list in a config doesn't re-encode a whole library.\n */\nexport function settingsHash(config: ResolvedMediaConfig): string {\n return sha(\n JSON.stringify({\n v: ENCODER_VERSION,\n maxWidth: config.maxWidth,\n crf: config.crf,\n maxBitrate: config.maxBitrate ?? null,\n audio: config.audio,\n posterTime: config.posterTime,\n outputs: [...config.outputs].sort(),\n }),\n );\n}\n\n/** `a/b/clip.mp4` → `a/b/clip` */\nfunction stem(id: string): string {\n const dot = id.lastIndexOf('.');\n const slash = id.lastIndexOf('/');\n return dot > slash ? id.slice(0, dot) : id;\n}\n\nfunction outputsFor(source: MediaSource, config: ResolvedMediaConfig): MediaOutput[] {\n const base = stem(source.id);\n return config.outputs.map((kind) => ({ kind, file: `${base}${OUTPUT_EXT[kind]}` }));\n}\n\n/**\n * Decide what to encode.\n *\n * A job is skipped only when all three hold: the source contents hash the same as\n * last time, the settings hash the same, and every declared output is still on\n * disk. Anything else re-encodes — including an absent or unreadable manifest,\n * which reads as \"encode everything\" and never as \"everything is done\".\n *\n * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both\n * want `hero.webm`). That is a layout mistake rather than a runtime condition, and\n * the failure it would otherwise cause — two sources overwriting each other every\n * run, each invalidating the other's cache entry — is invisible from the outside.\n */\nexport function planMedia(input: MediaPlanInput): MediaPlan {\n const config = resolveConfig(input.config);\n const settings = settingsHash(config);\n const probe = input.probe ?? (() => false);\n const notes: string[] = [];\n\n const jobs: MediaJob[] = [];\n const claimed = new Map<string, string>();\n\n for (const source of input.sources) {\n const outputs = outputsFor(source, config);\n for (const out of outputs) {\n const owner = claimed.get(out.file);\n if (owner !== undefined && owner !== source.id)\n throw new Error(\n `media: \"${source.id}\" and \"${owner}\" both produce \"${out.file}\". ` +\n 'Two sources with the same name in one directory overwrite each other — rename one.',\n );\n claimed.set(out.file, source.id);\n }\n\n const prev = input.previous?.entries[source.id];\n const cached =\n !input.force &&\n prev !== undefined &&\n prev.source === source.hash &&\n prev.settings === settings &&\n outputs.every((o) => probe(o.file));\n\n jobs.push({ source, outputs, settings, ...(cached ? { skip: 'unchanged' } : {}) });\n }\n\n const pending = jobs.filter((j) => !j.skip).length;\n if (input.sources.length === 0) notes.push('no video files found');\n else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);\n else if (!input.previous && pending > 0)\n notes.push('no manifest — encoding everything (a first run, or a cache that was not restored)');\n\n return { jobs, notes };\n}\n\n/** The jobs that will actually run. */\nexport const pendingJobs = (plan: MediaPlan): MediaJob[] => plan.jobs.filter((j) => !j.skip);\n\n/** Human-readable account of a plan, for `--dry` and for the run summary. */\nexport function formatPlan(plan: MediaPlan): string {\n const lines: string[] = [];\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n\n lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? '' : 's'}`);\n lines.push(` encode: ${pending.length}`);\n lines.push(` skipped: ${skipped} (unchanged)`);\n\n for (const job of pending)\n lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(', ')}`);\n\n for (const note of plan.notes) lines.push(` ! ${note}`);\n return lines.join('\\n');\n}\n","/**\n * `vitops media` — turning a directory of raw video into web-ready assets.\n *\n * A separate subpath (`@getvitops/utils/media`) rather than part of the package\n * index, matching `./favicon` and `./indexing`: this module shells out to an\n * external encoder, and a consumer importing the content helpers shouldn't carry\n * it.\n *\n * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`\n * decides everything and touches nothing; `ffmpeg.ts` executes and decides\n * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is\n * what lets `--dry` be a complete account of a run, and what lets the cache — the\n * part with real consequences, since getting it wrong means either minutes of\n * needless encoding or a stale video shipped — be tested with no ffmpeg installed.\n */\nimport { existsSync, mkdirSync, statSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { discoverVideos } from './discover.ts';\nimport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nimport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nimport { pendingJobs, planMedia, resolveConfig } from './plan.ts';\nimport type { MediaConfig, MediaManifestEntry, MediaPlan } from './types.ts';\n\nexport interface ProcessMediaOptions {\n /** Directory of unprocessed sources. Walked recursively. */\n raw: string;\n /** Where the encoded outputs go. Structure under `raw` is preserved. */\n out: string;\n config?: MediaConfig | undefined;\n /** Cache location. Default `.vitops/media-manifest.json`. */\n manifest?: string | undefined;\n /** Re-encode regardless of the cache. */\n force?: boolean | undefined;\n /** Decide everything, run nothing. */\n dry?: boolean | undefined;\n /** Called before each source is encoded — these take minutes, so say so. */\n onProgress?: ((message: string) => void) | undefined;\n}\n\nexport interface ProcessMediaResult {\n plan: MediaPlan;\n /** Outputs written this run, relative to `out`. */\n written: string[];\n /** Sources that needed no work. */\n skipped: number;\n}\n\n/**\n * Encode everything under `raw` that has changed since the last run.\n *\n * Callers get the cache for free because it lives here rather than in either of\n * them: `vitops media` and the Astro integration are equally incremental, since\n * they call this same function.\n */\nexport async function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult> {\n const rawDir = resolve(options.raw);\n const outDir = resolve(options.out);\n const manifestPath = resolve(options.manifest ?? MEDIA_MANIFEST_PATH);\n\n if (!existsSync(rawDir) || !statSync(rawDir).isDirectory())\n throw new Error(`media: raw directory not found: ${rawDir}`);\n // Sharing one directory would make each run's outputs the next run's inputs —\n // re-encoding a re-encode, forever, losing quality every time.\n if (rawDir === outDir)\n throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);\n\n const config = resolveConfig(options.config);\n const sources = await discoverVideos(rawDir);\n const previous = readManifest(manifestPath);\n const plan = planMedia({\n sources,\n ...(options.config ? { config: options.config } : {}),\n ...(previous ? { previous } : {}),\n ...(options.force ? { force: options.force } : {}),\n probe: (file) => existsSync(join(outDir, file)),\n });\n\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n if (options.dry) return { plan, written: [], skipped };\n\n // Carry forward what is still valid, so the manifest on disk is a truthful\n // account at every moment — including after a crash halfway through.\n const entries: Record<string, MediaManifestEntry> = {};\n for (const job of plan.jobs) {\n const prev = previous?.entries[job.source.id];\n if (job.skip && prev) entries[job.source.id] = prev;\n }\n\n if (pending.length > 0) await requireFfmpeg();\n\n const written: string[] = [];\n for (const job of pending) {\n options.onProgress?.(`encoding ${job.source.id}`);\n for (const out of job.outputs) {\n const target = join(outDir, out.file);\n mkdirSync(dirname(target), { recursive: true });\n if (out.kind === 'webm') await encodeWebm(job.source.path, target, config);\n else if (out.kind === 'mp4') await encodeMp4(job.source.path, target, config);\n else await extractPoster(job.source.path, target, config);\n written.push(out.file);\n }\n entries[job.source.id] = {\n source: job.source.hash,\n settings: job.settings,\n outputs: job.outputs.map((o) => o.file),\n };\n writeManifest(manifestPath, toManifest(entries));\n }\n\n // Also write when nothing encoded, which is what prunes entries for sources\n // that have since been deleted. Skipped when there is nothing to record either\n // way, so an empty raw directory doesn't create a manifest.\n if (pending.length === 0 && (previous || Object.keys(entries).length > 0))\n writeManifest(manifestPath, toManifest(entries));\n\n return { plan, written, skipped };\n}\n\nexport { DISCOVER_SKIP, VIDEO_EXT, discoverVideos, hashFile } from './discover.ts';\nexport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nexport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nexport { formatPlan, pendingJobs, planMedia, resolveConfig, settingsHash } from './plan.ts';\nexport type { MediaPlanInput, OutputProbe, ResolvedMediaConfig } from './plan.ts';\nexport { DEFAULT_OUTPUTS, ENCODER_VERSION, OUTPUT_EXT } from './types.ts';\nexport type {\n MediaConfig,\n MediaJob,\n MediaManifest,\n MediaManifestEntry,\n MediaOutput,\n MediaPlan,\n MediaSource,\n OutputKind,\n} from './types.ts';\n"],"mappings":"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"}
|
package/dist/notify/index.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { t as ConversionEvent } from "../types-
|
|
2
|
-
|
|
1
|
+
import { t as ConversionEvent } from "../types-7ELci8RQ.mjs";
|
|
3
2
|
//#region src/notify/types.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* The notification module's option surface.
|
|
@@ -44,6 +43,13 @@ interface EmailChannelConfig {
|
|
|
44
43
|
interface NotificationsConfig {
|
|
45
44
|
/** A bare address is shorthand for `{ provider: 'cloudflare', to }`. */
|
|
46
45
|
email?: string | EmailChannelConfig | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Optional action-link template added to the conversion email as an
|
|
48
|
+
* "Actions" section — `{clickId}` is replaced with the ad click ID captured
|
|
49
|
+
* on `_ac` (gclid, fbclid, …). Omitted from the email when the event carries
|
|
50
|
+
* no click ID, rather than rendering a broken link — see `renderEmail`.
|
|
51
|
+
*/
|
|
52
|
+
actionUrl?: string | undefined;
|
|
47
53
|
}
|
|
48
54
|
/** Everything `planNotifications` needs. Assembled by the caller from the config. */
|
|
49
55
|
interface NotifyContext {
|
|
@@ -109,9 +115,9 @@ interface EmailBinding {
|
|
|
109
115
|
//#endregion
|
|
110
116
|
//#region src/notify/plan.d.ts
|
|
111
117
|
/** Default Workers binding name for Cloudflare Email Sending. */
|
|
112
|
-
declare const DEFAULT_EMAIL_BINDING = "EMAIL";
|
|
118
|
+
export declare const DEFAULT_EMAIL_BINDING = "EMAIL";
|
|
113
119
|
/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */
|
|
114
|
-
declare function normalizeEmailChannel(input: string | EmailChannelConfig | undefined): EmailChannelConfig | undefined;
|
|
120
|
+
export declare function normalizeEmailChannel(input: string | EmailChannelConfig | undefined): EmailChannelConfig | undefined;
|
|
115
121
|
/**
|
|
116
122
|
* Who the notification reaches.
|
|
117
123
|
*
|
|
@@ -119,7 +125,7 @@ declare function normalizeEmailChannel(input: string | EmailChannelConfig | unde
|
|
|
119
125
|
* addresses, primary first. This cascade is why a site that has already said
|
|
120
126
|
* where it receives mail doesn't have to say it twice.
|
|
121
127
|
*/
|
|
122
|
-
declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
128
|
+
export declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
123
129
|
/**
|
|
124
130
|
* The From address.
|
|
125
131
|
*
|
|
@@ -127,14 +133,14 @@ declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContex
|
|
|
127
133
|
* site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,
|
|
128
134
|
* and guessing from the recipient's domain would send that failure every time.
|
|
129
135
|
*/
|
|
130
|
-
declare function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
136
|
+
export declare function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
131
137
|
/**
|
|
132
138
|
* Decide what will be sent, and say why anything won't be.
|
|
133
139
|
*
|
|
134
140
|
* Every "off" carries a reason rather than being an absence, so a caller can
|
|
135
141
|
* report the misconfiguration instead of reporting success over a no-op.
|
|
136
142
|
*/
|
|
137
|
-
declare function planNotifications(ctx: NotifyContext): NotificationPlan;
|
|
143
|
+
export declare function planNotifications(ctx: NotifyContext): NotificationPlan;
|
|
138
144
|
//#endregion
|
|
139
145
|
//#region src/notify/render.d.ts
|
|
140
146
|
/** One section: a heading and its lines. Empty sections are dropped by the caller. */
|
|
@@ -148,12 +154,15 @@ interface Section {
|
|
|
148
154
|
* Extracted so the text and HTML versions cannot disagree about content. They
|
|
149
155
|
* differ only in markup; a field added here appears in both.
|
|
150
156
|
*/
|
|
151
|
-
declare function describeEvent(event: ConversionEvent): {
|
|
157
|
+
export declare function describeEvent(event: ConversionEvent): {
|
|
152
158
|
subject: string;
|
|
153
159
|
sections: Section[];
|
|
154
160
|
};
|
|
155
161
|
/** Render for e-mail. Both halves, always — see `RenderedMessage`. */
|
|
156
|
-
declare function renderEmail(event: ConversionEvent
|
|
162
|
+
export declare function renderEmail(event: ConversionEvent, extra?: {
|
|
163
|
+
notes?: string[] | undefined;
|
|
164
|
+
actionUrl?: string | undefined;
|
|
165
|
+
}): RenderedMessage;
|
|
157
166
|
//#endregion
|
|
158
167
|
//#region src/notify/email.d.ts
|
|
159
168
|
interface SendEmailOptions {
|
|
@@ -183,7 +192,7 @@ interface SendEmailOptions {
|
|
|
183
192
|
* response because our mail didn't go out would turn a lost notification into a
|
|
184
193
|
* lost conversion. The failure is returned instead, for the caller to log.
|
|
185
194
|
*/
|
|
186
|
-
declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: EmailBinding, options?: SendEmailOptions): Promise<ChannelResult>;
|
|
195
|
+
export declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: EmailBinding, options?: SendEmailOptions): Promise<ChannelResult>;
|
|
187
196
|
/**
|
|
188
197
|
* Print the message instead of sending it.
|
|
189
198
|
*
|
|
@@ -191,17 +200,17 @@ declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: E
|
|
|
191
200
|
* no-op, because "I never saw the notification" is the symptom of both a broken
|
|
192
201
|
* integration and a working one nobody could observe.
|
|
193
202
|
*/
|
|
194
|
-
declare function logEmail(plan: EmailPlan, message: RenderedMessage, log?: (line: string) => void): ChannelResult;
|
|
203
|
+
export declare function logEmail(plan: EmailPlan, message: RenderedMessage, log?: (line: string) => void): ChannelResult;
|
|
195
204
|
//#endregion
|
|
196
205
|
//#region src/notify/index.d.ts
|
|
197
|
-
interface NotifyDeps {
|
|
206
|
+
export interface NotifyDeps {
|
|
198
207
|
/**
|
|
199
208
|
* The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification
|
|
200
209
|
* is printed instead — the dev path.
|
|
201
210
|
*/
|
|
202
211
|
email?: EmailBinding | undefined;
|
|
203
212
|
}
|
|
204
|
-
interface NotifyResult {
|
|
213
|
+
export interface NotifyResult {
|
|
205
214
|
results: ChannelResult[];
|
|
206
215
|
/** True when at least one channel delivered. */
|
|
207
216
|
delivered: boolean;
|
|
@@ -213,7 +222,9 @@ interface NotifyResult {
|
|
|
213
222
|
* them may throw into the request that produced the event. Every outcome —
|
|
214
223
|
* delivered, skipped, failed — comes back as data for the caller to log.
|
|
215
224
|
*/
|
|
216
|
-
declare function notify(event: ConversionEvent, ctx: NotifyContext, deps?: NotifyDeps
|
|
225
|
+
export declare function notify(event: ConversionEvent, ctx: NotifyContext, deps?: NotifyDeps, extra?: {
|
|
226
|
+
notes?: string[] | undefined;
|
|
227
|
+
}): Promise<NotifyResult>;
|
|
217
228
|
//#endregion
|
|
218
|
-
export {
|
|
229
|
+
export type { ChannelPlan, ChannelResult, EmailBinding, EmailChannelConfig, EmailPlan, NotificationPlan, NotificationsConfig, NotifyContext, RenderedMessage };
|
|
219
230
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -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":"
|
|
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":";;;;;;;;;;;;;;;;UAeiB;EACf;EACA;EACA;;;;;;;;UASe;EACf;;EAEA;;EAEA;;EAEA;EACA;;;;;EAKA;;UAGe;;EAEf,iBAAiB;;;;;;;EAOjB;;;UAOe;EACf,gBAAgB;;EAEhB;;EAEA;;;;;EAKA;;;UAIe;EACf;;EAEA;;UAGe,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf,OAAO;;EAEP;;UAGe;EACf;EACA;;EAEA;;EAEA;;;;;;;;;;UAWe;EACf,KAAK;IACH;IACA;MAAiB;MAAe;;IAChC;IACA;IACA;IACA;MACE;;;;;qBCrGO;;wBAGG,sBACd,gBAAgB,iCACf;;;;;;;;wBAYa,iBACd,SAAS,oBACT,KAAK;;;;;;;;wBAaS,cAAc,SAAS,oBAAoB,KAAK;;;;;;;wBAmBhD,kBAAkB,KAAK,gBAAgB;;;;UC7B7C;EACR;EACA;;;;;;;;wBAyDc,cAAc,OAAO;EAAoB;EAAiB,UAAU;;;wBA4DpE,YACd,OAAO,iBACP;EAAS;EAA8B;IACtC;;;UCxHc;;EAEf;;;;;;;;;;;;;EAaA;;EAEA,SAAS,eAAe;;;;;;;;;;wBA0CJ,UACpB,MAAM,WACN,SAAS,iBACT,SAAS,cACT,UAAS,mBACR,QAAQ;;;;;;;;wBAkDK,SACd,MAAM,WACN,SAAS,iBACT,OAAM,wBACL;;;iBC/Hc;;;;;EAKf,QAAQ;;iBAGO;EACf,SAAS;;EAET;;;;;;;;;wBAUoB,OACpB,OAAO,iBACP,KAAK,eACL,OAAM,YACN;EAAS;IACR,QAAQ"}
|
package/dist/notify/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as identifySubmitter } from "../identity-BoRddTx3.mjs";
|
|
1
2
|
import { n as identifyPlatform, t as getPrimaryClickId } from "../platform-0nlOIy8h.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/notify/email.ts
|
|
@@ -8,7 +9,7 @@ import { n as identifyPlatform, t as getPrimaryClickId } from "../platform-0nlOI
|
|
|
8
9
|
* payload, a suppressed recipient — where a retry is pure latency and the send
|
|
9
10
|
* will fail identically. Retrying those also burns the daily quota.
|
|
10
11
|
*/
|
|
11
|
-
const RETRYABLE = new Set([
|
|
12
|
+
const RETRYABLE = /* @__PURE__ */ new Set([
|
|
12
13
|
"E_RATE_LIMIT_EXCEEDED",
|
|
13
14
|
"E_DELIVERY_FAILED",
|
|
14
15
|
"E_INTERNAL_SERVER_ERROR"
|
|
@@ -230,7 +231,7 @@ function planEmail(ctx) {
|
|
|
230
231
|
* they submitted is untrue. The same rule `legal/derive.ts` applies when deriving
|
|
231
232
|
* `piiCollected`, and for the same reason. Callers may strip more.
|
|
232
233
|
*/
|
|
233
|
-
const OMIT_FIELDS = new Set([
|
|
234
|
+
const OMIT_FIELDS = /* @__PURE__ */ new Set([
|
|
234
235
|
"website",
|
|
235
236
|
"_honeypot",
|
|
236
237
|
"honeypot",
|
|
@@ -242,6 +243,26 @@ function label(key) {
|
|
|
242
243
|
function escapeHtml(value) {
|
|
243
244
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
244
245
|
}
|
|
246
|
+
function factLine([label, value]) {
|
|
247
|
+
return {
|
|
248
|
+
label,
|
|
249
|
+
text: value,
|
|
250
|
+
html: escapeHtml(value)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function linkLine(label, href) {
|
|
254
|
+
return {
|
|
255
|
+
label,
|
|
256
|
+
text: href,
|
|
257
|
+
html: `<a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function toRenderSection(section) {
|
|
261
|
+
return {
|
|
262
|
+
heading: section.heading,
|
|
263
|
+
lines: section.lines.map(factLine)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
245
266
|
/**
|
|
246
267
|
* Who submitted the form, for the subject line.
|
|
247
268
|
*
|
|
@@ -253,18 +274,7 @@ function escapeHtml(value) {
|
|
|
253
274
|
* even when no name field exists.
|
|
254
275
|
*/
|
|
255
276
|
function submitterName(formData) {
|
|
256
|
-
|
|
257
|
-
for (const k of keys) {
|
|
258
|
-
const v = formData[k]?.trim();
|
|
259
|
-
if (v) return v;
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
const full = get("name", "full_name", "fullName", "fullname", "your-name");
|
|
263
|
-
if (full) return full;
|
|
264
|
-
const first = get("first_name", "firstName", "firstname", "given_name");
|
|
265
|
-
const last = get("last_name", "lastName", "lastname", "family_name", "surname");
|
|
266
|
-
if (first || last) return [first, last].filter(Boolean).join(" ");
|
|
267
|
-
return get("email", "email_address", "emailAddress") ?? "Unknown";
|
|
277
|
+
return identifySubmitter(formData).fullName;
|
|
268
278
|
}
|
|
269
279
|
/**
|
|
270
280
|
* The event as sections — the shared structure behind both renderings.
|
|
@@ -281,6 +291,10 @@ function describeEvent(event) {
|
|
|
281
291
|
if (event.type === "form" && event.formData) {
|
|
282
292
|
subject = `[Contact Form] New submission from ${submitterName(event.formData)}`;
|
|
283
293
|
for (const [key, value] of Object.entries(event.formData)) if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);
|
|
294
|
+
} else if (event.type === "email") {
|
|
295
|
+
const email = event.email ?? "unknown";
|
|
296
|
+
subject = `[Email Click] Visitor emailing ${email}`;
|
|
297
|
+
contact.push(["Email", email]);
|
|
284
298
|
} else {
|
|
285
299
|
const phone = event.phone ?? "unknown";
|
|
286
300
|
subject = `[Phone Call] Visitor calling ${phone}`;
|
|
@@ -329,12 +343,25 @@ function describeEvent(event) {
|
|
|
329
343
|
};
|
|
330
344
|
}
|
|
331
345
|
/** Render for e-mail. Both halves, always — see `RenderedMessage`. */
|
|
332
|
-
function renderEmail(event) {
|
|
333
|
-
const { subject, sections } = describeEvent(event);
|
|
346
|
+
function renderEmail(event, extra = {}) {
|
|
347
|
+
const { subject, sections: eventSections } = describeEvent(event);
|
|
348
|
+
const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;
|
|
349
|
+
const actionSection = extra.actionUrl && clickId ? {
|
|
350
|
+
heading: "Actions",
|
|
351
|
+
lines: [linkLine("View in ad platform", extra.actionUrl.replace(/\{clickId\}/g, encodeURIComponent(clickId.value)))]
|
|
352
|
+
} : null;
|
|
353
|
+
const sections = [
|
|
354
|
+
...eventSections.map(toRenderSection),
|
|
355
|
+
...actionSection ? [actionSection] : [],
|
|
356
|
+
...extra.notes && extra.notes.length ? [{
|
|
357
|
+
heading: "Delivery",
|
|
358
|
+
lines: extra.notes.map((n) => factLine(["CRM", n]))
|
|
359
|
+
}] : []
|
|
360
|
+
];
|
|
334
361
|
return {
|
|
335
362
|
subject,
|
|
336
|
-
text: sections.map((s) => [`--- ${s.heading} ---`, ...s.lines.map((
|
|
337
|
-
html: sections.map((s) => `<h2 style="font:600 14px system-ui;margin:24px 0 8px">${escapeHtml(s.heading)}</h2><table style="font:14px system-ui;border-collapse:collapse">${s.lines.map((
|
|
363
|
+
text: sections.map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join("\n")).join("\n\n"),
|
|
364
|
+
html: sections.map((s) => `<h2 style="font:600 14px system-ui;margin:24px 0 8px">${escapeHtml(s.heading)}</h2><table style="font:14px system-ui;border-collapse:collapse">${s.lines.map((l) => `<tr><td style="padding:2px 12px 2px 0;color:#666;vertical-align:top">${escapeHtml(l.label)}</td><td style="padding:2px 0">${l.html}</td></tr>`).join("")}</table>`).join("")
|
|
338
365
|
};
|
|
339
366
|
}
|
|
340
367
|
|
|
@@ -347,11 +374,14 @@ function renderEmail(event) {
|
|
|
347
374
|
* them may throw into the request that produced the event. Every outcome —
|
|
348
375
|
* delivered, skipped, failed — comes back as data for the caller to log.
|
|
349
376
|
*/
|
|
350
|
-
async function notify(event, ctx, deps = {}) {
|
|
377
|
+
async function notify(event, ctx, deps = {}, extra = {}) {
|
|
351
378
|
const plan = planNotifications(ctx);
|
|
352
379
|
const results = [];
|
|
353
380
|
if (plan.email.enabled) {
|
|
354
|
-
const message = renderEmail(event
|
|
381
|
+
const message = renderEmail(event, {
|
|
382
|
+
...extra,
|
|
383
|
+
actionUrl: ctx.notifications?.actionUrl
|
|
384
|
+
});
|
|
355
385
|
results.push(deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message));
|
|
356
386
|
} else results.push({
|
|
357
387
|
channel: "email",
|