@samva/vite 0.10.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.
@@ -1,338 +1,25 @@
1
1
  import { watch } from "node:fs";
2
- import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
3
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { execFile } from "node:child_process";
5
- import { assetUrl, isAssetPath } from "@samva/markup/brand";
6
- import { TEMPLATE_NAME_PATTERN, checkTemplateCompatibility, compileTemplate, parseBrandSpecifier, projectBrandSpecifier } from "@samva/markup/compiler";
4
+ import { TEMPLATE_NAME_PATTERN, checkEmailCompatibility, compileTemplate, projectBrandSpecifier } from "@samva/markup/compiler";
7
5
  import { renderIr, renderIrSms, renderIrWhatsApp } from "@samva/markup/render";
8
- //#region src/internal/guards.ts
9
- /** A non-null, non-array object, narrowed for property access. */
10
- const isObject = (input) => typeof input === "object" && input !== null && !Array.isArray(input);
11
- //#endregion
12
- //#region src/layout.ts
13
- /** Directories scanned for templates when no `dir` is configured, in scan order. */
14
- const DEFAULT_TEMPLATE_DIRS = ["templates", "emails"];
15
- /** The folder for files this package generates, relative to the project root. Gitignore it. */
16
- const SAMVA_DIR = ".samva";
17
- const toPosix = (value) => sep === "/" ? value : value.split(sep).join("/");
18
- const SCRIPT = /\.[cm]?[jt]sx?$/;
19
- const isProjectText = (name) => SCRIPT.test(name) || name.endsWith(".css") || name.endsWith(".json");
20
- /** True for a file a compile can read: scripts, stylesheets, JSON and importable assets. */
21
- const isProjectFile = (path) => isProjectText(path) || isAssetPath(path);
22
- const skipped = (name) => name.startsWith(".") || name === "node_modules";
23
- /** True when `path` is `parent` or lies beneath it. */
24
- const isWithin = (path, parent) => {
25
- const inside = relative(parent, path);
26
- return inside === "" || !inside.startsWith("..") && !isAbsolute(inside);
27
- };
28
- const isDirectory = async (path) => (await stat(path).catch(() => void 0))?.isDirectory() === true;
29
- /**
30
- * The absolute directories a project's templates live in. A configured `dir`
31
- * is the only one scanned; otherwise every default directory that exists. A
32
- * directory outside the root cannot be compiled, because a project's files are
33
- * addressed relative to its root, so it is refused rather than half-read.
34
- */
35
- const templateDirectories = async (root, dir) => {
36
- if (dir !== void 0) {
37
- const configured = resolve(root, dir);
38
- 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.`);
39
- return await isDirectory(configured) ? [configured] : [];
40
- }
41
- return (await Promise.all(DEFAULT_TEMPLATE_DIRS.map(async (name) => {
42
- const path = join(root, name);
43
- return await isDirectory(path) ? path : void 0;
44
- }))).filter((path) => path !== void 0);
45
- };
46
- /** Root-relative POSIX path of an absolute one. */
47
- const projectPath = (root, path) => toPosix(relative(root, path));
48
- /** Every `.tsx` file beneath the given directories, as root-relative paths, sorted. */
49
- const discoverEntries = async (root, dirs) => {
50
- const found = [];
51
- const walk = async (current) => {
52
- const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
53
- await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
54
- const full = join(current, entry.name);
55
- if (entry.isDirectory()) return walk(full);
56
- if (entry.isFile() && entry.name.endsWith(".tsx")) found.push(projectPath(root, full));
57
- }));
58
- };
59
- await Promise.all(dirs.map(walk));
60
- return [...new Set(found)].sort();
61
- };
62
- /**
63
- * Read the project into the map the compiler consumes: scripts, stylesheets
64
- * and JSON as text, imported assets as bytes. Hidden folders (`.samva`, `.git`)
65
- * and `node_modules` are not part of a project.
66
- */
67
- const readProjectFiles = async (root) => {
68
- const files = {};
69
- const walk = async (current) => {
70
- const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
71
- await Promise.all(entries.filter((entry) => !skipped(entry.name)).map(async (entry) => {
72
- const full = join(current, entry.name);
73
- if (entry.isDirectory()) return walk(full);
74
- if (!entry.isFile()) return;
75
- const path = projectPath(root, full);
76
- if (isAssetPath(path)) files[path] = new Uint8Array(await readFile(full));
77
- else if (isProjectText(entry.name)) files[path] = await readFile(full, "utf8");
78
- }));
79
- };
80
- await walk(root);
81
- return files;
82
- };
83
- //#endregion
6
+ import { assetUrl, isAssetPath } from "@samva/markup/brand";
84
7
  //#region src/brand.ts
85
- /** Where brand records are cached, relative to the project root. */
86
- const BRAND_SNAPSHOT_DIR = `${SAMVA_DIR}/brands`;
87
- const SNAPSHOT_VERSION = 1;
88
- const DEFAULT_API_URL = "https://api.samva.dev";
89
- const LIVE_TIMEOUT_MS = 15e3;
90
- /** How long a resolution is reused, so a watch loop does not ask the API on every save. */
91
- const RESOLUTION_TTL_MS = 3e4;
92
- /** Accept only the fields a build needs, from an API body or a snapshot file. */
93
- const asBrand = (value) => {
94
- if (!isObject(value)) return void 0;
95
- const { slug, name, isDefault, css, assets, footer, digest } = value;
96
- 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;
97
- return {
98
- slug,
99
- name,
100
- isDefault,
101
- css,
102
- digest,
103
- ...assets === void 0 ? {} : { assets },
104
- ...footer === void 0 ? {} : { footer }
105
- };
106
- };
107
- const liveFromBody = (body, when) => {
108
- const brand = asBrand(body);
109
- return brand === void 0 ? {
110
- ok: false,
111
- reason: `${when} answered with something that is not a brand`
112
- } : {
113
- ok: true,
114
- brand: {
115
- ...brand,
116
- version: SNAPSHOT_VERSION,
117
- pulledAt: ""
118
- }
119
- };
120
- };
121
- const fetchJson = async (url, apiKey) => {
122
- const response = await fetch(url, {
123
- headers: {
124
- "x-api-key": apiKey,
125
- accept: "application/json"
126
- },
127
- signal: AbortSignal.timeout(LIVE_TIMEOUT_MS)
128
- });
129
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
130
- return response.json();
131
- };
132
- const liveWithApiKey = async (slug, env, apiKey) => {
133
- const base = (env["SAMVA_API_URL"] || DEFAULT_API_URL).replace(/\/+$/, "");
134
- try {
135
- if (slug !== void 0) return liveFromBody(await fetchJson(`${base}/v1/brands/${slug}`, apiKey), "The Samva API");
136
- const body = await fetchJson(`${base}/v1/brands`, apiKey);
137
- const found = (isObject(body) && Array.isArray(body["items"]) ? body["items"] : []).find((item) => isObject(item) && item["isDefault"] === true);
138
- return found === void 0 ? {
139
- ok: false,
140
- reason: "the organization has no default brand"
141
- } : liveFromBody(found, "The Samva API");
142
- } catch (cause) {
143
- return {
144
- ok: false,
145
- reason: `the Samva API did not answer (${cause instanceof Error ? cause.message : String(cause)})`
146
- };
147
- }
148
- };
149
- /** The CLI's error envelope message, or its raw stderr. */
150
- const cliFailure = (stderr) => {
151
- const parsed = (() => {
152
- try {
153
- return JSON.parse(stderr);
154
- } catch {
155
- return;
156
- }
157
- })();
158
- return isObject(parsed) && typeof parsed["message"] === "string" ? parsed["message"] : stderr.trim() || "it exited without a message";
159
- };
160
- const liveWithCli = (slug, env) => new Promise((resolve) => {
161
- execFile("samva", [
162
- "brands",
163
- "get",
164
- ...slug === void 0 ? [] : [slug],
165
- "--json"
166
- ], {
167
- env: { ...env },
168
- timeout: LIVE_TIMEOUT_MS,
169
- maxBuffer: 1048576
170
- }, (error, stdout, stderr) => {
171
- if (error !== null) {
172
- const code = error.code;
173
- resolve({
174
- ok: false,
175
- reason: code === "ENOENT" ? "there is no SAMVA_API_KEY and no `samva` CLI on PATH" : `\`samva brands get\` failed: ${cliFailure(stderr)}`
176
- });
177
- return;
178
- }
179
- const envelope = (() => {
180
- try {
181
- return JSON.parse(stdout);
182
- } catch {
183
- return;
184
- }
185
- })();
186
- resolve(liveFromBody(isObject(envelope) ? envelope["data"] : void 0, "`samva brands get`"));
187
- });
188
- });
189
- const fetchLiveBrand = (slug, env) => {
190
- const apiKey = env["SAMVA_API_KEY"];
191
- return apiKey !== void 0 && apiKey.length > 0 ? liveWithApiKey(slug, env, apiKey) : liveWithCli(slug, env);
192
- };
193
- const readSnapshotFile = async (path) => {
194
- const raw = await readFile(path, "utf8").catch((cause) => {
195
- if (cause.code === "ENOENT") return void 0;
196
- throw cause;
197
- });
198
- if (raw === void 0) return {
199
- ok: true,
200
- snapshot: void 0,
201
- path
202
- };
203
- const parsed = (() => {
204
- try {
205
- return JSON.parse(raw);
206
- } catch {
207
- return;
208
- }
209
- })();
210
- const brand = asBrand(parsed);
211
- if (brand === void 0 || !isObject(parsed) || parsed["version"] !== SNAPSHOT_VERSION || typeof parsed["pulledAt"] !== "string") return {
212
- ok: false,
213
- message: `${path} is not a brand snapshot this version of @samva/vite reads. Run \`samva brands pull\` to rewrite it.`
214
- };
215
- return {
216
- ok: true,
217
- snapshot: {
218
- ...brand,
219
- version: SNAPSHOT_VERSION,
220
- pulledAt: parsed["pulledAt"]
221
- },
222
- path
223
- };
224
- };
225
- /**
226
- * The snapshot for a named brand, or for the default brand the most recently
227
- * pulled snapshot that was the default when it was pulled.
228
- */
229
- const readSnapshot = async (root, slug) => {
230
- const directory = join(root, BRAND_SNAPSHOT_DIR);
231
- if (slug !== void 0) return readSnapshotFile(join(directory, `${slug}.json`));
232
- const names = await readdir(directory).catch((cause) => {
233
- if (cause.code === "ENOENT") return [];
234
- throw cause;
235
- });
236
- const reads = await Promise.all(names.filter((name) => name.endsWith(".json")).sort().map((name) => readSnapshotFile(join(directory, name))));
237
- const failed = reads.find((read) => !read.ok);
238
- if (failed !== void 0) return failed;
239
- const defaults = reads.flatMap((read) => read.ok && read.snapshot?.isDefault === true ? [{
240
- ...read,
241
- snapshot: read.snapshot
242
- }] : []);
243
- defaults.sort((a, b) => b.snapshot.pulledAt.localeCompare(a.snapshot.pulledAt));
244
- return defaults[0] ?? {
245
- ok: true,
246
- snapshot: void 0,
247
- path: directory
248
- };
249
- };
250
- const SAFE_SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
251
- /**
252
- * Refresh the cached copy after a live answer. Best effort: a read-only checkout
253
- * or a full disk must not fail a build that already has the brand it needs.
254
- */
255
- const cacheBrand = async (root, brand, cached) => {
256
- if (!SAFE_SLUG.test(brand.slug) || cached?.digest === brand.digest) return;
257
- const directory = join(root, BRAND_SNAPSHOT_DIR);
258
- const target = join(directory, `${brand.slug}.json`);
259
- const snapshot = {
260
- ...brand,
261
- pulledAt: (/* @__PURE__ */ new Date()).toISOString()
262
- };
263
- const temporary = `${target}.${process.pid}.tmp`;
264
- await mkdir(directory, { recursive: true }).then(() => writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8")).then(() => rename(temporary, target)).catch(() => void 0);
265
- };
266
- const toResolved = (brand) => ({
267
- slug: brand.slug,
268
- css: brand.css,
269
- digest: brand.digest,
270
- ...brand.assets === void 0 ? {} : { assets: brand.assets },
271
- ...brand.footer === void 0 ? {} : { footer: brand.footer }
272
- });
273
- const relativeSnapshot = (root, path) => relative(root, path).split(sep).join("/");
274
- /**
275
- * Resolve the brand a project's `samva:brand` import names: live when the
276
- * environment's credentials reach the API, else the pulled snapshot with a
277
- * warning, else a failure naming both fixes.
278
- */
279
- const resolveProjectBrand = async (input) => {
280
- const specifier = projectBrandSpecifier(input.files, input.themePath ?? "");
281
- 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 ? {
282
13
  ok: true,
283
14
  brand: void 0,
284
15
  source: "none",
285
16
  warnings: []
286
- };
287
- const slug = parseBrandSpecifier(specifier)?.slug;
288
- const pullCommand = `samva brands pull${slug === void 0 ? "" : ` ${slug}`}`;
289
- const [live, local] = await Promise.all([input.offline === true ? Promise.resolve({
290
- ok: false,
291
- reason: "offline mode skips the live brand"
292
- }) : fetchLiveBrand(slug, input.env), readSnapshot(input.root, slug)]);
293
- if (live.ok) {
294
- const cached = local.ok ? local.snapshot : void 0;
295
- const stale = cached !== void 0 && cached.digest !== live.brand.digest;
296
- await cacheBrand(input.root, live.brand, cached);
297
- return {
298
- ok: true,
299
- brand: toResolved(live.brand),
300
- source: "live",
301
- warnings: stale ? [{
302
- code: "brand-snapshot-stale",
303
- message: `${relativeSnapshot(input.root, local.ok ? local.path : input.root)} was older than the live brand ${live.brand.slug}; the cached copy is refreshed.`
304
- }] : []
305
- };
306
- }
307
- if (!local.ok) return {
308
- ok: false,
309
- message: local.message
310
- };
311
- if (local.snapshot === void 0) return {
17
+ } : {
312
18
  ok: false,
313
- 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.`
314
- };
315
- return {
316
- ok: true,
317
- brand: toResolved(local.snapshot),
318
- source: "snapshot",
319
- warnings: [{
320
- code: "brand-offline",
321
- message: `Using ${relativeSnapshot(input.root, local.path)} (pulled ${local.snapshot.pulledAt}) for ${specifier}: ${live.reason}.`
322
- }]
19
+ message: `The project imports ${specifier}, but no brandResolver is configured. Pass a brandResolver to the editor or project options.`
323
20
  };
324
21
  };
325
- /**
326
- * The environment a local build resolves the brand with: the process
327
- * environment plus the project's `.env` files, as the editor's dev server
328
- * reads them, so `SAMVA_API_KEY` in `.env` reaches `check` and `export` too.
329
- */
330
- const loadBrandEnv = async (root) => {
331
- const vite = await import("vite").catch(() => void 0);
332
- if (vite === void 0) throw new Error("`vite` is required to resolve samva:brand locally.");
333
- return vite.loadEnv("development", root, "");
334
- };
335
- /** The resolution as build findings: warnings stay warnings, a failure is an error. */
22
+ /** Host warnings stay warnings; unavailable brands are errors. */
336
23
  const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map((warning) => ({
337
24
  code: warning.code,
338
25
  severity: "warning",
@@ -344,31 +31,6 @@ const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map
344
31
  message: resolution.message,
345
32
  origins: []
346
33
  }];
347
- /**
348
- * One resolver per watch session. A resolution is reused for a short while per
349
- * specifier, so saving a file does not ask the API each time while a brand
350
- * edited elsewhere still arrives within the window.
351
- */
352
- const createBrandResolver = (options) => {
353
- const cache = /* @__PURE__ */ new Map();
354
- return { resolve: async (files, themePath) => {
355
- const key = projectBrandSpecifier(files, themePath ?? "") ?? "";
356
- const cached = cache.get(key);
357
- if (cached !== void 0 && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.resolution;
358
- const resolution = await resolveProjectBrand({
359
- root: options.root,
360
- files,
361
- themePath,
362
- env: options.env,
363
- offline: options.offline
364
- });
365
- cache.set(key, {
366
- at: Date.now(),
367
- resolution
368
- });
369
- return resolution;
370
- } };
371
- };
372
34
  //#endregion
373
35
  //#region src/compile.ts
374
36
  const CHANNELS = [
@@ -392,6 +54,7 @@ const compileEntry = async (input, entryPath) => {
392
54
  files: input.files,
393
55
  entry: entryPath,
394
56
  assetBase: input.assetBase,
57
+ brandPlugin: input.brandPlugin,
395
58
  ...input.brand === void 0 ? {} : { brand: input.brand },
396
59
  tailwind: input.theme === void 0 ? {} : {
397
60
  css: input.theme.css,
@@ -432,7 +95,8 @@ const compileEntry = async (input, entryPath) => {
432
95
  fixtures: compiled.fixtures,
433
96
  assets: compiled.assets,
434
97
  diagnostics: compiled.diagnostics,
435
- channels: channelsOf(ir)
98
+ channels: channelsOf(ir),
99
+ brandPlugin: input.brandPlugin
436
100
  }
437
101
  };
438
102
  };
@@ -493,7 +157,11 @@ const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.fixtur
493
157
  return {
494
158
  templateId: entry.id,
495
159
  fixture,
496
- rendered: renderIr(entry.ir, input, options),
160
+ rendered: renderIr(entry.ir, input, {
161
+ positions: true,
162
+ ...options,
163
+ ...entry.brandPlugin === void 0 ? {} : { brandPlugin: entry.brandPlugin }
164
+ }),
497
165
  diagnostics: []
498
166
  };
499
167
  } catch (cause) {
@@ -507,12 +175,25 @@ const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.fixtur
507
175
  });
508
176
  /** Client-matrix findings across the fixtures that rendered. */
509
177
  const emailCompatibility = (entry, rendered) => {
510
- const renderable = Object.fromEntries(rendered.filter((result) => result.rendered !== void 0).map((result) => [result.fixture, entry.fixtures[result.fixture]]));
511
- try {
512
- return checkTemplateCompatibility(entry.ir, renderable);
513
- } catch (cause) {
514
- return [finding("compatibility-failed", "error", cause instanceof Error ? cause.message : String(cause))];
515
- }
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
+ });
516
197
  };
517
198
  /** Render the SMS or WhatsApp channel of one fixture. */
518
199
  const renderChannelFixture = (entry, channel, fixture, options = {}) => {
@@ -540,6 +221,78 @@ const renderChannelFixture = (entry, channel, fixture, options = {}) => {
540
221
  };
541
222
  const hasErrors = (diagnostics) => diagnostics.some((item) => item.severity === "error");
542
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
543
296
  //#region src/local-assets.ts
544
297
  const HTTPS = /^https:\/\//i;
545
298
  /** True when the renderer accepts `base` as it is: a delivered message can resolve it. */
@@ -572,11 +325,10 @@ const projectThemePath = (root, themePath) => relative(root, themePath).split(se
572
325
  /** Resolve the root a set of options names. */
573
326
  const projectRoot = (options) => resolve(options.root ?? process.cwd());
574
327
  /**
575
- * Read, resolve the brand of, and compile every template in a project. `brands`
576
- * 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`
577
329
  * limits compilation to the named project-relative entries.
578
330
  */
579
- const loadProject = async (options = {}, brands, only) => {
331
+ const loadProject = async (options = {}, only) => {
580
332
  const root = projectRoot(options);
581
333
  const dirs = await templateDirectories(root, options.dir);
582
334
  const [discovered, files] = await Promise.all([discoverEntries(root, dirs), readProjectFiles(root)]);
@@ -584,15 +336,12 @@ const loadProject = async (options = {}, brands, only) => {
584
336
  const themeFile = resolveThemePath(root, options.theme);
585
337
  const themeCss = await readProjectThemeCss(themeFile);
586
338
  const themePath = themeFile === void 0 || themeCss === void 0 ? void 0 : projectThemePath(root, themeFile);
587
- const brand = await (brands ?? createBrandResolver({
588
- root,
589
- env: await loadBrandEnv(root),
590
- offline: options.offline === true
591
- })).resolve(files, themePath);
339
+ const brand = await resolveBrand(files, themePath, options.brandResolver, options.brandPlugin);
592
340
  const catalog = await compileProject({
593
341
  files,
594
342
  entries,
595
343
  assetBase: options.assetBase ?? "",
344
+ brandPlugin: options.brandPlugin,
596
345
  ...themeCss === void 0 || themePath === void 0 ? {} : { theme: {
597
346
  css: themeCss,
598
347
  path: themePath
@@ -622,7 +371,6 @@ const IGNORED_WATCH = [
622
371
  const createProjectSession = (options = {}) => {
623
372
  const root = projectRoot(options);
624
373
  let latest;
625
- let brands;
626
374
  const listeners = /* @__PURE__ */ new Set();
627
375
  const observer = watch(root, { recursive: true }, (_event, name) => {
628
376
  const changed = name === null ? "" : toPosix(name.toString());
@@ -632,12 +380,7 @@ const createProjectSession = (options = {}) => {
632
380
  });
633
381
  return {
634
382
  load: async () => {
635
- brands ??= createBrandResolver({
636
- root,
637
- env: await loadBrandEnv(root),
638
- offline: options.offline === true
639
- });
640
- latest = await loadProject(options, brands);
383
+ latest = await loadProject(options);
641
384
  return latest;
642
385
  },
643
386
  current: () => latest,
@@ -767,6 +510,6 @@ const exportProject = async (input) => {
767
510
  return receipt;
768
511
  };
769
512
  //#endregion
770
- 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 };
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 };
771
514
 
772
- //# sourceMappingURL=project-Ut6wra0r.js.map
515
+ //# sourceMappingURL=project-DwbOoPDk.js.map