@samva/vite 0.9.0 → 0.11.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 +9 -9
- package/CHANGELOG.md +380 -0
- package/README.md +44 -30
- package/dist/brand-BkXQMGgY.d.ts +52 -0
- package/dist/build.d.ts +3 -3
- package/dist/build.js +1 -1
- package/dist/dev.d.ts +8 -0
- package/dist/dev.js +8 -2
- package/dist/editor/assets/index-BBtWrzoS.css +2 -0
- package/dist/editor/assets/index-gqTrwEST.js +34 -0
- package/dist/editor/index.html +2 -2
- package/dist/editor/licenses/hugeicons-core-free-icons.md +21 -0
- package/dist/editor/licenses/hugeicons-react.md +21 -0
- package/dist/editor-DfdlKEeu.d.ts +48 -0
- package/dist/{editor-Cow7zZ_9.js → editor-DnFOzVJA.js} +15 -16
- package/dist/index.d.ts +3 -27
- package/dist/index.js +1 -1
- package/dist/{project-Cp8qb3s0.js → project-DwbOoPDk.js} +118 -377
- package/dist/project.d.ts +192 -2
- package/dist/project.js +2 -2
- package/dist/render.d.ts +3 -3
- package/dist/render.js +2 -2
- package/package.json +6 -9
- package/dist/editor/assets/index-BtfwuWcY.js +0 -32
- package/dist/editor/assets/index-DIuS_EZu.css +0 -2
- package/dist/project-CiUuzKsC.d.ts +0 -271
|
@@ -1,340 +1,25 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, readdir,
|
|
2
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { assetUrl, isAssetPath } from "@samva/markup/
|
|
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
|
|
4
|
+
import { TEMPLATE_NAME_PATTERN, checkEmailCompatibility, compileTemplate, projectBrandSpecifier } from "@samva/markup/compiler";
|
|
5
|
+
import { renderIr, renderIrSms, renderIrWhatsApp } from "@samva/markup/render";
|
|
6
|
+
import { assetUrl, isAssetPath } from "@samva/markup/brand";
|
|
86
7
|
//#region src/brand.ts
|
|
87
|
-
/**
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
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 {
|
|
8
|
+
/** Resolve only when a host supplies a resolver; brand imports otherwise fail loudly. */
|
|
9
|
+
const resolveBrand = async (files, themePath, resolver, plugin) => {
|
|
10
|
+
if (resolver !== void 0) return resolver.resolve(files, themePath);
|
|
11
|
+
const specifier = projectBrandSpecifier(files, themePath ?? "", plugin);
|
|
12
|
+
return specifier === void 0 ? {
|
|
284
13
|
ok: true,
|
|
285
14
|
brand: void 0,
|
|
286
15
|
source: "none",
|
|
287
16
|
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 {
|
|
17
|
+
} : {
|
|
314
18
|
ok: false,
|
|
315
|
-
message: `The project imports ${specifier}, but
|
|
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
|
-
}]
|
|
19
|
+
message: `The project imports ${specifier}, but no brandResolver is configured. Pass a brandResolver to the editor or project options.`
|
|
325
20
|
};
|
|
326
21
|
};
|
|
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. */
|
|
22
|
+
/** Host warnings stay warnings; unavailable brands are errors. */
|
|
338
23
|
const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map((warning) => ({
|
|
339
24
|
code: warning.code,
|
|
340
25
|
severity: "warning",
|
|
@@ -346,31 +31,6 @@ const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map
|
|
|
346
31
|
message: resolution.message,
|
|
347
32
|
origins: []
|
|
348
33
|
}];
|
|
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
34
|
//#endregion
|
|
375
35
|
//#region src/compile.ts
|
|
376
36
|
const CHANNELS = [
|
|
@@ -394,6 +54,7 @@ const compileEntry = async (input, entryPath) => {
|
|
|
394
54
|
files: input.files,
|
|
395
55
|
entry: entryPath,
|
|
396
56
|
assetBase: input.assetBase,
|
|
57
|
+
brandPlugin: input.brandPlugin,
|
|
397
58
|
...input.brand === void 0 ? {} : { brand: input.brand },
|
|
398
59
|
tailwind: input.theme === void 0 ? {} : {
|
|
399
60
|
css: input.theme.css,
|
|
@@ -434,7 +95,8 @@ const compileEntry = async (input, entryPath) => {
|
|
|
434
95
|
fixtures: compiled.fixtures,
|
|
435
96
|
assets: compiled.assets,
|
|
436
97
|
diagnostics: compiled.diagnostics,
|
|
437
|
-
channels: channelsOf(ir)
|
|
98
|
+
channels: channelsOf(ir),
|
|
99
|
+
brandPlugin: input.brandPlugin
|
|
438
100
|
}
|
|
439
101
|
};
|
|
440
102
|
};
|
|
@@ -495,7 +157,11 @@ const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.fixtur
|
|
|
495
157
|
return {
|
|
496
158
|
templateId: entry.id,
|
|
497
159
|
fixture,
|
|
498
|
-
rendered: renderIr(entry.ir, input,
|
|
160
|
+
rendered: renderIr(entry.ir, input, {
|
|
161
|
+
positions: true,
|
|
162
|
+
...options,
|
|
163
|
+
...entry.brandPlugin === void 0 ? {} : { brandPlugin: entry.brandPlugin }
|
|
164
|
+
}),
|
|
499
165
|
diagnostics: []
|
|
500
166
|
};
|
|
501
167
|
} catch (cause) {
|
|
@@ -509,12 +175,25 @@ const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.fixtur
|
|
|
509
175
|
});
|
|
510
176
|
/** Client-matrix findings across the fixtures that rendered. */
|
|
511
177
|
const emailCompatibility = (entry, rendered) => {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
178
|
+
return rendered.flatMap((result) => {
|
|
179
|
+
if (result.rendered === void 0) return [];
|
|
180
|
+
return checkEmailCompatibility({
|
|
181
|
+
html: result.rendered.html,
|
|
182
|
+
fixture: result.fixture,
|
|
183
|
+
positions: (result.rendered.positions ?? []).map((position, index) => ({
|
|
184
|
+
start: position.start,
|
|
185
|
+
end: position.end,
|
|
186
|
+
tag: position.tag,
|
|
187
|
+
instancePath: String(index),
|
|
188
|
+
authored: true,
|
|
189
|
+
origins: position.src === void 0 ? [] : [{
|
|
190
|
+
fileName: entry.ir.sources?.[position.src[2] ?? 0] ?? "",
|
|
191
|
+
lineNumber: position.src[0],
|
|
192
|
+
columnNumber: position.src[1]
|
|
193
|
+
}]
|
|
194
|
+
}))
|
|
195
|
+
});
|
|
196
|
+
});
|
|
518
197
|
};
|
|
519
198
|
/** Render the SMS or WhatsApp channel of one fixture. */
|
|
520
199
|
const renderChannelFixture = (entry, channel, fixture, options = {}) => {
|
|
@@ -542,6 +221,78 @@ const renderChannelFixture = (entry, channel, fixture, options = {}) => {
|
|
|
542
221
|
};
|
|
543
222
|
const hasErrors = (diagnostics) => diagnostics.some((item) => item.severity === "error");
|
|
544
223
|
//#endregion
|
|
224
|
+
//#region src/layout.ts
|
|
225
|
+
/** Directories scanned for templates when no `dir` is configured, in scan order. */
|
|
226
|
+
const DEFAULT_TEMPLATE_DIRS = ["templates", "emails"];
|
|
227
|
+
/** The folder for files this package generates, relative to the project root. Gitignore it. */
|
|
228
|
+
const SAMVA_DIR = ".samva";
|
|
229
|
+
const toPosix = (value) => sep === "/" ? value : value.split(sep).join("/");
|
|
230
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
231
|
+
const isProjectText = (name) => SCRIPT.test(name) || name.endsWith(".css") || name.endsWith(".json");
|
|
232
|
+
/** True for a file a compile can read: scripts, stylesheets, JSON and importable assets. */
|
|
233
|
+
const isProjectFile = (path) => isProjectText(path) || isAssetPath(path);
|
|
234
|
+
const skipped = (name) => name.startsWith(".") || name === "node_modules";
|
|
235
|
+
/** True when `path` is `parent` or lies beneath it. */
|
|
236
|
+
const isWithin = (path, parent) => {
|
|
237
|
+
const inside = relative(parent, path);
|
|
238
|
+
return inside === "" || !inside.startsWith("..") && !isAbsolute(inside);
|
|
239
|
+
};
|
|
240
|
+
const isDirectory = async (path) => (await stat(path).catch(() => void 0))?.isDirectory() === true;
|
|
241
|
+
/**
|
|
242
|
+
* The absolute directories a project's templates live in. A configured `dir`
|
|
243
|
+
* is the only one scanned; otherwise every default directory that exists. A
|
|
244
|
+
* directory outside the root cannot be compiled, because a project's files are
|
|
245
|
+
* addressed relative to its root, so it is refused rather than half-read.
|
|
246
|
+
*/
|
|
247
|
+
const templateDirectories = async (root, dir) => {
|
|
248
|
+
if (dir !== void 0) {
|
|
249
|
+
const configured = resolve(root, dir);
|
|
250
|
+
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.`);
|
|
251
|
+
return await isDirectory(configured) ? [configured] : [];
|
|
252
|
+
}
|
|
253
|
+
return (await Promise.all(DEFAULT_TEMPLATE_DIRS.map(async (name) => {
|
|
254
|
+
const path = join(root, name);
|
|
255
|
+
return await isDirectory(path) ? path : void 0;
|
|
256
|
+
}))).filter((path) => path !== void 0);
|
|
257
|
+
};
|
|
258
|
+
/** Root-relative POSIX path of an absolute one. */
|
|
259
|
+
const projectPath = (root, path) => toPosix(relative(root, path));
|
|
260
|
+
/** Every `.tsx` file beneath the given directories, as root-relative paths, sorted. */
|
|
261
|
+
const discoverEntries = async (root, dirs) => {
|
|
262
|
+
const found = [];
|
|
263
|
+
const walk = async (current) => {
|
|
264
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
265
|
+
await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
|
|
266
|
+
const full = join(current, entry.name);
|
|
267
|
+
if (entry.isDirectory()) return walk(full);
|
|
268
|
+
if (entry.isFile() && entry.name.endsWith(".tsx")) found.push(projectPath(root, full));
|
|
269
|
+
}));
|
|
270
|
+
};
|
|
271
|
+
await Promise.all(dirs.map(walk));
|
|
272
|
+
return [...new Set(found)].sort();
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Read the project into the map the compiler consumes: scripts, stylesheets
|
|
276
|
+
* and JSON as text, imported assets as bytes. Hidden folders (`.samva`, `.git`)
|
|
277
|
+
* and `node_modules` are not part of a project.
|
|
278
|
+
*/
|
|
279
|
+
const readProjectFiles = async (root) => {
|
|
280
|
+
const files = {};
|
|
281
|
+
const walk = async (current) => {
|
|
282
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
283
|
+
await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
|
|
284
|
+
const full = join(current, entry.name);
|
|
285
|
+
if (entry.isDirectory()) return walk(full);
|
|
286
|
+
if (!entry.isFile()) return;
|
|
287
|
+
const path = projectPath(root, full);
|
|
288
|
+
if (isAssetPath(path)) files[path] = new Uint8Array(await readFile(full));
|
|
289
|
+
else if (isProjectText(entry.name)) files[path] = await readFile(full, "utf8");
|
|
290
|
+
}));
|
|
291
|
+
};
|
|
292
|
+
await walk(root);
|
|
293
|
+
return files;
|
|
294
|
+
};
|
|
295
|
+
//#endregion
|
|
545
296
|
//#region src/local-assets.ts
|
|
546
297
|
const HTTPS = /^https:\/\//i;
|
|
547
298
|
/** True when the renderer accepts `base` as it is: a delivered message can resolve it. */
|
|
@@ -574,11 +325,10 @@ const projectThemePath = (root, themePath) => relative(root, themePath).split(se
|
|
|
574
325
|
/** Resolve the root a set of options names. */
|
|
575
326
|
const projectRoot = (options) => resolve(options.root ?? process.cwd());
|
|
576
327
|
/**
|
|
577
|
-
* Read, resolve the brand of, and compile every template in a project. `
|
|
578
|
-
* lets a long-lived caller reuse a brand resolution between loads, and `only`
|
|
328
|
+
* Read, resolve the brand of, and compile every template in a project. `only`
|
|
579
329
|
* limits compilation to the named project-relative entries.
|
|
580
330
|
*/
|
|
581
|
-
const loadProject = async (options = {},
|
|
331
|
+
const loadProject = async (options = {}, only) => {
|
|
582
332
|
const root = projectRoot(options);
|
|
583
333
|
const dirs = await templateDirectories(root, options.dir);
|
|
584
334
|
const [discovered, files] = await Promise.all([discoverEntries(root, dirs), readProjectFiles(root)]);
|
|
@@ -586,15 +336,12 @@ const loadProject = async (options = {}, brands, only) => {
|
|
|
586
336
|
const themeFile = resolveThemePath(root, options.theme);
|
|
587
337
|
const themeCss = await readProjectThemeCss(themeFile);
|
|
588
338
|
const themePath = themeFile === void 0 || themeCss === void 0 ? void 0 : projectThemePath(root, themeFile);
|
|
589
|
-
const brand = await (
|
|
590
|
-
root,
|
|
591
|
-
env: await loadBrandEnv(root),
|
|
592
|
-
offline: options.offline === true
|
|
593
|
-
})).resolve(files, themePath);
|
|
339
|
+
const brand = await resolveBrand(files, themePath, options.brandResolver, options.brandPlugin);
|
|
594
340
|
const catalog = await compileProject({
|
|
595
341
|
files,
|
|
596
342
|
entries,
|
|
597
343
|
assetBase: options.assetBase ?? "",
|
|
344
|
+
brandPlugin: options.brandPlugin,
|
|
598
345
|
...themeCss === void 0 || themePath === void 0 ? {} : { theme: {
|
|
599
346
|
css: themeCss,
|
|
600
347
|
path: themePath
|
|
@@ -624,7 +371,6 @@ const IGNORED_WATCH = [
|
|
|
624
371
|
const createProjectSession = (options = {}) => {
|
|
625
372
|
const root = projectRoot(options);
|
|
626
373
|
let latest;
|
|
627
|
-
let brands;
|
|
628
374
|
const listeners = /* @__PURE__ */ new Set();
|
|
629
375
|
const observer = watch(root, { recursive: true }, (_event, name) => {
|
|
630
376
|
const changed = name === null ? "" : toPosix(name.toString());
|
|
@@ -634,12 +380,7 @@ const createProjectSession = (options = {}) => {
|
|
|
634
380
|
});
|
|
635
381
|
return {
|
|
636
382
|
load: async () => {
|
|
637
|
-
|
|
638
|
-
root,
|
|
639
|
-
env: await loadBrandEnv(root),
|
|
640
|
-
offline: options.offline === true
|
|
641
|
-
});
|
|
642
|
-
latest = await loadProject(options, brands);
|
|
383
|
+
latest = await loadProject(options);
|
|
643
384
|
return latest;
|
|
644
385
|
},
|
|
645
386
|
current: () => latest,
|
|
@@ -769,6 +510,6 @@ const exportProject = async (input) => {
|
|
|
769
510
|
return receipt;
|
|
770
511
|
};
|
|
771
512
|
//#endregion
|
|
772
|
-
export {
|
|
513
|
+
export { renderEmailFixtures as C, renderChannelFixture as S, channelsOf as _, projectRoot as a, finding as b, restoreBase as c, discoverEntries as d, isProjectFile as f, templateDirectories as g, readProjectFiles as h, loadProject as i, DEFAULT_TEMPLATE_DIRS as l, projectPath as m, createProjectSession as n, compileBase as o, isWithin as p, exportProject as r, isDeliverableBase as s, DEFAULT_EXPORT_ASSET_BASE as t, SAMVA_DIR as u, compileProject as v, brandDiagnostics as w, hasErrors as x, emailCompatibility as y };
|
|
773
514
|
|
|
774
|
-
//# sourceMappingURL=project-
|
|
515
|
+
//# sourceMappingURL=project-DwbOoPDk.js.map
|