@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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/browser/compressed-module-loader.js +44 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.mjs +1174 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
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
|