@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.
@@ -1,340 +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 { 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
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
- /** 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 {
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 ${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
- }]
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, options),
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
- 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
- }
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. `brands`
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 = {}, brands, only) => {
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 (brands ?? createBrandResolver({
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
- brands ??= createBrandResolver({
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 { 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 };
773
514
 
774
- //# sourceMappingURL=project-Cp8qb3s0.js.map
515
+ //# sourceMappingURL=project-DwbOoPDk.js.map