@replayablejs/export 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,1174 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
4
+ import { load } from "cheerio";
5
+ import { build, transform } from "esbuild";
6
+ import { deflate } from "pako";
7
+ import { parse } from "acorn";
8
+ import { minify } from "terser";
9
+ import { Zip, ZipDeflate } from "fflate";
10
+ import { simple } from "acorn-walk";
11
+ import { createVariants, defineConfig } from "@replayablejs/config";
12
+ //#region src/shared/filesystem-error.ts
13
+ /** Reports whether a filesystem operation failed because its target path is absent. */
14
+ function isMissingPathError(error) {
15
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
16
+ }
17
+ //#endregion
18
+ //#region src/shared/canonical-path.ts
19
+ /**
20
+ * Resolves symlinks in the existing portion of a path.
21
+ *
22
+ * Generated directories commonly do not exist yet. In that case, the nearest
23
+ * existing ancestor is canonicalized and the missing path segments are appended
24
+ * unchanged. For example, if "generated" is a symlink, resolving
25
+ * "generated/exports" exposes its real destination before cleanup can use it.
26
+ */
27
+ async function resolveCanonicalPath(path) {
28
+ const missingSegments = [];
29
+ let existingPath = path;
30
+ while (true) {
31
+ try {
32
+ const canonicalPath = await realpath(existingPath);
33
+ return resolve(canonicalPath, ...missingSegments);
34
+ } catch (error) {
35
+ if (!isMissingPathError(error)) throw error;
36
+ }
37
+ const parent = dirname(existingPath);
38
+ if (parent === existingPath) throw new Error(`Unable to resolve an existing ancestor for generated path: ${path}.`);
39
+ missingSegments.unshift(basename(existingPath));
40
+ existingPath = parent;
41
+ }
42
+ }
43
+ //#endregion
44
+ //#region src/emission/write-export-artifacts.ts
45
+ /**
46
+ * Writes every prepared artifact before replacing the last successful export directory.
47
+ *
48
+ * The caller has already prepared the HTML/ZIP bytes and resolved unique, flat
49
+ * output filenames. This function only handles filesystem delivery; it does not
50
+ * build, compress, validate, or rename individual network artifacts.
51
+ *
52
+ * Delivery has three stages:
53
+ * 1. Write all new files into a temporary sibling directory. Existing exports stay
54
+ * untouched while those writes run, including when a write fails.
55
+ * 2. Move the existing export directory into a backup, then move the completed
56
+ * replacement into its final location. Old files are replaced as a set, not merged.
57
+ * 3. Remove the temporary directory and the now-obsolete backup after success.
58
+ *
59
+ * If installing the replacement fails, restore the backup before rejecting. If
60
+ * restoration also fails, keep the backup and report its path in an AggregateError
61
+ * containing both failures. Cleanup must never delete that remaining recovery copy.
62
+ *
63
+ * This is recovery for awaited filesystem failures, not a crash-safe transaction
64
+ * or a lock against concurrent exporters. The destination is briefly absent between
65
+ * the two renames; process termination during that interval cannot run rollback.
66
+ *
67
+ * @param outputDirectory - Canonical absolute destination resolved by the export pipeline.
68
+ * @param artifacts - Complete replacement set, with unique filenames assigned by resolution.
69
+ * An empty set intentionally produces an empty export directory.
70
+ *
71
+ * @example Temporary paths when replacing a project's `exports` directory:
72
+ * ```text
73
+ * basic-playable/
74
+ * exports/ Previous delivery files, until replacement starts
75
+ * .exports-staging-<unique>/ Temporary sibling owned by this operation
76
+ * prepared/ New applovin_default_en.html, google_default_en.zip, ...
77
+ * previous/ Old exports, moved here immediately before replacement
78
+ * ```
79
+ * On success, `prepared` becomes `exports`, and the temporary sibling is removed.
80
+ */
81
+ async function writeExportArtifacts(outputDirectory, artifacts) {
82
+ const parent = dirname(outputDirectory);
83
+ await assertOutputDirectoryUnchanged(outputDirectory);
84
+ await mkdir(parent, { recursive: true });
85
+ const stagingDirectory = await mkdtemp(join(parent, `.${basename(outputDirectory)}-staging-`));
86
+ const preparedDirectory = join(stagingDirectory, "prepared");
87
+ const backupDirectory = join(stagingDirectory, "previous");
88
+ let preserveBackup = false;
89
+ try {
90
+ await mkdir(preparedDirectory);
91
+ for (const artifact of artifacts) await writeFile(join(preparedDirectory, basename(artifact.outputFile)), artifact.content);
92
+ await assertOutputDirectoryUnchanged(outputDirectory);
93
+ const hasPreviousExport = await movePreviousExport(outputDirectory, backupDirectory);
94
+ try {
95
+ await rename(preparedDirectory, outputDirectory);
96
+ } catch (error) {
97
+ if (hasPreviousExport) try {
98
+ await rename(backupDirectory, outputDirectory);
99
+ } catch (restoreError) {
100
+ preserveBackup = true;
101
+ throw new AggregateError([error, restoreError], `Export replacement failed. Previous exports remain at ${backupDirectory}.`, { cause: restoreError });
102
+ }
103
+ throw error;
104
+ }
105
+ } finally {
106
+ if (!preserveBackup) await rm(stagingDirectory, {
107
+ recursive: true,
108
+ force: true
109
+ });
110
+ }
111
+ }
112
+ /**
113
+ * Moves an existing destination into the operation's backup directory.
114
+ *
115
+ * Returns true only after the move succeeds, allowing the caller to attempt rollback.
116
+ * A missing path returns false (normally the first export). Permission, device, and
117
+ * other filesystem errors propagate; they must not be treated as an absent export.
118
+ */
119
+ async function movePreviousExport(outputDirectory, backupDirectory) {
120
+ try {
121
+ await rename(outputDirectory, backupDirectory);
122
+ return true;
123
+ } catch (error) {
124
+ if (isMissingPathError(error)) return false;
125
+ throw error;
126
+ }
127
+ }
128
+ /**
129
+ * Detects a symlink introduced after resolution and before directory replacement.
130
+ *
131
+ * Resolution stores the canonical destination. Resolving it again immediately
132
+ * before staging and replacement detects an intermediate path redirected since then.
133
+ * Reject rather than move a directory belonging to that new target. This check
134
+ * does not lock the path or prevent another process changing it after the check.
135
+ */
136
+ async function assertOutputDirectoryUnchanged(outputDirectory) {
137
+ if (await resolveCanonicalPath(outputDirectory) !== outputDirectory) throw new Error(`Refusing to clean a redirected export directory: ${outputDirectory}.`);
138
+ }
139
+ //#endregion
140
+ //#region src/emission/emit-export-project.ts
141
+ /** Writes prepared artifacts safely, then reports their committed destinations. */
142
+ async function emitExportProject(project, artifacts) {
143
+ await writeExportArtifacts(project.outputDirectory, artifacts);
144
+ return {
145
+ outputDirectory: project.outputDirectory,
146
+ variants: artifacts.map(describeExportArtifact)
147
+ };
148
+ }
149
+ /** Reports the committed artifact's final path and byte size. */
150
+ function describeExportArtifact(artifact) {
151
+ const { content, outputFile, variantId } = artifact;
152
+ return {
153
+ file: outputFile,
154
+ size: measureArtifact(content),
155
+ variantId
156
+ };
157
+ }
158
+ /** Measures text and binary artifacts in the bytes reported by the filesystem. */
159
+ function measureArtifact(content) {
160
+ return typeof content === "string" ? Buffer.byteLength(content) : content.byteLength;
161
+ }
162
+ //#endregion
163
+ //#region src/shared/export-limits.ts
164
+ /** Maximum delivery size in bytes; Liftoff applies this to entry HTML. */
165
+ const MAX_EXPORT_SIZE_BYTES = 5e6;
166
+ /** Moloco requires a strictly smaller delivery; other networks include the limit. */
167
+ function isWithinExportSizeLimit(bytes, network) {
168
+ return network === "moloco" ? bytes < MAX_EXPORT_SIZE_BYTES : bytes <= MAX_EXPORT_SIZE_BYTES;
169
+ }
170
+ //#endregion
171
+ //#region src/shared/export-file-reference.ts
172
+ /** Converts a relative document URL into its normalized export-file path. */
173
+ function resolveExportFileReference(reference) {
174
+ if (reference === "" || reference.startsWith("//") || reference.startsWith("/") || /^[a-z][a-z\d+.-]*:/iu.test(reference)) return;
175
+ const encodedPath = reference.split(/[?#]/u, 1)[0];
176
+ if (encodedPath === void 0 || encodedPath === "") return;
177
+ let decodedPath;
178
+ try {
179
+ decodedPath = decodeURIComponent(encodedPath);
180
+ } catch {
181
+ return;
182
+ }
183
+ const exportFilePath = posix.normalize(decodedPath);
184
+ if (exportFilePath === ".." || exportFilePath.startsWith("../") || posix.isAbsolute(exportFilePath)) return;
185
+ return exportFilePath;
186
+ }
187
+ //#endregion
188
+ //#region src/validation/resource-references.ts
189
+ const RESOURCE_SELECTOR = "[src], link[href], object[data], video[poster]";
190
+ const RESOURCE_ATTRIBUTES = [
191
+ "src",
192
+ "href",
193
+ "data",
194
+ "poster"
195
+ ];
196
+ /**
197
+ * Describes every document resource rejected by the caller's availability policy.
198
+ *
199
+ * For example, an unresolved image becomes 'img[src]="assets/logo.png"', making
200
+ * the exact element, attribute, and reference immediately visible in diagnostics.
201
+ */
202
+ function collectUnavailableResourceReferences(document, isAvailable) {
203
+ const unavailableResources = [];
204
+ for (const element of document(RESOURCE_SELECTOR).toArray()) for (const attribute of RESOURCE_ATTRIBUTES) {
205
+ const reference = document(element).attr(attribute);
206
+ if (reference === void 0 || isAvailable(reference)) continue;
207
+ unavailableResources.push(`${element.tagName}[${attribute}]=${JSON.stringify(reference)}`);
208
+ }
209
+ return unavailableResources;
210
+ }
211
+ /** Reports whether a reference's bytes already live inside the HTML document. */
212
+ function isEmbeddedResourceReference(reference) {
213
+ return reference.startsWith("data:") || reference.startsWith("#");
214
+ }
215
+ /**
216
+ * Reports whether a resource is embedded or resolves to a file inside an archive.
217
+ *
218
+ * The caller builds the path set once, then reuses it for every document
219
+ * reference instead of repeatedly searching the archive file array.
220
+ */
221
+ function isAvailableArchiveResourceReference(reference, archivePaths) {
222
+ if (isEmbeddedResourceReference(reference)) return true;
223
+ const exportFilePath = resolveExportFileReference(reference);
224
+ return exportFilePath !== void 0 && archivePaths.has(exportFilePath);
225
+ }
226
+ //#endregion
227
+ //#region src/shared/playable-html.ts
228
+ /** Canonical entry document produced by every Replayable build. */
229
+ const PLAYABLE_HTML_FILE = "index.html";
230
+ /** Returns the required playable entry document from a collected build. */
231
+ function requirePlayableHtml(files, variantId) {
232
+ const html = files.find(({ path }) => path === PLAYABLE_HTML_FILE);
233
+ if (html === void 0) throw new Error(`Build ${variantId} does not contain a root ${PLAYABLE_HTML_FILE}.`);
234
+ return html;
235
+ }
236
+ //#endregion
237
+ //#region src/validation/stylesheet-resources.ts
238
+ /** Checks inline CSS in its final HTML location, including style attributes. */
239
+ async function validateDocumentStylesheets(document, files = [], htmlPath = PLAYABLE_HTML_FILE) {
240
+ for (const element of document("style, [style]").toArray()) {
241
+ const node = document(element);
242
+ if (element.tagName === "style") await validateStylesheetResources(node.text(), htmlPath, files);
243
+ const declarations = node.attr("style");
244
+ if (declarations !== void 0) await validateStylesheetResources(`.inline { ${declarations} }`, htmlPath, files);
245
+ }
246
+ }
247
+ /** Checks each archived stylesheet relative to its own file, not the root HTML. */
248
+ async function validateArchiveStylesheets(files) {
249
+ const decoder = new TextDecoder();
250
+ for (const file of files) if (file.path.endsWith(".css")) await validateStylesheetResources(decoder.decode(file.data), file.path, files);
251
+ else if (file.path.endsWith(".html")) await validateDocumentStylesheets(load(decoder.decode(file.data)), files, file.path);
252
+ }
253
+ /**
254
+ * Lets esbuild parse CSS escapes, url() and @import rather than guessing with regex.
255
+ * All references are marked external during this inspection: nothing is fetched,
256
+ * emitted, or bundled. The existing export files are the only allowed resources.
257
+ */
258
+ async function validateStylesheetResources(source, owner, files) {
259
+ const paths = new Set(files.map((file) => file.path));
260
+ const unavailable = /* @__PURE__ */ new Set();
261
+ await build({
262
+ stdin: {
263
+ contents: source,
264
+ loader: "css",
265
+ sourcefile: owner
266
+ },
267
+ bundle: true,
268
+ write: false,
269
+ logLevel: "silent",
270
+ plugins: [{
271
+ name: "validate-export-css-resources",
272
+ setup(builder) {
273
+ builder.onResolve({ filter: /.*/ }, ({ path }) => {
274
+ if (!isEmbeddedResourceReference(path) && !isArchivedStylesheetResource(path, owner, paths)) unavailable.add(path);
275
+ return {
276
+ path,
277
+ external: true
278
+ };
279
+ });
280
+ }
281
+ }]
282
+ });
283
+ if (unavailable.size > 0) throw new Error(`Stylesheet in ${owner} contains unavailable resources: ${[...unavailable].join(", ")}.`);
284
+ }
285
+ /** Resolves nested CSS URLs while rejecting remote and root-relative resources. */
286
+ function isArchivedStylesheetResource(reference, owner, paths) {
287
+ if (reference.startsWith("/") || /^[a-z][a-z\d+.-]*:/iu.test(reference)) return false;
288
+ const path = resolveExportFileReference(posix.join(posix.dirname(owner), reference));
289
+ return path !== void 0 && paths.has(path);
290
+ }
291
+ //#endregion
292
+ //#region src/validation/single-html.ts
293
+ /** Enforces the destination policy that can be proven from one standalone document. */
294
+ async function validateSingleHtmlExport(document, source, options) {
295
+ validateFileSize(source, options);
296
+ await validateDocumentStylesheets(document);
297
+ const preservedReferences = new Set(options.preservedResourceReferences);
298
+ const unavailableResources = collectUnavailableResourceReferences(document, (reference) => isEmbeddedResourceReference(reference) || preservedReferences.has(reference));
299
+ if (unavailableResources.length > 0) throw new Error(`${options.networkName} export contains non-embedded resources: ${unavailableResources.join(", ")}.`);
300
+ }
301
+ /** Rejects a serialized document larger than the destination network permits. */
302
+ function validateFileSize(source, options) {
303
+ const size = Buffer.byteLength(source);
304
+ if (size <= options.maxFileSizeBytes) return;
305
+ const formattedSize = size.toLocaleString("en-US");
306
+ const formattedLimit = options.maxFileSizeBytes.toLocaleString("en-US");
307
+ throw new Error(`${options.networkName} export exceeds its ${formattedLimit}-byte limit: ${formattedSize} bytes.`);
308
+ }
309
+ //#endregion
310
+ //#region src/preparation/shared/collect-build-files.ts
311
+ /**
312
+ * Collects one build directory as files ready for network-specific preparation.
313
+ *
314
+ * Given `dist/default/google/en` as the build directory:
315
+ *
316
+ * - `dist/default/google/en/index.html` becomes `{ path: 'index.html', data }`
317
+ * - `dist/default/google/en/assets/main.js` becomes `{ path: 'assets/main.js', data }`
318
+ *
319
+ * Paths always use forward slashes and are sorted before their bytes are read,
320
+ * producing the same ordered result on macOS, Linux, and Windows.
321
+ */
322
+ async function collectBuildFiles(buildDirectory) {
323
+ const files = (await readdir(buildDirectory, {
324
+ recursive: true,
325
+ withFileTypes: true
326
+ })).filter((entry) => entry.isFile()).map((entry) => {
327
+ const file = resolve(entry.parentPath, entry.name);
328
+ return {
329
+ path: relative(buildDirectory, file).split(sep).join(posix.sep),
330
+ file
331
+ };
332
+ }).sort(compareExportFiles);
333
+ return Promise.all(files.map(async ({ path, file }) => ({
334
+ path,
335
+ data: await readFile(file)
336
+ })));
337
+ }
338
+ /** Orders export files by code point, independently of host locale. */
339
+ function compareExportFiles(left, right) {
340
+ if (left.path < right.path) return -1;
341
+ if (left.path > right.path) return 1;
342
+ return 0;
343
+ }
344
+ //#endregion
345
+ //#region src/preparation/shared/compress-javascript.ts
346
+ const MAX_DEFLATE_LEVEL = 9;
347
+ const MIN_COMPRESSION_SAVINGS_RATIO = .05;
348
+ /**
349
+ * Compresses prepared JavaScript only when its embedded Base64 is meaningfully smaller.
350
+ *
351
+ * Deflate produces binary data, but the standalone HTML must carry that data as
352
+ * Base64 text. Comparing the finished Base64 payload accounts for its roughly
353
+ * one-third expansion. Requiring a five-percent final reduction avoids spending
354
+ * runtime CPU to inflate a large module for a negligible delivery-size saving.
355
+ * Returning `undefined` tells the caller to preserve the original module source.
356
+ */
357
+ function createCompressedJavaScriptPayload(source) {
358
+ if (source.length === 0) return;
359
+ const compressedSource = deflate(source, { level: MAX_DEFLATE_LEVEL });
360
+ const compressedPayload = Buffer.from(compressedSource).toString("base64");
361
+ const sourceSize = Buffer.byteLength(source);
362
+ if ((sourceSize - Buffer.byteLength(compressedPayload)) / sourceSize < MIN_COMPRESSION_SAVINGS_RATIO) return;
363
+ return compressedPayload;
364
+ }
365
+ /** Creates both delivery representations without discarding the prepared source. */
366
+ function createJavaScriptCompressionCandidate(source) {
367
+ return {
368
+ compressedPayload: createCompressedJavaScriptPayload(source),
369
+ source
370
+ };
371
+ }
372
+ //#endregion
373
+ //#region src/validation/build-entries.ts
374
+ const ENTRY_ATTRIBUTE$1 = "data-replayable-entry";
375
+ const entryRoles = [
376
+ "host",
377
+ "config",
378
+ "assets",
379
+ "application"
380
+ ];
381
+ /**
382
+ * Requires the four generated runtime entries to appear once in execution order.
383
+ *
384
+ * Module scripts execute in document order after fetching, so accepting missing,
385
+ * duplicated, or reordered markers would make an otherwise valid archive fail only
386
+ * at runtime with a misleading registration error.
387
+ */
388
+ function validateBuildEntryOrder(document, variantId) {
389
+ const actualRoles = document(`script[${ENTRY_ATTRIBUTE$1}]`).toArray().map((element) => document(element).attr(ENTRY_ATTRIBUTE$1));
390
+ if (actualRoles.length === entryRoles.length && actualRoles.every((role, index) => role === entryRoles[index])) return;
391
+ throw new Error(`Build ${variantId} must execute Replayable entries as ${entryRoles.join(" → ")}; received ${actualRoles.join(" → ") || "none"}.`);
392
+ }
393
+ //#endregion
394
+ //#region src/preparation/shared/prepare-javascript.ts
395
+ /**
396
+ * Prepares generated JavaScript for network delivery and safe HTML embedding.
397
+ *
398
+ * The narrow evaluation pass converts constant template literals produced by
399
+ * the build minifier into ordinary quoted strings without enabling Terser's
400
+ * broader compression transforms. This keeps static network analyzers reliable,
401
+ * while `ascii_only` escapes Unicode for conservative delivery environments and
402
+ * `inline_script` prevents source from closing its containing script element.
403
+ */
404
+ async function prepareExportJavaScript(source, sourceType) {
405
+ const result = await minify(source, {
406
+ compress: {
407
+ defaults: false,
408
+ evaluate: true
409
+ },
410
+ mangle: false,
411
+ module: sourceType === "module",
412
+ format: {
413
+ ascii_only: true,
414
+ inline_script: true,
415
+ quote_style: 1
416
+ }
417
+ });
418
+ if (result.code === void 0) throw new Error("Terser did not produce JavaScript for export.");
419
+ return result.code;
420
+ }
421
+ //#endregion
422
+ //#region src/preparation/shared/prepare-classic-javascript.ts
423
+ /**
424
+ * Converts a self-contained module into Mintegral's external classic script.
425
+ * Vite's dynamic-import helpers can retain import.meta even without splitting.
426
+ * Resolve their URL against the executing script, not the containing HTML page.
427
+ */
428
+ async function prepareClassicJavaScript(source) {
429
+ const result = await prepareExportJavaScript(`(async function (entryUrl) {
430
+ 'use strict';
431
+ ${(await transform(source, {
432
+ loader: "js",
433
+ define: {
434
+ "import.meta.url": "entryUrl",
435
+ "import.meta.resolve": "undefined"
436
+ }
437
+ })).code}
438
+ })(document.currentScript.src);`, "script");
439
+ parse(result, {
440
+ ecmaVersion: "latest",
441
+ sourceType: "script"
442
+ });
443
+ return result;
444
+ }
445
+ //#endregion
446
+ //#region src/preparation/shared/prepare-stylesheet.ts
447
+ /**
448
+ * Prepares generated CSS for compact and safe embedding inside a style element.
449
+ *
450
+ * Esbuild's "inline-style" support escapes source that could otherwise terminate
451
+ * the containing style element when the final HTML is parsed.
452
+ */
453
+ async function prepareExportStylesheet(source) {
454
+ return (await transform(source, {
455
+ loader: "css",
456
+ minify: true,
457
+ supported: { "inline-style": true }
458
+ })).code;
459
+ }
460
+ //#endregion
461
+ //#region src/preparation/shared/html-document.ts
462
+ const ENTRY_ATTRIBUTE = "data-replayable-entry";
463
+ /** Loads the required root HTML document from collected build files. */
464
+ function loadHtmlBuildDocument(files, variantId) {
465
+ const html = requirePlayableHtml(files, variantId);
466
+ return load(new TextDecoder().decode(html.data));
467
+ }
468
+ /**
469
+ * Resolves the local host, config, assets, application, and stylesheet files
470
+ * referenced by a production build document.
471
+ *
472
+ * Host-provided and external resources are ignored. Every marked entry and local
473
+ * stylesheet must exist in `files`. Internal entry markers are removed after
474
+ * resolution so they never appear in network delivery HTML.
475
+ */
476
+ function resolveHtmlBuildResources(document, files, variantId) {
477
+ validateBuildEntryOrder(document, variantId);
478
+ return {
479
+ entries: {
480
+ host: resolveEntry(document, files, variantId, "host"),
481
+ config: resolveEntry(document, files, variantId, "config"),
482
+ assets: resolveEntry(document, files, variantId, "assets"),
483
+ application: resolveEntry(document, files, variantId, "application")
484
+ },
485
+ stylesheets: resolveStylesheets(document, files, variantId)
486
+ };
487
+ }
488
+ /** Replaces every generated stylesheet link with minified inline CSS. */
489
+ async function inlineStylesheets(document, stylesheets) {
490
+ for (const stylesheet of stylesheets) {
491
+ const inlineSource = await prepareExportStylesheet(new TextDecoder().decode(stylesheet.file.data));
492
+ const style = document("<style></style>").text(inlineSource);
493
+ stylesheet.element.replaceWith(style);
494
+ }
495
+ }
496
+ /** Reads and normalizes a generated module before its delivery form is selected. */
497
+ function prepareModuleEntrySource(entry) {
498
+ return prepareExportJavaScript(new TextDecoder().decode(entry.file.data), "module");
499
+ }
500
+ /** Replaces a module reference with an already prepared inline source. */
501
+ function inlinePreparedModuleEntry(entry, source) {
502
+ entry.element.removeAttr("src");
503
+ entry.element.text(source);
504
+ }
505
+ /**
506
+ * Converts one external module entry into an external classic script.
507
+ *
508
+ * The HTML element keeps its `src` but loses `type="module"`. The returned file
509
+ * contains an async IIFE so authored top-level `await` retains its behavior.
510
+ */
511
+ async function prepareClassicScriptEntry(entry) {
512
+ const javaScript = await prepareClassicJavaScript(new TextDecoder().decode(entry.file.data));
513
+ entry.element.removeAttr("type");
514
+ return {
515
+ path: entry.file.path,
516
+ data: new TextEncoder().encode(javaScript)
517
+ };
518
+ }
519
+ /** Serializes a transformed document with one deterministic trailing newline. */
520
+ function serializeHtmlDocument(document) {
521
+ return `${document.html()}\n`;
522
+ }
523
+ /** Resolves exactly one local script carrying the requested Replayable entry role. */
524
+ function resolveEntry(document, files, variantId, role) {
525
+ const entries = [];
526
+ for (const element of document(`script[${ENTRY_ATTRIBUTE}="${role}"][src]`).toArray()) {
527
+ const script = document(element);
528
+ const reference = script.attr("src");
529
+ if (reference === void 0) continue;
530
+ const resource = resolveLocalResource(script, reference, files, variantId);
531
+ if (resource !== void 0) {
532
+ script.removeAttr(ENTRY_ATTRIBUTE);
533
+ entries.push(resource);
534
+ }
535
+ }
536
+ const entry = entries[0];
537
+ if (entry === void 0 || entries.length !== 1) throw new Error(`Build ${variantId} requires exactly one local ${role} entry; received ${entries.length}.`);
538
+ return entry;
539
+ }
540
+ /** Resolves local stylesheet links while preserving their document positions. */
541
+ function resolveStylesheets(document, files, variantId) {
542
+ const stylesheets = [];
543
+ for (const element of document("link[rel][href]").toArray()) {
544
+ const stylesheet = document(element);
545
+ const relation = stylesheet.attr("rel");
546
+ const reference = stylesheet.attr("href");
547
+ if (!(relation?.split(/\s+/u).some((value) => value.toLowerCase() === "stylesheet") ?? false) || reference === void 0) continue;
548
+ const resource = resolveLocalResource(stylesheet, reference, files, variantId);
549
+ if (resource !== void 0) stylesheets.push(resource);
550
+ }
551
+ return stylesheets;
552
+ }
553
+ /** Resolves one local document reference while leaving host and remote URLs untouched. */
554
+ function resolveLocalResource(element, reference, files, variantId) {
555
+ const path = resolveExportFileReference(reference);
556
+ if (path === void 0) return;
557
+ const file = files.find((candidate) => candidate.path === path);
558
+ if (file === void 0) throw new Error(`Build ${variantId} contains an unavailable local resource: ${reference}.`);
559
+ return {
560
+ element,
561
+ file
562
+ };
563
+ }
564
+ //#endregion
565
+ //#region src/shared/compression-protocol.ts
566
+ /** Identifies the inert script element that stores one Replayable module. */
567
+ const COMPRESSED_ENTRY_ATTRIBUTE = "data-replayable-compressed-entry";
568
+ /** Describes whether an inert Replayable module contains source or Deflate data. */
569
+ const ENTRY_ENCODING_ATTRIBUTE = "data-replayable-entry-encoding";
570
+ //#endregion
571
+ //#region src/preparation/shared/load-compressed-module-loader.ts
572
+ const COMPRESSED_MODULE_LOADER_PATH = new URL("./browser/compressed-module-loader.js", import.meta.url);
573
+ let compressedModuleLoaderSource;
574
+ /**
575
+ * Loads and prepares Replayable's compiled browser-side module loader.
576
+ *
577
+ * The promise caches both the file read and export preparation so concurrently
578
+ * generated variants share the same immutable minified source.
579
+ */
580
+ function loadCompressedModuleLoader() {
581
+ compressedModuleLoaderSource ??= readFile(COMPRESSED_MODULE_LOADER_PATH, "utf8").then((source) => prepareExportJavaScript(source, "module"));
582
+ return compressedModuleLoaderSource;
583
+ }
584
+ //#endregion
585
+ //#region src/preparation/shared/inline-compressed-modules.ts
586
+ /**
587
+ * Stores assets and application as inert payloads and appends their shared loader.
588
+ *
589
+ * Compressed entries use Base64 because Deflate produces binary data. An entry
590
+ * that did not become smaller remains readable JavaScript under `identity`
591
+ * encoding, avoiding Base64's one-third expansion while preserving deterministic
592
+ * assets-before-application execution.
593
+ */
594
+ async function inlineCompressedModules(entries) {
595
+ const loaderSource = await loadCompressedModuleLoader();
596
+ const loader = entries.application.resource.element.clone();
597
+ storeModulePayload(entries.assets, "assets");
598
+ storeModulePayload(entries.application, "application");
599
+ loader.removeAttr("src");
600
+ loader.removeAttr(COMPRESSED_ENTRY_ATTRIBUTE);
601
+ loader.removeAttr(ENTRY_ENCODING_ATTRIBUTE);
602
+ loader.attr("type", "module");
603
+ loader.text(loaderSource);
604
+ entries.application.resource.element.after(loader);
605
+ }
606
+ /** Replaces one module reference with its selected inert delivery representation. */
607
+ function storeModulePayload(entry, role) {
608
+ const compressedPayload = entry.candidate.compressedPayload;
609
+ entry.resource.element.removeAttr("src");
610
+ entry.resource.element.attr("type", "application/octet-stream");
611
+ entry.resource.element.attr(COMPRESSED_ENTRY_ATTRIBUTE, role);
612
+ entry.resource.element.attr(ENTRY_ENCODING_ATTRIBUTE, compressedPayload === void 0 ? "identity" : "deflate");
613
+ entry.resource.element.text(compressedPayload ?? entry.candidate.source);
614
+ }
615
+ //#endregion
616
+ //#region src/preparation/shared/load-pako-inflater.ts
617
+ const PAKO_INFLATER_PATH = createRequire(import.meta.url).resolve("pako/dist/pako_inflate.min.js");
618
+ let pakoInflaterSource;
619
+ /**
620
+ * Loads Pako's official inflate-only browser distribution.
621
+ *
622
+ * The source is cached because every single-HTML variant uses the same immutable
623
+ * dependency file. Keeping the promise also shares an in-progress read when
624
+ * several variants are prepared concurrently.
625
+ */
626
+ function loadPakoInflater() {
627
+ pakoInflaterSource ??= readFile(PAKO_INFLATER_PATH, "utf8");
628
+ return pakoInflaterSource;
629
+ }
630
+ //#endregion
631
+ //#region src/preparation/shared/inline-pako-inflater.ts
632
+ /**
633
+ * Embeds Pako's official inflate-only distribution before the first compressed entry.
634
+ *
635
+ * The original minified source is preserved, including its license banner. The
636
+ * caller owns the one-time decision so multiple module candidates share one inflater.
637
+ */
638
+ async function inlinePakoInflater(beforeEntry) {
639
+ const source = await loadPakoInflater();
640
+ const script = beforeEntry.element.clone();
641
+ script.removeAttr("src");
642
+ script.removeAttr("type");
643
+ script.text(source);
644
+ beforeEntry.element.before(script);
645
+ }
646
+ //#endregion
647
+ //#region src/preparation/shared/single-html.ts
648
+ /**
649
+ * Produces one standalone HTML document from an existing runnable build.
650
+ *
651
+ * Local stylesheets and the validator-visible host and config entries are embedded
652
+ * directly. Assets and application become ordered payloads when compression is
653
+ * useful: qualifying Deflate results use Base64, while the other entry remains plain
654
+ * source. If neither qualifies, both remain ordinary inline modules. The final
655
+ * document is validated against the network's size and external-resource policy.
656
+ */
657
+ async function prepareSingleHtmlExport(context, options) {
658
+ const files = await collectBuildFiles(context.buildDirectory);
659
+ const document = loadHtmlBuildDocument(files, context.variant.id);
660
+ const resources = resolveHtmlBuildResources(document, files, context.variant.id);
661
+ await inlineStylesheets(document, resources.stylesheets);
662
+ const [host, config, assets, application] = await Promise.all([
663
+ prepareModuleEntrySource(resources.entries.host),
664
+ prepareModuleEntrySource(resources.entries.config),
665
+ prepareModuleEntrySource(resources.entries.assets),
666
+ prepareModuleEntrySource(resources.entries.application)
667
+ ]);
668
+ options.validateJavaScript?.([
669
+ host,
670
+ config,
671
+ assets,
672
+ application
673
+ ]);
674
+ inlinePreparedModuleEntry(resources.entries.host, host);
675
+ inlinePreparedModuleEntry(resources.entries.config, config);
676
+ const assetsModule = createJavaScriptCompressionCandidate(assets);
677
+ const applicationModule = createJavaScriptCompressionCandidate(application);
678
+ if (assetsModule.compressedPayload !== void 0 || applicationModule.compressedPayload !== void 0) {
679
+ await inlinePakoInflater(resources.entries.assets);
680
+ await inlineCompressedModules({
681
+ application: {
682
+ candidate: applicationModule,
683
+ resource: resources.entries.application
684
+ },
685
+ assets: {
686
+ candidate: assetsModule,
687
+ resource: resources.entries.assets
688
+ }
689
+ });
690
+ } else {
691
+ inlinePreparedModuleEntry(resources.entries.assets, assetsModule.source);
692
+ inlinePreparedModuleEntry(resources.entries.application, applicationModule.source);
693
+ }
694
+ const source = serializeHtmlDocument(document);
695
+ await validateSingleHtmlExport(document, source, options);
696
+ return source;
697
+ }
698
+ //#endregion
699
+ //#region src/preparation/networks/applovin.ts
700
+ /** Produces one upload-ready AppLovin document from an existing variant build. */
701
+ function prepareAppLovinExport(context) {
702
+ return prepareSingleHtmlExport(context, {
703
+ maxFileSizeBytes: MAX_EXPORT_SIZE_BYTES,
704
+ networkName: "AppLovin"
705
+ });
706
+ }
707
+ //#endregion
708
+ //#region src/validation/networks/google.ts
709
+ const EXIT_API_URL = "https://tpc.googlesyndication.com/pagead/gadgets/html5/api/exitapi.js";
710
+ const ORIENTATIONS = /* @__PURE__ */ new Set([
711
+ "landscape",
712
+ "portrait",
713
+ "portrait,landscape"
714
+ ]);
715
+ const SUPPORTED_ARCHIVE_PATH = /^[A-Za-z\d._/-]+$/u;
716
+ /** Enforces every Google rule that can be proven from prepared archive files. */
717
+ function validateGoogleFiles(files, variantId) {
718
+ const html = requirePlayableHtml(files, variantId);
719
+ validateFileCount(files);
720
+ validateArchivePaths$1(files);
721
+ validateDocument$1(new TextDecoder().decode(html.data), files, variantId);
722
+ validateExitCall(files);
723
+ }
724
+ /** Enforces Google's documented maximum compressed archive size. */
725
+ function validateGoogleArchive(archive) {
726
+ if (isWithinExportSizeLimit(archive.byteLength, "google")) return;
727
+ const formattedSize = archive.byteLength.toLocaleString("en-US");
728
+ throw new Error(`Google archive exceeds its 5 MB limit: ${formattedSize} bytes.`);
729
+ }
730
+ /** Enforces Google's documented maximum number of files inside the ZIP. */
731
+ function validateFileCount(files) {
732
+ if (files.length > 512) throw new Error(`Google archive contains ${files.length} files; the maximum is 512.`);
733
+ }
734
+ /** Rejects archive paths containing characters unsupported by Google Ads. */
735
+ function validateArchivePaths$1(files) {
736
+ const invalidPaths = files.map(({ path }) => path).filter((archivePath) => !SUPPORTED_ARCHIVE_PATH.test(archivePath));
737
+ if (invalidPaths.length > 0) throw new Error(`Google archive contains unsupported paths: ${invalidPaths.join(", ")}.`);
738
+ }
739
+ /** Validates Google's required document structure, metadata, and resource policy. */
740
+ function validateDocument$1(source, files, variantId) {
741
+ const document = load(source);
742
+ validateBuildEntryOrder(document, variantId);
743
+ if (!source.trimStart().toLowerCase().startsWith("<!doctype html>")) throw new Error("Google entry document must begin with <!DOCTYPE html>.");
744
+ if (document("html").length !== 1 || document("head").length !== 1 || document("body").length !== 1) throw new Error("Google entry document must contain one html, head, and body element.");
745
+ if (document("head > meta[charset]").attr("charset")?.toLowerCase() !== "utf-8") throw new Error("Google entry document must declare UTF-8 encoding.");
746
+ const orientation = document("head > meta[name=\"ad.orientation\"]").attr("content");
747
+ if (orientation === void 0 || !ORIENTATIONS.has(orientation)) throw new Error("Google entry document has no valid ad.orientation metadata.");
748
+ if (document(`head > script[src="${EXIT_API_URL}"]`).length !== 1) throw new Error("Google entry document must load the official Exit API exactly once.");
749
+ validateResourceReferences(document, files);
750
+ }
751
+ /** Allows the official Exit API and requires every other resource to live in the ZIP. */
752
+ function validateResourceReferences(document, files) {
753
+ const archivePaths = new Set(files.map(({ path }) => path));
754
+ const unavailableResources = collectUnavailableResourceReferences(document, (reference) => reference === EXIT_API_URL || isAvailableArchiveResourceReference(reference, archivePaths));
755
+ if (unavailableResources.length > 0) throw new Error(`Google archive contains unavailable resources: ${unavailableResources.join(", ")}.`);
756
+ }
757
+ /** Ensures the bundled CTA path invokes Google's network-owned destination API. */
758
+ function validateExitCall(files) {
759
+ const decoder = new TextDecoder();
760
+ if (!files.filter(({ path }) => path.endsWith(".js")).some(({ data }) => decoder.decode(data).includes("ExitApi.exit()"))) throw new Error("Google archive does not invoke ExitApi.exit().");
761
+ }
762
+ //#endregion
763
+ //#region src/preparation/shared/create-zip-archive.ts
764
+ /** Compresses prepared export files at the highest supported compression level. */
765
+ function createZipArchive(files) {
766
+ return new Promise((resolve, reject) => {
767
+ const chunks = [];
768
+ const archive = new Zip((error, chunk, final) => {
769
+ if (error !== null) {
770
+ reject(error);
771
+ return;
772
+ }
773
+ chunks.push(chunk);
774
+ if (final) resolve(Buffer.concat(chunks));
775
+ });
776
+ for (const file of files) {
777
+ const archiveFile = new ZipDeflate(file.path, { level: 9 });
778
+ archive.add(archiveFile);
779
+ archiveFile.push(file.data, true);
780
+ }
781
+ archive.end();
782
+ });
783
+ }
784
+ //#endregion
785
+ //#region src/preparation/networks/google.ts
786
+ /** Packages one existing Google build as an upload-ready HTML5 ZIP. */
787
+ async function prepareGoogleExport(context) {
788
+ const files = await collectBuildFiles(context.buildDirectory);
789
+ validateGoogleFiles(files, context.variant.id);
790
+ await validateArchiveStylesheets(files);
791
+ const archive = await createZipArchive(files);
792
+ validateGoogleArchive(archive);
793
+ return archive;
794
+ }
795
+ //#endregion
796
+ //#region src/validation/networks/liftoff.ts
797
+ const ASCII_ARCHIVE_PATH = /^[\x20-\x7e]+$/u;
798
+ /** Enforces Liftoff's HTML size, archive path, and local-resource requirements. */
799
+ function validateLiftoffFiles(files, variantId) {
800
+ const html = requirePlayableHtml(files, variantId);
801
+ validateHtmlSize(html);
802
+ validateArchivePaths(files);
803
+ validateDocument(new TextDecoder().decode(html.data), files, variantId);
804
+ }
805
+ /** Enforces Liftoff's documented maximum entry-document size. */
806
+ function validateHtmlSize(html) {
807
+ if (!isWithinExportSizeLimit(html.data.byteLength, "liftoff")) {
808
+ const formattedSize = html.data.byteLength.toLocaleString("en-US");
809
+ throw new Error(`Liftoff HTML exceeds its 5 MB limit: ${formattedSize} bytes.`);
810
+ }
811
+ }
812
+ /** Rejects filenames that Liftoff's case-sensitive CDN cannot address reliably. */
813
+ function validateArchivePaths(files) {
814
+ const invalidPaths = files.map(({ path }) => path).filter((archivePath) => !ASCII_ARCHIVE_PATH.test(archivePath));
815
+ if (invalidPaths.length > 0) throw new Error(`Liftoff export contains non-ASCII filenames: ${invalidPaths.join(", ")}.`);
816
+ }
817
+ /** Enforces Liftoff's supported document shape and local-only resource policy. */
818
+ function validateDocument(source, files, variantId) {
819
+ const document = load(source);
820
+ validateBuildEntryOrder(document, variantId);
821
+ if (document("iframe").length > 0) throw new Error("Liftoff export must not contain iframe elements.");
822
+ const archivePaths = new Set(files.map(({ path }) => path));
823
+ const unavailableResources = collectUnavailableResourceReferences(document, (reference) => isAvailableArchiveResourceReference(reference, archivePaths));
824
+ if (unavailableResources.length > 0) throw new Error(`Liftoff export contains unavailable resources: ${unavailableResources.join(", ")}.`);
825
+ }
826
+ //#endregion
827
+ //#region src/preparation/networks/liftoff.ts
828
+ /** Packages one existing Liftoff build as an upload-ready progressive-loading ZIP. */
829
+ async function prepareLiftoffExport(context) {
830
+ const files = await collectBuildFiles(context.buildDirectory);
831
+ validateLiftoffFiles(files, context.variant.id);
832
+ await validateArchiveStylesheets(files);
833
+ return createZipArchive(files);
834
+ }
835
+ //#endregion
836
+ //#region src/preparation/networks/meta.ts
837
+ /** Produces one self-contained HTML document accepted by Meta playable ads. */
838
+ function prepareMetaExport(context) {
839
+ return prepareSingleHtmlExport(context, {
840
+ maxFileSizeBytes: MAX_EXPORT_SIZE_BYTES,
841
+ networkName: "Meta"
842
+ });
843
+ }
844
+ //#endregion
845
+ //#region src/validation/networks/mintegral.ts
846
+ /** Ensures every resource requested by the prepared HTML is contained in the ZIP. */
847
+ function validateMintegralFiles(source, files) {
848
+ const document = load(source);
849
+ const archivePaths = new Set(files.map(({ path }) => path));
850
+ const unavailableResources = collectUnavailableResourceReferences(document, (reference) => isAvailableArchiveResourceReference(reference, archivePaths));
851
+ if (unavailableResources.length > 0) throw new Error(`Mintegral export contains resources outside its ZIP: ${unavailableResources.join(", ")}.`);
852
+ }
853
+ /** Enforces Mintegral's maximum compressed delivery size. */
854
+ function validateMintegralArchive(archive) {
855
+ if (isWithinExportSizeLimit(archive.byteLength, "mintegral")) return;
856
+ const formattedSize = archive.byteLength.toLocaleString("en-US");
857
+ throw new Error(`Mintegral export exceeds the 5 MB limit: ${formattedSize} bytes.`);
858
+ }
859
+ //#endregion
860
+ //#region src/preparation/networks/mintegral.ts
861
+ /** Packages one existing Mintegral build as an upload-ready ZIP archive. */
862
+ async function prepareMintegralExport(context) {
863
+ const files = await convertMintegralBuild(context, await collectBuildFiles(context.buildDirectory));
864
+ const html = requirePlayableHtml(files, context.variant.id);
865
+ validateMintegralFiles(new TextDecoder().decode(html.data), files);
866
+ await validateArchiveStylesheets(files);
867
+ const archive = await createZipArchive(files);
868
+ validateMintegralArchive(archive);
869
+ return archive;
870
+ }
871
+ /**
872
+ * Converts Replayable's four ESM entries into Mintegral's local classic-script form.
873
+ *
874
+ * The async wrapper preserves authored top-level `await`, while Terser verifies that
875
+ * no imports or exports remain and safely escapes text embedded in a script context.
876
+ */
877
+ async function convertMintegralBuild(context, files) {
878
+ const document = loadHtmlBuildDocument(files, context.variant.id);
879
+ const resources = resolveHtmlBuildResources(document, files, context.variant.id);
880
+ await inlineStylesheets(document, resources.stylesheets);
881
+ const classicEntries = [
882
+ await prepareClassicScriptEntry(resources.entries.host),
883
+ await prepareClassicScriptEntry(resources.entries.config),
884
+ await prepareClassicScriptEntry(resources.entries.assets),
885
+ await prepareClassicScriptEntry(resources.entries.application)
886
+ ];
887
+ const htmlData = new TextEncoder().encode(serializeHtmlDocument(document));
888
+ const filesByPath = new Map(files.map((file) => [file.path, file]));
889
+ for (const stylesheet of resources.stylesheets) filesByPath.delete(stylesheet.file.path);
890
+ filesByPath.set(PLAYABLE_HTML_FILE, {
891
+ path: PLAYABLE_HTML_FILE,
892
+ data: htmlData
893
+ });
894
+ for (const classicEntry of classicEntries) filesByPath.set(classicEntry.path, classicEntry);
895
+ return [...filesByPath.values()];
896
+ }
897
+ //#endregion
898
+ //#region src/validation/browser-redirects.ts
899
+ const BROWSER_OBJECTS = /* @__PURE__ */ new Set([
900
+ "window",
901
+ "self",
902
+ "globalThis",
903
+ "document"
904
+ ]);
905
+ /**
906
+ * Checks direct browser navigation without mistaking shader .location properties,
907
+ * comments, or strings for executable redirects. Each module is parsed separately:
908
+ * independent build entries may legitimately reuse the same top-level bindings.
909
+ * This is a static direct-API check, not analysis of aliases or dynamically built code.
910
+ */
911
+ function containsBrowserRedirect(source) {
912
+ const program = parse(source, {
913
+ ecmaVersion: "latest",
914
+ sourceType: "module"
915
+ });
916
+ let redirects = false;
917
+ simple(program, {
918
+ AssignmentExpression(node) {
919
+ const path = browserPath(node.left);
920
+ redirects ||= path === "location" || path === "location.href";
921
+ },
922
+ CallExpression(node) {
923
+ const path = browserPath(node.callee);
924
+ redirects ||= path === "location.assign" || path === "location.replace" || path === "open";
925
+ }
926
+ });
927
+ return redirects;
928
+ }
929
+ /** Removes only recognized browser roots; arbitrary object properties do not match. */
930
+ function browserPath(node) {
931
+ const path = memberPath(node);
932
+ if (path === void 0) return;
933
+ const [root, ...properties] = path;
934
+ if (root === "document" && properties[0] !== "location") return;
935
+ if (root !== void 0 && BROWSER_OBJECTS.has(root)) return properties.join(".");
936
+ return root === "location" ? path.join(".") : void 0;
937
+ }
938
+ /** Resolves literal member access, including window['location'] and optional chains. */
939
+ function memberPath(node) {
940
+ if (node.type === "Identifier") return [node.name];
941
+ if (node.type === "ChainExpression") return memberPath(node.expression);
942
+ if (node.type !== "MemberExpression") return;
943
+ const object = memberPath(node.object);
944
+ const property = node.property;
945
+ const name = !node.computed && property.type === "Identifier" ? property.name : property.type === "Literal" && typeof property.value === "string" ? property.value : void 0;
946
+ return object !== void 0 && name !== void 0 ? [...object, name] : void 0;
947
+ }
948
+ //#endregion
949
+ //#region src/validation/networks/moloco.ts
950
+ const CTA_CALL = "FbPlayableAd.onCTAClick()";
951
+ /** Requires the responsive portrait-and-landscape layout documented by Moloco. */
952
+ function validateMolocoVariant(context) {
953
+ const { landscape, portrait } = context.variant.screen.orientations;
954
+ if (!landscape.enabled || !portrait.enabled) throw new Error("Moloco playables must support both portrait and landscape orientations.");
955
+ }
956
+ /** Rejects APIs and redirect behavior explicitly prohibited by Moloco. */
957
+ function validateMolocoSource(source) {
958
+ if (!isWithinExportSizeLimit(Buffer.byteLength(source), "moloco")) throw new Error("Moloco export must be smaller than 5 MB.");
959
+ if (source.toLowerCase().includes("mraid.js")) throw new Error("Moloco export must not contain mraid.js.");
960
+ if (!source.includes(CTA_CALL)) throw new Error(`Moloco export must invoke ${CTA_CALL}.`);
961
+ }
962
+ /** Checks readable code independently of the final compressed document size. */
963
+ function validateMolocoJavaScript(sources) {
964
+ const source = sources.join("\n");
965
+ if (source.includes("XMLHttpRequest")) throw new Error("Moloco export contains XMLHttpRequest. Disable audio or remove the dependency that provides it.");
966
+ if (source.toLowerCase().includes("mraid.js")) throw new Error("Moloco export must not contain mraid.js.");
967
+ if (!source.includes(CTA_CALL)) throw new Error(`Moloco export must invoke ${CTA_CALL}.`);
968
+ if (sources.some(containsBrowserRedirect)) throw new Error("Moloco export contains a direct JavaScript redirect.");
969
+ }
970
+ //#endregion
971
+ //#region src/preparation/networks/moloco.ts
972
+ /** Produces one self-contained HTML document accepted by Moloco playable ads. */
973
+ async function prepareMolocoExport(context) {
974
+ validateMolocoVariant(context);
975
+ const source = await prepareSingleHtmlExport(context, {
976
+ maxFileSizeBytes: MAX_EXPORT_SIZE_BYTES,
977
+ networkName: "Moloco",
978
+ validateJavaScript: validateMolocoJavaScript
979
+ });
980
+ validateMolocoSource(source);
981
+ return source;
982
+ }
983
+ //#endregion
984
+ //#region src/preparation/networks/preview.ts
985
+ /** Produces a portable self-contained document for local review and sharing. */
986
+ function preparePreviewExport(context) {
987
+ return prepareSingleHtmlExport(context, {
988
+ maxFileSizeBytes: MAX_EXPORT_SIZE_BYTES,
989
+ networkName: "Preview"
990
+ });
991
+ }
992
+ //#endregion
993
+ //#region src/preparation/networks/unity.ts
994
+ const UNITY_MRAID_REFERENCE = "mraid.js";
995
+ /** Produces one upload-ready Unity document while preserving its host-injected MRAID bootstrap. */
996
+ function prepareUnityExport(context) {
997
+ return prepareSingleHtmlExport(context, {
998
+ maxFileSizeBytes: MAX_EXPORT_SIZE_BYTES,
999
+ networkName: "Unity",
1000
+ preservedResourceReferences: [UNITY_MRAID_REFERENCE]
1001
+ });
1002
+ }
1003
+ //#endregion
1004
+ //#region src/preparation/prepare-export-project.ts
1005
+ /** Prepares every network artifact in memory without changing existing exports. */
1006
+ function prepareExportProject(project) {
1007
+ return Promise.all(project.variants.map(prepareExportVariant));
1008
+ }
1009
+ /** Prepares one resolved variant and keeps its destination beside the content. */
1010
+ async function prepareExportVariant(context) {
1011
+ return {
1012
+ content: await prepareExportContent(context),
1013
+ outputFile: context.outputFile,
1014
+ variantId: context.variant.id
1015
+ };
1016
+ }
1017
+ /** Applies the delivery rules owned by the resolved variant's network. */
1018
+ function prepareExportContent(context) {
1019
+ switch (context.variant.network) {
1020
+ case "applovin": return prepareAppLovinExport(context);
1021
+ case "google": return prepareGoogleExport(context);
1022
+ case "liftoff": return prepareLiftoffExport(context);
1023
+ case "meta": return prepareMetaExport(context);
1024
+ case "mintegral": return prepareMintegralExport(context);
1025
+ case "moloco": return prepareMolocoExport(context);
1026
+ case "preview": return preparePreviewExport(context);
1027
+ case "unity": return prepareUnityExport(context);
1028
+ default: throw new Error(`Unsupported export network: ${String(context.variant.network)}.`);
1029
+ }
1030
+ }
1031
+ //#endregion
1032
+ //#region src/resolution/resolve-export-directories.ts
1033
+ const DEFAULT_OUTPUT_DIRECTORY = "exports";
1034
+ /**
1035
+ * Resolves and validates the two generated roots used by export.
1036
+ *
1037
+ * Both directories must remain inside the project, and neither may contain the
1038
+ * other. This prevents the emission cleanup from deleting source builds. For
1039
+ * example, "dist" and "exports" are valid siblings, while "dist/exports" is not.
1040
+ */
1041
+ async function resolveExportDirectories(projectRoot, buildOutputPath, exportOutputPath = DEFAULT_OUTPUT_DIRECTORY) {
1042
+ const canonicalProjectRoot = await realpath(projectRoot);
1043
+ const directories = {
1044
+ buildOutput: await resolveGeneratedDirectory(canonicalProjectRoot, buildOutputPath),
1045
+ exportOutput: await resolveGeneratedDirectory(canonicalProjectRoot, exportOutputPath)
1046
+ };
1047
+ assertDirectoriesDoNotOverlap(directories);
1048
+ return directories;
1049
+ }
1050
+ /** Resolves one generated root and rejects the project root or paths outside it. */
1051
+ async function resolveGeneratedDirectory(projectRoot, configuredPath) {
1052
+ const directory = await resolveCanonicalPath(resolve(projectRoot, configuredPath));
1053
+ const projectRelativePath = relative(projectRoot, directory);
1054
+ const escapesProject = projectRelativePath === ".." || projectRelativePath.startsWith(`..${sep}`) || isAbsolute(projectRelativePath);
1055
+ if (projectRelativePath === "" || escapesProject) throw new Error(`Generated directory must be inside the project root: ${configuredPath}.`);
1056
+ return directory;
1057
+ }
1058
+ /** Prevents export cleanup from removing builds or writing artifacts inside them. */
1059
+ function assertDirectoriesDoNotOverlap(directories) {
1060
+ if (containsDirectory(directories.buildOutput, directories.exportOutput) || containsDirectory(directories.exportOutput, directories.buildOutput)) throw new Error("The build and export directories must not overlap.");
1061
+ }
1062
+ /**
1063
+ * Reports whether "parent" contains "child", including equality.
1064
+ *
1065
+ * For example, "/project/dist" contains "/project/dist/google", but it does not
1066
+ * contain the sibling "/project/exports".
1067
+ */
1068
+ function containsDirectory(parent, child) {
1069
+ const childPath = relative(parent, child);
1070
+ return childPath === "" || childPath !== ".." && !childPath.startsWith(`..${sep}`) && !isAbsolute(childPath);
1071
+ }
1072
+ //#endregion
1073
+ //#region src/resolution/resolve-export-variant.ts
1074
+ /**
1075
+ * Resolves the existing build and future artifact paths for one variant.
1076
+ *
1077
+ * For example, the build "dist/default/google/en/index.html" becomes the
1078
+ * delivery artifact "exports/google_default_en.zip".
1079
+ * This function verifies the build but does not create the export directory.
1080
+ */
1081
+ async function resolveExportVariant(directories, variant) {
1082
+ const buildDirectory = join(directories.buildOutput, variant.id);
1083
+ const htmlFile = join(buildDirectory, PLAYABLE_HTML_FILE);
1084
+ await assertBuiltHtmlExists(variant.id, htmlFile);
1085
+ return {
1086
+ buildDirectory,
1087
+ outputFile: join(directories.exportOutput, resolveExportFileName(variant)),
1088
+ variant
1089
+ };
1090
+ }
1091
+ /**
1092
+ * Selects the upload artifact name required by the destination network.
1093
+ *
1094
+ * Every name identifies its network, version, and language, such as
1095
+ * "applovin_default_en.html" or "google_default_en.zip".
1096
+ */
1097
+ function resolveExportFileName(variant) {
1098
+ const artifactName = [
1099
+ variant.network,
1100
+ variant.version,
1101
+ variant.localization.language
1102
+ ].map(normalizeArtifactNameSegment).join("_");
1103
+ switch (variant.network) {
1104
+ case "applovin":
1105
+ case "meta":
1106
+ case "moloco":
1107
+ case "preview":
1108
+ case "unity": return `${artifactName}.html`;
1109
+ case "google":
1110
+ case "liftoff":
1111
+ case "mintegral": return `${artifactName}.zip`;
1112
+ default: throw new Error(`Unsupported export network: ${String(variant.network)}.`);
1113
+ }
1114
+ }
1115
+ /** Produces a portable artifact-name segment containing letters, digits, and underscores. */
1116
+ function normalizeArtifactNameSegment(value) {
1117
+ const segment = value.toLowerCase().replace(/[^a-z\d]+/gu, "_").replace(/^_+|_+$/gu, "");
1118
+ return segment === "" ? "playable" : segment;
1119
+ }
1120
+ /** Verifies that export consumes a completed build rather than rebuilding implicitly. */
1121
+ async function assertBuiltHtmlExists(variantId, htmlFile) {
1122
+ try {
1123
+ if ((await stat(htmlFile)).isFile()) return;
1124
+ } catch (error) {
1125
+ if (isMissingPathError(error)) throw new Error(`Missing build for ${variantId}. Run replayable build before replayable export.`, { cause: error });
1126
+ throw new Error(`Unable to inspect build for ${variantId}: ${htmlFile}.`, { cause: error });
1127
+ }
1128
+ throw new Error(`Build output for ${variantId} is not an HTML file: ${htmlFile}.`);
1129
+ }
1130
+ //#endregion
1131
+ //#region src/resolution/resolve-export-project.ts
1132
+ /**
1133
+ * Resolves every existing variant build and its future export destination.
1134
+ *
1135
+ * This read-only stage parses the project configuration, validates the build and
1136
+ * export roots, verifies every expected build, and returns paths for preparation
1137
+ * and emission. It neither rebuilds variants nor changes existing exports.
1138
+ */
1139
+ async function resolveExportProject(input, options) {
1140
+ const config = defineConfig(input);
1141
+ const directories = await resolveExportDirectories(resolve(options.projectRoot), config.build.outDir, options.outputDirectory);
1142
+ const variants = await Promise.all(createVariants(config).map((variant) => resolveExportVariant(directories, variant)));
1143
+ assertUniqueOutputFiles(variants);
1144
+ return {
1145
+ outputDirectory: directories.exportOutput,
1146
+ variants
1147
+ };
1148
+ }
1149
+ /** Rejects variants whose normalized names would overwrite the same delivery artifact. */
1150
+ function assertUniqueOutputFiles(variants) {
1151
+ const variantByOutputFile = /* @__PURE__ */ new Map();
1152
+ for (const variant of variants) {
1153
+ const existingVariantId = variantByOutputFile.get(variant.outputFile);
1154
+ if (existingVariantId !== void 0) throw new Error(`Export variants ${existingVariantId} and ${variant.variant.id} resolve to the same output file: ${variant.outputFile}.`);
1155
+ variantByOutputFile.set(variant.outputFile, variant.variant.id);
1156
+ }
1157
+ }
1158
+ //#endregion
1159
+ //#region src/pipeline/export-project.ts
1160
+ /**
1161
+ * Produces every upload-ready artifact from an existing Replayable project build.
1162
+ *
1163
+ * Resolution verifies the builds and assigns destinations. Preparation creates
1164
+ * every artifact in memory. Emission replaces the previous export directory only
1165
+ * after all preparation succeeds, preserving the last successful export on error.
1166
+ */
1167
+ async function exportProject(config, options) {
1168
+ const project = await resolveExportProject(config, options);
1169
+ return emitExportProject(project, await prepareExportProject(project));
1170
+ }
1171
+ //#endregion
1172
+ export { exportProject };
1173
+
1174
+ //# sourceMappingURL=index.mjs.map