@samva/vite 0.8.0 → 0.9.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/AGENTS.md +62 -58
- package/README.md +140 -94
- package/dist/build.d.ts +47 -42
- package/dist/build.js +88 -213
- package/dist/dev.d.ts +22 -0
- package/dist/dev.js +43 -0
- package/dist/editor/assets/index-BtfwuWcY.js +32 -0
- package/dist/editor/assets/index-DIuS_EZu.css +2 -0
- package/dist/editor/index.html +2 -2
- package/dist/editor-Cow7zZ_9.js +780 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1 -1054
- package/dist/project-CiUuzKsC.d.ts +271 -0
- package/dist/project-Cp8qb3s0.js +774 -0
- package/dist/project.d.ts +2 -175
- package/dist/project.js +2 -2
- package/dist/render.d.ts +54 -0
- package/dist/render.js +98 -0
- package/package.json +11 -12
- package/dist/discovery-D2eR-rzi.js +0 -35
- package/dist/editor/assets/index-6Qlbkdhz.js +0 -36
- package/dist/editor/assets/index-Ed_V5wEW.css +0 -2
- package/dist/email.d.ts +0 -60
- package/dist/email.js +0 -126
- package/dist/emit.d.ts +0 -99
- package/dist/emit.js +0 -142
- package/dist/project-RON2Ei7d.js +0 -603
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { parseBrandSpecifier, projectBrandSpecifier } from "@samva/markup/compiler";
|
|
6
|
+
import { assetUrl, isAssetPath } from "@samva/markup/email/assets";
|
|
7
|
+
import { renderIr, renderIrSms, renderIrWhatsApp } from "@samva/markup/render-ir";
|
|
8
|
+
import { checkTemplateCompatibility, compileTemplate } from "@samva/markup/static";
|
|
9
|
+
import { TEMPLATE_NAME_PATTERN } from "@samva/markup/template";
|
|
10
|
+
//#region src/internal/guards.ts
|
|
11
|
+
/** A non-null, non-array object, narrowed for property access. */
|
|
12
|
+
const isObject = (input) => typeof input === "object" && input !== null && !Array.isArray(input);
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/layout.ts
|
|
15
|
+
/** Directories scanned for templates when no `dir` is configured, in scan order. */
|
|
16
|
+
const DEFAULT_TEMPLATE_DIRS = ["templates", "emails"];
|
|
17
|
+
/** The folder for files this package generates, relative to the project root. Gitignore it. */
|
|
18
|
+
const SAMVA_DIR = ".samva";
|
|
19
|
+
const toPosix = (value) => sep === "/" ? value : value.split(sep).join("/");
|
|
20
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
21
|
+
const isProjectText = (name) => SCRIPT.test(name) || name.endsWith(".css") || name.endsWith(".json");
|
|
22
|
+
/** True for a file a compile can read: scripts, stylesheets, JSON and importable assets. */
|
|
23
|
+
const isProjectFile = (path) => isProjectText(path) || isAssetPath(path);
|
|
24
|
+
const skipped = (name) => name.startsWith(".") || name === "node_modules";
|
|
25
|
+
/** True when `path` is `parent` or lies beneath it. */
|
|
26
|
+
const isWithin = (path, parent) => {
|
|
27
|
+
const inside = relative(parent, path);
|
|
28
|
+
return inside === "" || !inside.startsWith("..") && !isAbsolute(inside);
|
|
29
|
+
};
|
|
30
|
+
const isDirectory = async (path) => (await stat(path).catch(() => void 0))?.isDirectory() === true;
|
|
31
|
+
/**
|
|
32
|
+
* The absolute directories a project's templates live in. A configured `dir`
|
|
33
|
+
* is the only one scanned; otherwise every default directory that exists. A
|
|
34
|
+
* directory outside the root cannot be compiled, because a project's files are
|
|
35
|
+
* addressed relative to its root, so it is refused rather than half-read.
|
|
36
|
+
*/
|
|
37
|
+
const templateDirectories = async (root, dir) => {
|
|
38
|
+
if (dir !== void 0) {
|
|
39
|
+
const configured = resolve(root, dir);
|
|
40
|
+
if (!isWithin(configured, root)) throw new Error(`The templates directory ${configured} is outside the project root ${root}. Set the project root to a folder that contains both.`);
|
|
41
|
+
return await isDirectory(configured) ? [configured] : [];
|
|
42
|
+
}
|
|
43
|
+
return (await Promise.all(DEFAULT_TEMPLATE_DIRS.map(async (name) => {
|
|
44
|
+
const path = join(root, name);
|
|
45
|
+
return await isDirectory(path) ? path : void 0;
|
|
46
|
+
}))).filter((path) => path !== void 0);
|
|
47
|
+
};
|
|
48
|
+
/** Root-relative POSIX path of an absolute one. */
|
|
49
|
+
const projectPath = (root, path) => toPosix(relative(root, path));
|
|
50
|
+
/** Every `.tsx` file beneath the given directories, as root-relative paths, sorted. */
|
|
51
|
+
const discoverEntries = async (root, dirs) => {
|
|
52
|
+
const found = [];
|
|
53
|
+
const walk = async (current) => {
|
|
54
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
55
|
+
await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
|
|
56
|
+
const full = join(current, entry.name);
|
|
57
|
+
if (entry.isDirectory()) return walk(full);
|
|
58
|
+
if (entry.isFile() && entry.name.endsWith(".tsx")) found.push(projectPath(root, full));
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
await Promise.all(dirs.map(walk));
|
|
62
|
+
return [...new Set(found)].sort();
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Read the project into the map the compiler consumes: scripts, stylesheets
|
|
66
|
+
* and JSON as text, imported assets as bytes. Hidden folders (`.samva`, `.git`)
|
|
67
|
+
* and `node_modules` are not part of a project.
|
|
68
|
+
*/
|
|
69
|
+
const readProjectFiles = async (root) => {
|
|
70
|
+
const files = {};
|
|
71
|
+
const walk = async (current) => {
|
|
72
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
73
|
+
await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
|
|
74
|
+
const full = join(current, entry.name);
|
|
75
|
+
if (entry.isDirectory()) return walk(full);
|
|
76
|
+
if (!entry.isFile()) return;
|
|
77
|
+
const path = projectPath(root, full);
|
|
78
|
+
if (isAssetPath(path)) files[path] = new Uint8Array(await readFile(full));
|
|
79
|
+
else if (isProjectText(entry.name)) files[path] = await readFile(full, "utf8");
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
await walk(root);
|
|
83
|
+
return files;
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/brand.ts
|
|
87
|
+
/** Where brand records are cached, relative to the project root. */
|
|
88
|
+
const BRAND_SNAPSHOT_DIR = `${SAMVA_DIR}/brands`;
|
|
89
|
+
const SNAPSHOT_VERSION = 1;
|
|
90
|
+
const DEFAULT_API_URL = "https://api.samva.dev";
|
|
91
|
+
const LIVE_TIMEOUT_MS = 15e3;
|
|
92
|
+
/** How long a resolution is reused, so a watch loop does not ask the API on every save. */
|
|
93
|
+
const RESOLUTION_TTL_MS = 3e4;
|
|
94
|
+
/** Accept only the fields a build needs, from an API body or a snapshot file. */
|
|
95
|
+
const asBrand = (value) => {
|
|
96
|
+
if (!isObject(value)) return void 0;
|
|
97
|
+
const { slug, name, isDefault, css, assets, footer, digest } = value;
|
|
98
|
+
if (typeof slug !== "string" || typeof name !== "string" || typeof isDefault !== "boolean" || typeof css !== "string" || typeof digest !== "string" || assets !== void 0 && !isObject(assets) || footer !== void 0 && !isObject(footer)) return void 0;
|
|
99
|
+
return {
|
|
100
|
+
slug,
|
|
101
|
+
name,
|
|
102
|
+
isDefault,
|
|
103
|
+
css,
|
|
104
|
+
digest,
|
|
105
|
+
...assets === void 0 ? {} : { assets },
|
|
106
|
+
...footer === void 0 ? {} : { footer }
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const liveFromBody = (body, when) => {
|
|
110
|
+
const brand = asBrand(body);
|
|
111
|
+
return brand === void 0 ? {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: `${when} answered with something that is not a brand`
|
|
114
|
+
} : {
|
|
115
|
+
ok: true,
|
|
116
|
+
brand: {
|
|
117
|
+
...brand,
|
|
118
|
+
version: SNAPSHOT_VERSION,
|
|
119
|
+
pulledAt: ""
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
const fetchJson = async (url, apiKey) => {
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
headers: {
|
|
126
|
+
"x-api-key": apiKey,
|
|
127
|
+
accept: "application/json"
|
|
128
|
+
},
|
|
129
|
+
signal: AbortSignal.timeout(LIVE_TIMEOUT_MS)
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
132
|
+
return response.json();
|
|
133
|
+
};
|
|
134
|
+
const liveWithApiKey = async (slug, env, apiKey) => {
|
|
135
|
+
const base = (env["SAMVA_API_URL"] || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
136
|
+
try {
|
|
137
|
+
if (slug !== void 0) return liveFromBody(await fetchJson(`${base}/v1/brands/${slug}`, apiKey), "The Samva API");
|
|
138
|
+
const body = await fetchJson(`${base}/v1/brands`, apiKey);
|
|
139
|
+
const found = (isObject(body) && Array.isArray(body["items"]) ? body["items"] : []).find((item) => isObject(item) && item["isDefault"] === true);
|
|
140
|
+
return found === void 0 ? {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: "the organization has no default brand"
|
|
143
|
+
} : liveFromBody(found, "The Samva API");
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: `the Samva API did not answer (${cause instanceof Error ? cause.message : String(cause)})`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/** The CLI's error envelope message, or its raw stderr. */
|
|
152
|
+
const cliFailure = (stderr) => {
|
|
153
|
+
const parsed = (() => {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(stderr);
|
|
156
|
+
} catch {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
return isObject(parsed) && typeof parsed["message"] === "string" ? parsed["message"] : stderr.trim() || "it exited without a message";
|
|
161
|
+
};
|
|
162
|
+
const liveWithCli = (slug, env) => new Promise((resolve) => {
|
|
163
|
+
execFile("samva", [
|
|
164
|
+
"brands",
|
|
165
|
+
"get",
|
|
166
|
+
...slug === void 0 ? [] : [slug],
|
|
167
|
+
"--json"
|
|
168
|
+
], {
|
|
169
|
+
env: { ...env },
|
|
170
|
+
timeout: LIVE_TIMEOUT_MS,
|
|
171
|
+
maxBuffer: 1048576
|
|
172
|
+
}, (error, stdout, stderr) => {
|
|
173
|
+
if (error !== null) {
|
|
174
|
+
const code = error.code;
|
|
175
|
+
resolve({
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: code === "ENOENT" ? "there is no SAMVA_API_KEY and no `samva` CLI on PATH" : `\`samva brands get\` failed: ${cliFailure(stderr)}`
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const envelope = (() => {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(stdout);
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
resolve(liveFromBody(isObject(envelope) ? envelope["data"] : void 0, "`samva brands get`"));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
const fetchLiveBrand = (slug, env) => {
|
|
192
|
+
const apiKey = env["SAMVA_API_KEY"];
|
|
193
|
+
return apiKey !== void 0 && apiKey.length > 0 ? liveWithApiKey(slug, env, apiKey) : liveWithCli(slug, env);
|
|
194
|
+
};
|
|
195
|
+
const readSnapshotFile = async (path) => {
|
|
196
|
+
const raw = await readFile(path, "utf8").catch((cause) => {
|
|
197
|
+
if (cause.code === "ENOENT") return void 0;
|
|
198
|
+
throw cause;
|
|
199
|
+
});
|
|
200
|
+
if (raw === void 0) return {
|
|
201
|
+
ok: true,
|
|
202
|
+
snapshot: void 0,
|
|
203
|
+
path
|
|
204
|
+
};
|
|
205
|
+
const parsed = (() => {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(raw);
|
|
208
|
+
} catch {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
const brand = asBrand(parsed);
|
|
213
|
+
if (brand === void 0 || !isObject(parsed) || parsed["version"] !== SNAPSHOT_VERSION || typeof parsed["pulledAt"] !== "string") return {
|
|
214
|
+
ok: false,
|
|
215
|
+
message: `${path} is not a brand snapshot this version of @samva/vite reads. Run \`samva brands pull\` to rewrite it.`
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
snapshot: {
|
|
220
|
+
...brand,
|
|
221
|
+
version: SNAPSHOT_VERSION,
|
|
222
|
+
pulledAt: parsed["pulledAt"]
|
|
223
|
+
},
|
|
224
|
+
path
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The snapshot for a named brand, or for the default brand the most recently
|
|
229
|
+
* pulled snapshot that was the default when it was pulled.
|
|
230
|
+
*/
|
|
231
|
+
const readSnapshot = async (root, slug) => {
|
|
232
|
+
const directory = join(root, BRAND_SNAPSHOT_DIR);
|
|
233
|
+
if (slug !== void 0) return readSnapshotFile(join(directory, `${slug}.json`));
|
|
234
|
+
const names = await readdir(directory).catch((cause) => {
|
|
235
|
+
if (cause.code === "ENOENT") return [];
|
|
236
|
+
throw cause;
|
|
237
|
+
});
|
|
238
|
+
const reads = await Promise.all(names.filter((name) => name.endsWith(".json")).sort().map((name) => readSnapshotFile(join(directory, name))));
|
|
239
|
+
const failed = reads.find((read) => !read.ok);
|
|
240
|
+
if (failed !== void 0) return failed;
|
|
241
|
+
const defaults = reads.flatMap((read) => read.ok && read.snapshot?.isDefault === true ? [{
|
|
242
|
+
...read,
|
|
243
|
+
snapshot: read.snapshot
|
|
244
|
+
}] : []);
|
|
245
|
+
defaults.sort((a, b) => b.snapshot.pulledAt.localeCompare(a.snapshot.pulledAt));
|
|
246
|
+
return defaults[0] ?? {
|
|
247
|
+
ok: true,
|
|
248
|
+
snapshot: void 0,
|
|
249
|
+
path: directory
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
const SAFE_SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
253
|
+
/**
|
|
254
|
+
* Refresh the cached copy after a live answer. Best effort: a read-only checkout
|
|
255
|
+
* or a full disk must not fail a build that already has the brand it needs.
|
|
256
|
+
*/
|
|
257
|
+
const cacheBrand = async (root, brand, cached) => {
|
|
258
|
+
if (!SAFE_SLUG.test(brand.slug) || cached?.digest === brand.digest) return;
|
|
259
|
+
const directory = join(root, BRAND_SNAPSHOT_DIR);
|
|
260
|
+
const target = join(directory, `${brand.slug}.json`);
|
|
261
|
+
const snapshot = {
|
|
262
|
+
...brand,
|
|
263
|
+
pulledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
264
|
+
};
|
|
265
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
266
|
+
await mkdir(directory, { recursive: true }).then(() => writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8")).then(() => rename(temporary, target)).catch(() => void 0);
|
|
267
|
+
};
|
|
268
|
+
const toResolved = (brand) => ({
|
|
269
|
+
slug: brand.slug,
|
|
270
|
+
css: brand.css,
|
|
271
|
+
digest: brand.digest,
|
|
272
|
+
...brand.assets === void 0 ? {} : { assets: brand.assets },
|
|
273
|
+
...brand.footer === void 0 ? {} : { footer: brand.footer }
|
|
274
|
+
});
|
|
275
|
+
const relativeSnapshot = (root, path) => relative(root, path).split(sep).join("/");
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the brand a project's `samva:brand` import names: live when the
|
|
278
|
+
* environment's credentials reach the API, else the pulled snapshot with a
|
|
279
|
+
* warning, else a failure naming both fixes.
|
|
280
|
+
*/
|
|
281
|
+
const resolveProjectBrand = async (input) => {
|
|
282
|
+
const specifier = projectBrandSpecifier(input.files, input.themePath ?? "");
|
|
283
|
+
if (specifier === void 0) return {
|
|
284
|
+
ok: true,
|
|
285
|
+
brand: void 0,
|
|
286
|
+
source: "none",
|
|
287
|
+
warnings: []
|
|
288
|
+
};
|
|
289
|
+
const slug = parseBrandSpecifier(specifier)?.slug;
|
|
290
|
+
const pullCommand = `samva brands pull${slug === void 0 ? "" : ` ${slug}`}`;
|
|
291
|
+
const [live, local] = await Promise.all([input.offline === true ? Promise.resolve({
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: "offline mode skips the live brand"
|
|
294
|
+
}) : fetchLiveBrand(slug, input.env), readSnapshot(input.root, slug)]);
|
|
295
|
+
if (live.ok) {
|
|
296
|
+
const cached = local.ok ? local.snapshot : void 0;
|
|
297
|
+
const stale = cached !== void 0 && cached.digest !== live.brand.digest;
|
|
298
|
+
await cacheBrand(input.root, live.brand, cached);
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
brand: toResolved(live.brand),
|
|
302
|
+
source: "live",
|
|
303
|
+
warnings: stale ? [{
|
|
304
|
+
code: "brand-snapshot-stale",
|
|
305
|
+
message: `${relativeSnapshot(input.root, local.ok ? local.path : input.root)} was older than the live brand ${live.brand.slug}; the cached copy is refreshed.`
|
|
306
|
+
}] : []
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (!local.ok) return {
|
|
310
|
+
ok: false,
|
|
311
|
+
message: local.message
|
|
312
|
+
};
|
|
313
|
+
if (local.snapshot === void 0) return {
|
|
314
|
+
ok: false,
|
|
315
|
+
message: `The project imports ${specifier}, but ${live.reason}, and there is no snapshot in ${BRAND_SNAPSHOT_DIR}. Run \`samva login\` or set SAMVA_API_KEY, or run \`${pullCommand}\` while online.`
|
|
316
|
+
};
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
brand: toResolved(local.snapshot),
|
|
320
|
+
source: "snapshot",
|
|
321
|
+
warnings: [{
|
|
322
|
+
code: "brand-offline",
|
|
323
|
+
message: `Using ${relativeSnapshot(input.root, local.path)} (pulled ${local.snapshot.pulledAt}) for ${specifier}: ${live.reason}.`
|
|
324
|
+
}]
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* The environment a local build resolves the brand with: the process
|
|
329
|
+
* environment plus the project's `.env` files, as the editor's dev server
|
|
330
|
+
* reads them, so `SAMVA_API_KEY` in `.env` reaches `check` and `export` too.
|
|
331
|
+
*/
|
|
332
|
+
const loadBrandEnv = async (root) => {
|
|
333
|
+
const vite = await import("vite").catch(() => void 0);
|
|
334
|
+
if (vite === void 0) throw new Error("`vite` is required to resolve samva:brand locally.");
|
|
335
|
+
return vite.loadEnv("development", root, "");
|
|
336
|
+
};
|
|
337
|
+
/** The resolution as build findings: warnings stay warnings, a failure is an error. */
|
|
338
|
+
const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map((warning) => ({
|
|
339
|
+
code: warning.code,
|
|
340
|
+
severity: "warning",
|
|
341
|
+
message: warning.message,
|
|
342
|
+
origins: []
|
|
343
|
+
})) : [{
|
|
344
|
+
code: "brand-unavailable",
|
|
345
|
+
severity: "error",
|
|
346
|
+
message: resolution.message,
|
|
347
|
+
origins: []
|
|
348
|
+
}];
|
|
349
|
+
/**
|
|
350
|
+
* One resolver per watch session. A resolution is reused for a short while per
|
|
351
|
+
* specifier, so saving a file does not ask the API each time while a brand
|
|
352
|
+
* edited elsewhere still arrives within the window.
|
|
353
|
+
*/
|
|
354
|
+
const createBrandResolver = (options) => {
|
|
355
|
+
const cache = /* @__PURE__ */ new Map();
|
|
356
|
+
return { resolve: async (files, themePath) => {
|
|
357
|
+
const key = projectBrandSpecifier(files, themePath ?? "") ?? "";
|
|
358
|
+
const cached = cache.get(key);
|
|
359
|
+
if (cached !== void 0 && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.resolution;
|
|
360
|
+
const resolution = await resolveProjectBrand({
|
|
361
|
+
root: options.root,
|
|
362
|
+
files,
|
|
363
|
+
themePath,
|
|
364
|
+
env: options.env,
|
|
365
|
+
offline: options.offline
|
|
366
|
+
});
|
|
367
|
+
cache.set(key, {
|
|
368
|
+
at: Date.now(),
|
|
369
|
+
resolution
|
|
370
|
+
});
|
|
371
|
+
return resolution;
|
|
372
|
+
} };
|
|
373
|
+
};
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/compile.ts
|
|
376
|
+
const CHANNELS = [
|
|
377
|
+
"email",
|
|
378
|
+
"sms",
|
|
379
|
+
"whatsapp"
|
|
380
|
+
];
|
|
381
|
+
/** The channels a compiled template declares, in a fixed order. */
|
|
382
|
+
const channelsOf = (ir) => CHANNELS.filter((channel) => ir[channel] !== void 0);
|
|
383
|
+
const finding = (code, severity, message, extra = {}) => ({
|
|
384
|
+
code,
|
|
385
|
+
severity,
|
|
386
|
+
message,
|
|
387
|
+
origins: [],
|
|
388
|
+
...extra
|
|
389
|
+
});
|
|
390
|
+
const isHelper = (diagnostics) => diagnostics.length > 0 && diagnostics.every((item) => item.code === "no-template");
|
|
391
|
+
const failure = (cause) => finding("compile-failed", "error", cause instanceof Error ? cause.message : String(cause));
|
|
392
|
+
const compileEntry = async (input, entryPath) => {
|
|
393
|
+
const attempt = await compileTemplate({
|
|
394
|
+
files: input.files,
|
|
395
|
+
entry: entryPath,
|
|
396
|
+
assetBase: input.assetBase,
|
|
397
|
+
...input.brand === void 0 ? {} : { brand: input.brand },
|
|
398
|
+
tailwind: input.theme === void 0 ? {} : {
|
|
399
|
+
css: input.theme.css,
|
|
400
|
+
cssPath: input.theme.path
|
|
401
|
+
}
|
|
402
|
+
}).then((compiled) => ({
|
|
403
|
+
ok: true,
|
|
404
|
+
compiled
|
|
405
|
+
}), (cause) => ({
|
|
406
|
+
ok: false,
|
|
407
|
+
cause
|
|
408
|
+
}));
|
|
409
|
+
if (!attempt.ok) return {
|
|
410
|
+
kind: "failed",
|
|
411
|
+
entry: {
|
|
412
|
+
entryPath,
|
|
413
|
+
diagnostics: [failure(attempt.cause)]
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const { compiled } = attempt;
|
|
417
|
+
const { ir } = compiled;
|
|
418
|
+
if (ir === void 0) return isHelper(compiled.diagnostics) ? {
|
|
419
|
+
kind: "helper",
|
|
420
|
+
entryPath
|
|
421
|
+
} : {
|
|
422
|
+
kind: "failed",
|
|
423
|
+
entry: {
|
|
424
|
+
entryPath,
|
|
425
|
+
diagnostics: compiled.diagnostics
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
return {
|
|
429
|
+
kind: "template",
|
|
430
|
+
entry: {
|
|
431
|
+
id: ir.template,
|
|
432
|
+
entryPath,
|
|
433
|
+
ir,
|
|
434
|
+
fixtures: compiled.fixtures,
|
|
435
|
+
assets: compiled.assets,
|
|
436
|
+
diagnostics: compiled.diagnostics,
|
|
437
|
+
channels: channelsOf(ir)
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Compile every candidate entry. An entry the compiler reports `no-template`
|
|
443
|
+
* for is a helper module, not a template; that is how a partial is told apart
|
|
444
|
+
* from a template without evaluating either. One failure stays with its entry.
|
|
445
|
+
*/
|
|
446
|
+
const compileProject = async (input) => {
|
|
447
|
+
const entries = [...input.entries].sort();
|
|
448
|
+
const results = await Promise.all(entries.map((entryPath) => compileEntry(input, entryPath)));
|
|
449
|
+
const templates = [];
|
|
450
|
+
const failures = [];
|
|
451
|
+
const helpers = [];
|
|
452
|
+
const diagnostics = [];
|
|
453
|
+
const assets = /* @__PURE__ */ new Map();
|
|
454
|
+
const declaredBy = /* @__PURE__ */ new Map();
|
|
455
|
+
for (const result of results) {
|
|
456
|
+
if (result.kind === "helper") {
|
|
457
|
+
helpers.push(result.entryPath);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (result.kind === "failed") {
|
|
461
|
+
failures.push(result.entry);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const { entry } = result;
|
|
465
|
+
const previous = declaredBy.get(entry.id);
|
|
466
|
+
if (previous !== void 0) {
|
|
467
|
+
diagnostics.push(finding("duplicate-template-id", "error", `Template id ${JSON.stringify(entry.id)} is declared by both ${previous} and ${entry.entryPath}. An id is a publication's identity, so it must name exactly one template.`));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
declaredBy.set(entry.id, entry.entryPath);
|
|
471
|
+
for (const asset of entry.assets) assets.set(asset.digest, asset);
|
|
472
|
+
templates.push(entry);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
templates: templates.sort((left, right) => left.id < right.id ? -1 : 1),
|
|
476
|
+
failures,
|
|
477
|
+
helpers,
|
|
478
|
+
diagnostics,
|
|
479
|
+
assets: [...assets.values()].sort((left, right) => left.fileName < right.fileName ? -1 : 1)
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
/** A renderer refusal as findings about one fixture. */
|
|
483
|
+
const renderFindings = (cause, templateId, fixture) => {
|
|
484
|
+
const carried = cause.diagnostics;
|
|
485
|
+
if (Array.isArray(carried) && carried.length > 0) return carried.map((item) => finding(item.code, "error", item.message, { fixtures: [fixture] }));
|
|
486
|
+
return [finding("render-failed", "error", `${templateId} could not render fixture ${JSON.stringify(fixture)}: ${cause instanceof Error ? cause.message : String(cause)}`, { fixtures: [fixture] })];
|
|
487
|
+
};
|
|
488
|
+
/**
|
|
489
|
+
* Render every fixture a template declares through its email channel. A fixture
|
|
490
|
+
* the renderer refuses is a finding on that fixture, never a thrown error, so
|
|
491
|
+
* one bad input does not take the rest of the catalog with it.
|
|
492
|
+
*/
|
|
493
|
+
const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.fixtures).map(([fixture, input]) => {
|
|
494
|
+
try {
|
|
495
|
+
return {
|
|
496
|
+
templateId: entry.id,
|
|
497
|
+
fixture,
|
|
498
|
+
rendered: renderIr(entry.ir, input, options),
|
|
499
|
+
diagnostics: []
|
|
500
|
+
};
|
|
501
|
+
} catch (cause) {
|
|
502
|
+
return {
|
|
503
|
+
templateId: entry.id,
|
|
504
|
+
fixture,
|
|
505
|
+
rendered: void 0,
|
|
506
|
+
diagnostics: renderFindings(cause, entry.id, fixture)
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
/** Client-matrix findings across the fixtures that rendered. */
|
|
511
|
+
const emailCompatibility = (entry, rendered) => {
|
|
512
|
+
const renderable = Object.fromEntries(rendered.filter((result) => result.rendered !== void 0).map((result) => [result.fixture, entry.fixtures[result.fixture]]));
|
|
513
|
+
try {
|
|
514
|
+
return checkTemplateCompatibility(entry.ir, renderable);
|
|
515
|
+
} catch (cause) {
|
|
516
|
+
return [finding("compatibility-failed", "error", cause instanceof Error ? cause.message : String(cause))];
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
/** Render the SMS or WhatsApp channel of one fixture. */
|
|
520
|
+
const renderChannelFixture = (entry, channel, fixture, options = {}) => {
|
|
521
|
+
if (!Object.hasOwn(entry.fixtures, fixture)) return {
|
|
522
|
+
ok: false,
|
|
523
|
+
diagnostics: [finding("unknown-fixture", "error", `Template ${entry.id} declares no fixture named ${JSON.stringify(fixture)}.`)]
|
|
524
|
+
};
|
|
525
|
+
try {
|
|
526
|
+
const input = entry.fixtures[fixture];
|
|
527
|
+
return channel === "sms" ? {
|
|
528
|
+
ok: true,
|
|
529
|
+
channel,
|
|
530
|
+
text: renderIrSms(entry.ir, input, options)
|
|
531
|
+
} : {
|
|
532
|
+
ok: true,
|
|
533
|
+
channel,
|
|
534
|
+
message: renderIrWhatsApp(entry.ir, input, options)
|
|
535
|
+
};
|
|
536
|
+
} catch (cause) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
diagnostics: renderFindings(cause, entry.id, fixture)
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const hasErrors = (diagnostics) => diagnostics.some((item) => item.severity === "error");
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/local-assets.ts
|
|
546
|
+
const HTTPS = /^https:\/\//i;
|
|
547
|
+
/** True when the renderer accepts `base` as it is: a delivered message can resolve it. */
|
|
548
|
+
const isDeliverableBase = (base) => HTTPS.test(base);
|
|
549
|
+
/** An https origin exactly as long as `base`, unique enough not to occur in authored content. */
|
|
550
|
+
const placeholderBase = (base) => `https://${"~".repeat(Math.max(1, base.length - 8))}`;
|
|
551
|
+
/** The placeholder to compile with for `base`, or `base` itself when it can be delivered. */
|
|
552
|
+
const compileBase = (base) => base === void 0 || isDeliverableBase(base) ? base : placeholderBase(base);
|
|
553
|
+
/** Swap the placeholder for `base` in rendered output. */
|
|
554
|
+
const restoreBase = (output, base) => base === void 0 || isDeliverableBase(base) ? output : output.replaceAll(placeholderBase(base), base);
|
|
555
|
+
/**
|
|
556
|
+
* Resolves the `theme` plugin option to an absolute path, or `undefined` when
|
|
557
|
+
* theming is explicitly disabled (`theme: false`). A string is taken relative to
|
|
558
|
+
* `root` (absolute strings pass through); the default is `<root>/theme.css`.
|
|
559
|
+
*/
|
|
560
|
+
const resolveThemePath = (root, option) => {
|
|
561
|
+
if (option === false) return void 0;
|
|
562
|
+
const file = option ?? "theme.css";
|
|
563
|
+
return isAbsolute(file) ? file : resolve(root, file);
|
|
564
|
+
};
|
|
565
|
+
/** Read authored Tailwind CSS verbatim; the email compiler validates its syntax. */
|
|
566
|
+
const readProjectThemeCss = async (path) => path === void 0 ? void 0 : readFile(path, "utf8").catch((cause) => {
|
|
567
|
+
if (cause.code === "ENOENT") return void 0;
|
|
568
|
+
throw cause;
|
|
569
|
+
});
|
|
570
|
+
/** The theme's project-relative path, from which its relative `@import`s resolve. */
|
|
571
|
+
const projectThemePath = (root, themePath) => relative(root, themePath).split(sep).join("/");
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/project.ts
|
|
574
|
+
/** Resolve the root a set of options names. */
|
|
575
|
+
const projectRoot = (options) => resolve(options.root ?? process.cwd());
|
|
576
|
+
/**
|
|
577
|
+
* Read, resolve the brand of, and compile every template in a project. `brands`
|
|
578
|
+
* lets a long-lived caller reuse a brand resolution between loads, and `only`
|
|
579
|
+
* limits compilation to the named project-relative entries.
|
|
580
|
+
*/
|
|
581
|
+
const loadProject = async (options = {}, brands, only) => {
|
|
582
|
+
const root = projectRoot(options);
|
|
583
|
+
const dirs = await templateDirectories(root, options.dir);
|
|
584
|
+
const [discovered, files] = await Promise.all([discoverEntries(root, dirs), readProjectFiles(root)]);
|
|
585
|
+
const entries = only === void 0 ? discovered : discovered.filter((path) => only.includes(path));
|
|
586
|
+
const themeFile = resolveThemePath(root, options.theme);
|
|
587
|
+
const themeCss = await readProjectThemeCss(themeFile);
|
|
588
|
+
const themePath = themeFile === void 0 || themeCss === void 0 ? void 0 : projectThemePath(root, themeFile);
|
|
589
|
+
const brand = await (brands ?? createBrandResolver({
|
|
590
|
+
root,
|
|
591
|
+
env: await loadBrandEnv(root),
|
|
592
|
+
offline: options.offline === true
|
|
593
|
+
})).resolve(files, themePath);
|
|
594
|
+
const catalog = await compileProject({
|
|
595
|
+
files,
|
|
596
|
+
entries,
|
|
597
|
+
assetBase: options.assetBase ?? "",
|
|
598
|
+
...themeCss === void 0 || themePath === void 0 ? {} : { theme: {
|
|
599
|
+
css: themeCss,
|
|
600
|
+
path: themePath
|
|
601
|
+
} },
|
|
602
|
+
...brand.ok && brand.brand !== void 0 ? { brand: brand.brand } : {}
|
|
603
|
+
});
|
|
604
|
+
return {
|
|
605
|
+
root,
|
|
606
|
+
files,
|
|
607
|
+
catalog,
|
|
608
|
+
themePath,
|
|
609
|
+
brand,
|
|
610
|
+
diagnostics: [...brandDiagnostics(brand), ...catalog.diagnostics]
|
|
611
|
+
};
|
|
612
|
+
};
|
|
613
|
+
/** Paths under these are the tool's own output, not project source. */
|
|
614
|
+
const IGNORED_WATCH = [
|
|
615
|
+
SAMVA_DIR,
|
|
616
|
+
".git",
|
|
617
|
+
"node_modules"
|
|
618
|
+
];
|
|
619
|
+
/**
|
|
620
|
+
* The authoring loop: one watcher, one brand resolver, a catalog reloaded on
|
|
621
|
+
* demand. Each `load` re-reads the project and recompiles every template, so a
|
|
622
|
+
* partial that changed reaches every template that imports it.
|
|
623
|
+
*/
|
|
624
|
+
const createProjectSession = (options = {}) => {
|
|
625
|
+
const root = projectRoot(options);
|
|
626
|
+
let latest;
|
|
627
|
+
let brands;
|
|
628
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
629
|
+
const observer = watch(root, { recursive: true }, (_event, name) => {
|
|
630
|
+
const changed = name === null ? "" : toPosix(name.toString());
|
|
631
|
+
if (IGNORED_WATCH.some((ignored) => changed === ignored || changed.startsWith(`${ignored}/`))) return;
|
|
632
|
+
if (changed.split("/").includes("node_modules")) return;
|
|
633
|
+
for (const listener of listeners) listener(changed);
|
|
634
|
+
});
|
|
635
|
+
return {
|
|
636
|
+
load: async () => {
|
|
637
|
+
brands ??= createBrandResolver({
|
|
638
|
+
root,
|
|
639
|
+
env: await loadBrandEnv(root),
|
|
640
|
+
offline: options.offline === true
|
|
641
|
+
});
|
|
642
|
+
latest = await loadProject(options, brands);
|
|
643
|
+
return latest;
|
|
644
|
+
},
|
|
645
|
+
current: () => latest,
|
|
646
|
+
subscribe: (listener) => {
|
|
647
|
+
listeners.add(listener);
|
|
648
|
+
return () => void listeners.delete(listener);
|
|
649
|
+
},
|
|
650
|
+
close: async () => {
|
|
651
|
+
observer.close();
|
|
652
|
+
listeners.clear();
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Where an export serves its assets from when the caller names no origin.
|
|
658
|
+
*
|
|
659
|
+
* An export writes one page at `templates/<id>/<fixture>.html` and every asset
|
|
660
|
+
* once at `assets/<content address>`, so the base a page resolves against is
|
|
661
|
+
* the path from its own directory back to that one. A template id is one path
|
|
662
|
+
* segment, so every page sits at the same depth and one base serves them all.
|
|
663
|
+
*/
|
|
664
|
+
const DEFAULT_EXPORT_ASSET_BASE = "../../assets";
|
|
665
|
+
/**
|
|
666
|
+
* The path an export writes, or `undefined` when a name would leave the root.
|
|
667
|
+
* The compiler already refuses an id or a fixture key that is not one path
|
|
668
|
+
* segment, so this is the guard rather than the contract: the same pattern,
|
|
669
|
+
* plus a containment check, so a name that reaches `join` can never address a
|
|
670
|
+
* file the export does not own.
|
|
671
|
+
*/
|
|
672
|
+
const exportPath = (out, ...segments) => {
|
|
673
|
+
if (!segments.every((segment) => TEMPLATE_NAME_PATTERN.test(segment))) return void 0;
|
|
674
|
+
const path = join(out, ...segments);
|
|
675
|
+
const inside = relative(out, path);
|
|
676
|
+
return inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside) ? void 0 : path;
|
|
677
|
+
};
|
|
678
|
+
const unsafeExportName = (kind, value) => finding("unsafe-export-name", "error", `${kind} ${JSON.stringify(value)} is not one path segment, so an export cannot write a file for it. Use letters, digits, ".", "_" and "-", starting with a letter, digit or "_".`);
|
|
679
|
+
/**
|
|
680
|
+
* Render every template's fixtures to files beside the bytes of every asset they
|
|
681
|
+
* reference, with the manifest that maps each address to its file. Email
|
|
682
|
+
* fixtures write `<fixture>.html` and `<fixture>.txt`; a template's SMS and
|
|
683
|
+
* WhatsApp channels write `<fixture>.sms.txt` and `<fixture>.whatsapp.json`.
|
|
684
|
+
*
|
|
685
|
+
* A relative `assetBase` is accepted, and the written page resolves its images
|
|
686
|
+
* from the directory it sits in. Delivery keeps refusing relative references,
|
|
687
|
+
* because an inbox has no directory to resolve one against, so an export
|
|
688
|
+
* destined for sending names the https origin it will be served from instead.
|
|
689
|
+
*/
|
|
690
|
+
const exportProject = async (input) => {
|
|
691
|
+
const out = resolve(input.out);
|
|
692
|
+
const base = input.assetBase ?? "../../assets";
|
|
693
|
+
const project = await loadProject({
|
|
694
|
+
...input,
|
|
695
|
+
assetBase: compileBase(base)
|
|
696
|
+
});
|
|
697
|
+
const { catalog } = project;
|
|
698
|
+
const diagnostics = [
|
|
699
|
+
...project.diagnostics,
|
|
700
|
+
...catalog.failures.flatMap((failure) => failure.diagnostics),
|
|
701
|
+
...catalog.templates.flatMap((entry) => entry.diagnostics)
|
|
702
|
+
];
|
|
703
|
+
const templates = [];
|
|
704
|
+
for (const entry of catalog.templates) {
|
|
705
|
+
const directory = exportPath(out, "templates", entry.id);
|
|
706
|
+
if (directory === void 0) {
|
|
707
|
+
diagnostics.push(unsafeExportName("Template id", entry.id));
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
await mkdir(directory, { recursive: true });
|
|
711
|
+
const rendered = entry.channels.includes("email") ? renderEmailFixtures(entry) : [];
|
|
712
|
+
const exported = [];
|
|
713
|
+
for (const fixture of Object.keys(entry.fixtures)) {
|
|
714
|
+
const write = async (name, content) => {
|
|
715
|
+
const path = exportPath(out, "templates", entry.id, name);
|
|
716
|
+
if (path === void 0) {
|
|
717
|
+
diagnostics.push(unsafeExportName("Fixture name", fixture));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
await writeFile(path, content, "utf8");
|
|
721
|
+
return toPosix(relative(out, path));
|
|
722
|
+
};
|
|
723
|
+
const email = rendered.find((result) => result.fixture === fixture);
|
|
724
|
+
diagnostics.push(...email?.diagnostics ?? []);
|
|
725
|
+
const sms = entry.channels.includes("sms") ? renderChannelFixture(entry, "sms", fixture) : void 0;
|
|
726
|
+
const whatsapp = entry.channels.includes("whatsapp") ? renderChannelFixture(entry, "whatsapp", fixture) : void 0;
|
|
727
|
+
for (const result of [sms, whatsapp]) if (result?.ok === false) diagnostics.push(...result.diagnostics);
|
|
728
|
+
exported.push({
|
|
729
|
+
fixture,
|
|
730
|
+
...email?.rendered === void 0 ? {} : {
|
|
731
|
+
html: await write(`${fixture}.html`, restoreBase(email.rendered.html, base)),
|
|
732
|
+
text: await write(`${fixture}.txt`, email.rendered.text)
|
|
733
|
+
},
|
|
734
|
+
...sms?.ok === true && sms.channel === "sms" ? { sms: await write(`${fixture}.sms.txt`, sms.text) } : {},
|
|
735
|
+
...whatsapp?.ok === true && whatsapp.channel === "whatsapp" ? { whatsapp: await write(`${fixture}.whatsapp.json`, restoreBase(JSON.stringify(whatsapp.message, null, 2), base)) } : {}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
templates.push({
|
|
739
|
+
id: entry.id,
|
|
740
|
+
entryPath: entry.entryPath,
|
|
741
|
+
fixtures: exported
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
const assetDirectory = join(out, "assets");
|
|
745
|
+
if (catalog.assets.length > 0) await mkdir(assetDirectory, { recursive: true });
|
|
746
|
+
await Promise.all(catalog.assets.map((asset) => {
|
|
747
|
+
const bytes = project.files[asset.path];
|
|
748
|
+
return bytes === void 0 || typeof bytes === "string" ? Promise.resolve() : writeFile(join(assetDirectory, asset.fileName), bytes);
|
|
749
|
+
}));
|
|
750
|
+
const receipt = {
|
|
751
|
+
templates,
|
|
752
|
+
assets: {
|
|
753
|
+
base,
|
|
754
|
+
assets: catalog.assets
|
|
755
|
+
},
|
|
756
|
+
diagnostics
|
|
757
|
+
};
|
|
758
|
+
await mkdir(out, { recursive: true });
|
|
759
|
+
await writeFile(join(out, "catalog.json"), `${JSON.stringify({
|
|
760
|
+
...receipt,
|
|
761
|
+
assets: {
|
|
762
|
+
base,
|
|
763
|
+
assets: catalog.assets.map((asset) => ({
|
|
764
|
+
...asset,
|
|
765
|
+
url: assetUrl(asset, base)
|
|
766
|
+
}))
|
|
767
|
+
}
|
|
768
|
+
}, null, 2)}\n`, "utf8");
|
|
769
|
+
return receipt;
|
|
770
|
+
};
|
|
771
|
+
//#endregion
|
|
772
|
+
export { discoverEntries as C, readProjectFiles as D, projectPath as E, templateDirectories as O, SAMVA_DIR as S, isWithin as T, brandDiagnostics as _, projectRoot as a, resolveProjectBrand as b, restoreBase as c, emailCompatibility as d, finding as f, BRAND_SNAPSHOT_DIR as g, renderEmailFixtures as h, loadProject as i, isObject as k, channelsOf as l, renderChannelFixture as m, createProjectSession as n, compileBase as o, hasErrors as p, exportProject as r, isDeliverableBase as s, DEFAULT_EXPORT_ASSET_BASE as t, compileProject as u, createBrandResolver as v, isProjectFile as w, DEFAULT_TEMPLATE_DIRS as x, loadBrandEnv as y };
|
|
773
|
+
|
|
774
|
+
//# sourceMappingURL=project-Cp8qb3s0.js.map
|