@samva/vite 0.7.0 → 0.8.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 +6 -1
- package/README.md +29 -3
- package/dist/build.js +31 -8
- package/dist/{theme-CBF6Czjm.js → discovery-D2eR-rzi.js} +4 -19
- package/dist/editor/assets/index-6Qlbkdhz.js +36 -0
- package/dist/editor/assets/index-Ed_V5wEW.css +2 -0
- package/dist/editor/index.html +2 -2
- package/dist/index.js +60 -21
- package/dist/project-RON2Ei7d.js +603 -0
- package/dist/project.d.ts +62 -3
- package/dist/project.js +2 -294
- package/package.json +2 -2
- package/dist/editor/assets/index-BeVA-Ur2.js +0 -32
- package/dist/editor/assets/index-Dz2CE64u.css +0 -2
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { createEmailRenderer } from "./email.js";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { assetUrl, isAssetPath, parseBrandSpecifier, projectBrandSpecifier } from "@samva/markup/compiler";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { TEMPLATE_NAME_PATTERN, isTemplateDefinition, isTemplateDefinition as isTemplateDefinition$1 } from "@samva/markup/template";
|
|
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/brand.ts
|
|
13
|
+
/** Where `samva brands pull` writes snapshots, relative to the project root. */
|
|
14
|
+
const BRAND_SNAPSHOT_DIR = ".samva/brands";
|
|
15
|
+
const SNAPSHOT_VERSION = 1;
|
|
16
|
+
const DEFAULT_API_URL = "https://api.samva.dev";
|
|
17
|
+
const LIVE_TIMEOUT_MS = 15e3;
|
|
18
|
+
/** How long a resolution is reused, so a watch loop does not ask the API on every save. */
|
|
19
|
+
const RESOLUTION_TTL_MS = 3e4;
|
|
20
|
+
/** Accept only the fields a build needs, from an API body or a snapshot file. */
|
|
21
|
+
const asBrand = (value) => {
|
|
22
|
+
if (!isObject(value)) return void 0;
|
|
23
|
+
const { slug, name, isDefault, css, assets, footer, digest } = value;
|
|
24
|
+
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;
|
|
25
|
+
return {
|
|
26
|
+
slug,
|
|
27
|
+
name,
|
|
28
|
+
isDefault,
|
|
29
|
+
css,
|
|
30
|
+
digest,
|
|
31
|
+
...assets === void 0 ? {} : { assets },
|
|
32
|
+
...footer === void 0 ? {} : { footer }
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
const liveFromBody = (body, when) => {
|
|
36
|
+
const brand = asBrand(body);
|
|
37
|
+
return brand === void 0 ? {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `${when} answered with something that is not a brand`
|
|
40
|
+
} : {
|
|
41
|
+
ok: true,
|
|
42
|
+
brand: {
|
|
43
|
+
...brand,
|
|
44
|
+
version: SNAPSHOT_VERSION,
|
|
45
|
+
pulledAt: ""
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const fetchJson = async (url, apiKey) => {
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
headers: {
|
|
52
|
+
"x-api-key": apiKey,
|
|
53
|
+
accept: "application/json"
|
|
54
|
+
},
|
|
55
|
+
signal: AbortSignal.timeout(LIVE_TIMEOUT_MS)
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
58
|
+
return response.json();
|
|
59
|
+
};
|
|
60
|
+
const liveWithApiKey = async (slug, env, apiKey) => {
|
|
61
|
+
const base = (env["SAMVA_API_URL"] || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
62
|
+
try {
|
|
63
|
+
if (slug !== void 0) return liveFromBody(await fetchJson(`${base}/v1/brands/${slug}`, apiKey), "The Samva API");
|
|
64
|
+
const body = await fetchJson(`${base}/v1/brands`, apiKey);
|
|
65
|
+
const found = (isObject(body) && Array.isArray(body["items"]) ? body["items"] : []).find((item) => isObject(item) && item["isDefault"] === true);
|
|
66
|
+
return found === void 0 ? {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "the organization has no default brand"
|
|
69
|
+
} : liveFromBody(found, "The Samva API");
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `the Samva API did not answer (${cause instanceof Error ? cause.message : String(cause)})`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/** The CLI's error envelope message, or its raw stderr. */
|
|
78
|
+
const cliFailure = (stderr) => {
|
|
79
|
+
const parsed = (() => {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(stderr);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
return isObject(parsed) && typeof parsed["message"] === "string" ? parsed["message"] : stderr.trim() || "it exited without a message";
|
|
87
|
+
};
|
|
88
|
+
const liveWithCli = (slug, env) => new Promise((resolve) => {
|
|
89
|
+
execFile("samva", [
|
|
90
|
+
"brands",
|
|
91
|
+
"get",
|
|
92
|
+
...slug === void 0 ? [] : [slug],
|
|
93
|
+
"--json"
|
|
94
|
+
], {
|
|
95
|
+
env: { ...env },
|
|
96
|
+
timeout: LIVE_TIMEOUT_MS,
|
|
97
|
+
maxBuffer: 1048576
|
|
98
|
+
}, (error, stdout, stderr) => {
|
|
99
|
+
if (error !== null) {
|
|
100
|
+
const code = error.code;
|
|
101
|
+
resolve({
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: code === "ENOENT" ? "there is no SAMVA_API_KEY and no `samva` CLI on PATH" : `\`samva brands get\` failed: ${cliFailure(stderr)}`
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const envelope = (() => {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(stdout);
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
resolve(liveFromBody(isObject(envelope) ? envelope["data"] : void 0, "`samva brands get`"));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
const fetchLiveBrand = (slug, env) => {
|
|
118
|
+
const apiKey = env["SAMVA_API_KEY"];
|
|
119
|
+
return apiKey !== void 0 && apiKey.length > 0 ? liveWithApiKey(slug, env, apiKey) : liveWithCli(slug, env);
|
|
120
|
+
};
|
|
121
|
+
const readSnapshotFile = async (path) => {
|
|
122
|
+
const raw = await readFile(path, "utf8").catch((cause) => {
|
|
123
|
+
if (cause.code === "ENOENT") return void 0;
|
|
124
|
+
throw cause;
|
|
125
|
+
});
|
|
126
|
+
if (raw === void 0) return {
|
|
127
|
+
ok: true,
|
|
128
|
+
snapshot: void 0,
|
|
129
|
+
path
|
|
130
|
+
};
|
|
131
|
+
const parsed = (() => {
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(raw);
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
const brand = asBrand(parsed);
|
|
139
|
+
if (brand === void 0 || !isObject(parsed) || parsed["version"] !== SNAPSHOT_VERSION || typeof parsed["pulledAt"] !== "string") return {
|
|
140
|
+
ok: false,
|
|
141
|
+
message: `${path} is not a brand snapshot this version of @samva/vite reads. Run \`samva brands pull\` to rewrite it.`
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
snapshot: {
|
|
146
|
+
...brand,
|
|
147
|
+
version: SNAPSHOT_VERSION,
|
|
148
|
+
pulledAt: parsed["pulledAt"]
|
|
149
|
+
},
|
|
150
|
+
path
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The snapshot for a named brand, or for the default brand the most recently
|
|
155
|
+
* pulled snapshot that was the default when it was pulled.
|
|
156
|
+
*/
|
|
157
|
+
const readSnapshot = async (root, slug) => {
|
|
158
|
+
const directory = join(root, BRAND_SNAPSHOT_DIR);
|
|
159
|
+
if (slug !== void 0) return readSnapshotFile(join(directory, `${slug}.json`));
|
|
160
|
+
const names = await readdir(directory).catch((cause) => {
|
|
161
|
+
if (cause.code === "ENOENT") return [];
|
|
162
|
+
throw cause;
|
|
163
|
+
});
|
|
164
|
+
const reads = await Promise.all(names.filter((name) => name.endsWith(".json")).sort().map((name) => readSnapshotFile(join(directory, name))));
|
|
165
|
+
const failed = reads.find((read) => !read.ok);
|
|
166
|
+
if (failed !== void 0) return failed;
|
|
167
|
+
const defaults = reads.flatMap((read) => read.ok && read.snapshot?.isDefault === true ? [{
|
|
168
|
+
...read,
|
|
169
|
+
snapshot: read.snapshot
|
|
170
|
+
}] : []);
|
|
171
|
+
defaults.sort((a, b) => b.snapshot.pulledAt.localeCompare(a.snapshot.pulledAt));
|
|
172
|
+
return defaults[0] ?? {
|
|
173
|
+
ok: true,
|
|
174
|
+
snapshot: void 0,
|
|
175
|
+
path: directory
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
const toResolved = (brand) => ({
|
|
179
|
+
slug: brand.slug,
|
|
180
|
+
css: brand.css,
|
|
181
|
+
digest: brand.digest,
|
|
182
|
+
...brand.assets === void 0 ? {} : { assets: brand.assets },
|
|
183
|
+
...brand.footer === void 0 ? {} : { footer: brand.footer }
|
|
184
|
+
});
|
|
185
|
+
const relativeSnapshot = (root, path) => relative(root, path).split(sep).join("/");
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the brand a project's `samva:brand` import names: live when the
|
|
188
|
+
* environment's credentials reach the API, else the pulled snapshot with a
|
|
189
|
+
* warning, else a failure naming both fixes.
|
|
190
|
+
*/
|
|
191
|
+
const resolveProjectBrand = async (input) => {
|
|
192
|
+
const specifier = projectBrandSpecifier(input.files, input.themePath ?? "");
|
|
193
|
+
if (specifier === void 0) return {
|
|
194
|
+
ok: true,
|
|
195
|
+
brand: void 0,
|
|
196
|
+
source: "none",
|
|
197
|
+
warnings: []
|
|
198
|
+
};
|
|
199
|
+
const slug = parseBrandSpecifier(specifier)?.slug;
|
|
200
|
+
const pullCommand = `samva brands pull${slug === void 0 ? "" : ` ${slug}`}`;
|
|
201
|
+
const [live, local] = await Promise.all([fetchLiveBrand(slug, input.env), readSnapshot(input.root, slug)]);
|
|
202
|
+
if (live.ok) {
|
|
203
|
+
const stale = local.ok && local.snapshot !== void 0 && local.snapshot.digest !== live.brand.digest;
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
brand: toResolved(live.brand),
|
|
207
|
+
source: "live",
|
|
208
|
+
warnings: stale ? [{
|
|
209
|
+
code: "brand-snapshot-stale",
|
|
210
|
+
message: `${relativeSnapshot(input.root, local.path)} is older than the live brand ${live.brand.slug}. Run \`${pullCommand}\` so offline builds match it.`
|
|
211
|
+
}] : []
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!local.ok) return {
|
|
215
|
+
ok: false,
|
|
216
|
+
message: local.message
|
|
217
|
+
};
|
|
218
|
+
if (local.snapshot === void 0) return {
|
|
219
|
+
ok: false,
|
|
220
|
+
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.`
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
brand: toResolved(local.snapshot),
|
|
225
|
+
source: "snapshot",
|
|
226
|
+
warnings: [{
|
|
227
|
+
code: "brand-offline",
|
|
228
|
+
message: `Using ${relativeSnapshot(input.root, local.path)} (pulled ${local.snapshot.pulledAt}) for ${specifier}: ${live.reason}.`
|
|
229
|
+
}]
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The environment a local build resolves the brand with: the process
|
|
234
|
+
* environment plus the project's `.env` files, as the editor's dev server
|
|
235
|
+
* reads them, so `SAMVA_API_KEY` in `.env` reaches `check` and `export` too.
|
|
236
|
+
*/
|
|
237
|
+
const loadBrandEnv = async (root) => {
|
|
238
|
+
const vite = await import("vite").catch(() => void 0);
|
|
239
|
+
if (vite === void 0) throw new Error("`vite` is required to resolve samva:brand locally.");
|
|
240
|
+
return vite.loadEnv("development", root, "");
|
|
241
|
+
};
|
|
242
|
+
/** The resolution as build findings: warnings stay warnings, a failure is an error. */
|
|
243
|
+
const brandDiagnostics = (resolution) => resolution.ok ? resolution.warnings.map((warning) => ({
|
|
244
|
+
code: warning.code,
|
|
245
|
+
severity: "warning",
|
|
246
|
+
message: warning.message,
|
|
247
|
+
origins: []
|
|
248
|
+
})) : [{
|
|
249
|
+
code: "brand-unavailable",
|
|
250
|
+
severity: "error",
|
|
251
|
+
message: resolution.message,
|
|
252
|
+
origins: []
|
|
253
|
+
}];
|
|
254
|
+
/**
|
|
255
|
+
* One resolver per watch session. A resolution is reused for a short while per
|
|
256
|
+
* specifier, so saving a file does not ask the API each time while a brand
|
|
257
|
+
* edited elsewhere still arrives within the window.
|
|
258
|
+
*/
|
|
259
|
+
const createBrandResolver = (options) => {
|
|
260
|
+
const cache = /* @__PURE__ */ new Map();
|
|
261
|
+
return { resolve: async (files, themePath) => {
|
|
262
|
+
const key = projectBrandSpecifier(files, themePath ?? "") ?? "";
|
|
263
|
+
const cached = cache.get(key);
|
|
264
|
+
if (cached !== void 0 && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.resolution;
|
|
265
|
+
const resolution = await resolveProjectBrand({
|
|
266
|
+
root: options.root,
|
|
267
|
+
files,
|
|
268
|
+
themePath,
|
|
269
|
+
env: options.env
|
|
270
|
+
});
|
|
271
|
+
cache.set(key, {
|
|
272
|
+
at: Date.now(),
|
|
273
|
+
resolution
|
|
274
|
+
});
|
|
275
|
+
return resolution;
|
|
276
|
+
} };
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Resolves the `theme` plugin option to an absolute path, or `undefined` when
|
|
280
|
+
* theming is explicitly disabled (`theme: false`). A string is taken relative to
|
|
281
|
+
* `root` (absolute strings pass through); the default is `<root>/theme.css`.
|
|
282
|
+
*/
|
|
283
|
+
const resolveThemePath = (root, option) => {
|
|
284
|
+
if (option === false) return void 0;
|
|
285
|
+
const file = option ?? "theme.css";
|
|
286
|
+
return isAbsolute(file) ? file : resolve(root, file);
|
|
287
|
+
};
|
|
288
|
+
/** Read authored Tailwind CSS verbatim; the email compiler validates its syntax. */
|
|
289
|
+
const readProjectThemeCss = async (path) => path === void 0 ? void 0 : readFile(path, "utf8").catch((cause) => {
|
|
290
|
+
if (cause.code === "ENOENT") return void 0;
|
|
291
|
+
throw cause;
|
|
292
|
+
});
|
|
293
|
+
/** The theme's project-relative path, from which its relative `@import`s resolve. */
|
|
294
|
+
const projectThemePath = (root, themePath) => relative(root, themePath).split(sep).join("/");
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/project.ts
|
|
297
|
+
const toPosix = (value) => sep === "/" ? value : value.split(sep).join("/");
|
|
298
|
+
const isCandidateEntry = (path) => path.endsWith(".tsx");
|
|
299
|
+
const diagnostic = (code, severity, message) => ({
|
|
300
|
+
code,
|
|
301
|
+
severity,
|
|
302
|
+
message,
|
|
303
|
+
origins: []
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Read a project directory into the source map the compiler consumes. Text
|
|
307
|
+
* modules arrive as strings and imported files as bytes, which is the same
|
|
308
|
+
* shape the hosted build hands `compileEmailProject`.
|
|
309
|
+
*/
|
|
310
|
+
const readEmailProject = async (dir) => {
|
|
311
|
+
const root = resolve(dir);
|
|
312
|
+
const files = {};
|
|
313
|
+
const walk = async (current) => {
|
|
314
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
315
|
+
await Promise.all(entries.filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules").map(async (entry) => {
|
|
316
|
+
const full = join(current, entry.name);
|
|
317
|
+
if (entry.isDirectory()) return walk(full);
|
|
318
|
+
if (!entry.isFile()) return;
|
|
319
|
+
const path = toPosix(relative(root, full));
|
|
320
|
+
files[path] = isAssetPath(path) ? new Uint8Array(await readFile(full)) : await readFile(full, "utf8");
|
|
321
|
+
}));
|
|
322
|
+
};
|
|
323
|
+
await walk(root);
|
|
324
|
+
return files;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* Compile and evaluate every candidate entry, keeping the ones that declare a
|
|
328
|
+
* template. Compilation is per entry, exactly as a publication is per template,
|
|
329
|
+
* so shared components and CSS reach each template the same way they reach the
|
|
330
|
+
* hosted build.
|
|
331
|
+
*/
|
|
332
|
+
const loadEmailCatalog = async (input) => {
|
|
333
|
+
const candidates = (input.entryPaths ?? Object.keys(input.files).filter(isCandidateEntry)).slice();
|
|
334
|
+
candidates.sort();
|
|
335
|
+
const templates = [];
|
|
336
|
+
const helpers = [];
|
|
337
|
+
const diagnostics = [];
|
|
338
|
+
const assets = /* @__PURE__ */ new Map();
|
|
339
|
+
const declaredBy = /* @__PURE__ */ new Map();
|
|
340
|
+
const loaded = await Promise.all(candidates.map((entryPath) => loadEntry(input.renderer, input.files, entryPath, input.stableNamespaces === true ? entryPath : void 0)));
|
|
341
|
+
for (const [index, entryPath] of candidates.entries()) {
|
|
342
|
+
const result = loaded[index];
|
|
343
|
+
if (!result.ok) {
|
|
344
|
+
diagnostics.push(diagnostic("entry-load-error", "error", `${entryPath}: ${result.message}`));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const template = result.entry.template;
|
|
348
|
+
if (template === void 0) {
|
|
349
|
+
helpers.push(entryPath);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!isTemplateDefinition(template)) {
|
|
353
|
+
helpers.push(entryPath);
|
|
354
|
+
diagnostics.push(diagnostic("not-a-template", "warning", `${entryPath} default-exports something that is not a template definition. Wrap it with defineEmail, or drop the default export so it reads as a helper module.`));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const previous = declaredBy.get(template.id);
|
|
358
|
+
if (previous !== void 0) {
|
|
359
|
+
diagnostics.push(diagnostic("duplicate-template-id", "error", `Template id ${JSON.stringify(template.id)} is declared by both ${previous} and ${entryPath}. An id is a publication's identity, so it must name exactly one template.`));
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
declaredBy.set(template.id, entryPath);
|
|
363
|
+
for (const asset of result.entry.compiled.assets) assets.set(asset.digest, asset);
|
|
364
|
+
diagnostics.push(...result.entry.compiled.diagnostics);
|
|
365
|
+
templates.push({
|
|
366
|
+
id: template.id,
|
|
367
|
+
entryPath,
|
|
368
|
+
definition: template,
|
|
369
|
+
loaded: result.entry
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
templates: templates.sort((left, right) => left.id < right.id ? -1 : 1),
|
|
374
|
+
helpers,
|
|
375
|
+
diagnostics,
|
|
376
|
+
assets: [...assets.values()].sort((left, right) => left.fileName < right.fileName ? -1 : 1)
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
const loadEntry = async (renderer, files, entryPath, namespace) => renderer.load(files, entryPath, namespace).then((entry) => ({
|
|
380
|
+
ok: true,
|
|
381
|
+
entry
|
|
382
|
+
}), (cause) => ({
|
|
383
|
+
ok: false,
|
|
384
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
385
|
+
}));
|
|
386
|
+
/**
|
|
387
|
+
* Validate and render every fixture a template declares. A fixture that its own
|
|
388
|
+
* schema rejects is a source defect the author sees here rather than at publish.
|
|
389
|
+
*/
|
|
390
|
+
const renderEmailFixtures = (entry, options = {}) => Object.entries(entry.definition.fixtures).map(([fixture, data]) => {
|
|
391
|
+
if (!entry.definition.schema.validate(data)) return {
|
|
392
|
+
templateId: entry.id,
|
|
393
|
+
fixture,
|
|
394
|
+
rendered: void 0,
|
|
395
|
+
diagnostics: [diagnostic("invalid-fixture", "error", `Fixture ${JSON.stringify(fixture)} does not satisfy ${entry.id}'s own input schema: ${JSON.stringify(entry.definition.schema.validate.errors)}`)]
|
|
396
|
+
};
|
|
397
|
+
try {
|
|
398
|
+
const rendered = entry.loaded.renderEmail(entry.definition, data, options);
|
|
399
|
+
return {
|
|
400
|
+
templateId: entry.id,
|
|
401
|
+
fixture,
|
|
402
|
+
rendered,
|
|
403
|
+
diagnostics: rendered.diagnostics
|
|
404
|
+
};
|
|
405
|
+
} catch (cause) {
|
|
406
|
+
return {
|
|
407
|
+
templateId: entry.id,
|
|
408
|
+
fixture,
|
|
409
|
+
rendered: void 0,
|
|
410
|
+
diagnostics: findingsOf(cause, entry.id, fixture)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
const findingsOf = (cause, templateId, fixture) => {
|
|
415
|
+
const carried = cause.diagnostics;
|
|
416
|
+
if (Array.isArray(carried) && carried.length > 0) return carried;
|
|
417
|
+
return [diagnostic("render-failed", "error", `${templateId} could not render fixture ${JSON.stringify(fixture)}: ${cause instanceof Error ? cause.message : String(cause)}`)];
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
420
|
+
* The authoring loop: one evaluator, one catalog, reloaded on change. Content
|
|
421
|
+
* semantics come from `compileEmailProject` and `renderEmail`, the same two
|
|
422
|
+
* calls the hosted preview makes, so what an author iterates on locally is what
|
|
423
|
+
* a publication renders.
|
|
424
|
+
*/
|
|
425
|
+
const createEmailProjectSession = async (dir, options) => {
|
|
426
|
+
const root = resolve(dir);
|
|
427
|
+
const renderer = await createEmailRenderer(options);
|
|
428
|
+
let catalog = {
|
|
429
|
+
templates: [],
|
|
430
|
+
helpers: [],
|
|
431
|
+
diagnostics: [],
|
|
432
|
+
assets: []
|
|
433
|
+
};
|
|
434
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
435
|
+
const observer = watch(root, { recursive: true }, (_event, name) => {
|
|
436
|
+
const changed = name === null ? root : toPosix(name.toString());
|
|
437
|
+
for (const listener of listeners) listener(changed);
|
|
438
|
+
});
|
|
439
|
+
const reload = async () => {
|
|
440
|
+
renderer.invalidate();
|
|
441
|
+
catalog = await loadEmailCatalog({
|
|
442
|
+
renderer,
|
|
443
|
+
files: await readEmailProject(root),
|
|
444
|
+
stableNamespaces: true
|
|
445
|
+
});
|
|
446
|
+
return catalog;
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
reload,
|
|
450
|
+
current: () => catalog,
|
|
451
|
+
subscribe: (listener) => {
|
|
452
|
+
listeners.add(listener);
|
|
453
|
+
return () => void listeners.delete(listener);
|
|
454
|
+
},
|
|
455
|
+
close: async () => {
|
|
456
|
+
observer.close();
|
|
457
|
+
listeners.clear();
|
|
458
|
+
await renderer.close();
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* Where an export serves its assets from when the caller names no origin.
|
|
464
|
+
*
|
|
465
|
+
* An export writes one page at `templates/<id>/<fixture>.html` and every asset
|
|
466
|
+
* once at `assets/<content address>`, so the base a page resolves against is
|
|
467
|
+
* the path from its own directory back to that one. A template id is one path
|
|
468
|
+
* segment, so every page sits at the same depth and one base serves them all.
|
|
469
|
+
*/
|
|
470
|
+
const DEFAULT_EXPORT_ASSET_BASE = "../../assets";
|
|
471
|
+
/**
|
|
472
|
+
* The path an export writes, or `undefined` when a name would leave the root.
|
|
473
|
+
*
|
|
474
|
+
* `defineEmail` already refuses an id or a fixture key that is not one path
|
|
475
|
+
* segment, so this is the guard rather than the contract: the same pattern,
|
|
476
|
+
* plus a containment check, so a name that reaches `join` can never address a
|
|
477
|
+
* file the export does not own.
|
|
478
|
+
*/
|
|
479
|
+
const exportPath = (out, ...segments) => {
|
|
480
|
+
if (!segments.every((segment) => TEMPLATE_NAME_PATTERN.test(segment))) return void 0;
|
|
481
|
+
const path = join(out, ...segments);
|
|
482
|
+
const inside = relative(out, path);
|
|
483
|
+
return inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside) ? void 0 : path;
|
|
484
|
+
};
|
|
485
|
+
const unsafeExportName = (kind, value) => diagnostic("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 "_".`);
|
|
486
|
+
/**
|
|
487
|
+
* Render every template's fixtures to HTML and text beside the bytes of every
|
|
488
|
+
* asset they reference, with the manifest that maps each address to its file.
|
|
489
|
+
*
|
|
490
|
+
* The export renders in the compiler's export mode, so a relative `assetBase`
|
|
491
|
+
* is accepted and the written page resolves its images from the directory it
|
|
492
|
+
* sits in. Delivery keeps refusing relative references — an inbox has no
|
|
493
|
+
* directory to resolve one against — so an export destined for sending names
|
|
494
|
+
* the https origin it will be served from instead.
|
|
495
|
+
*/
|
|
496
|
+
const exportEmailProject = async (input) => {
|
|
497
|
+
const out = resolve(input.out);
|
|
498
|
+
const root = resolve(input.root ?? input.dir);
|
|
499
|
+
const files = await readEmailProject(root);
|
|
500
|
+
const templatesPrefix = toPosix(relative(root, resolve(input.dir)));
|
|
501
|
+
const entryPaths = templatesPrefix === "" ? void 0 : Object.keys(files).filter((path) => isCandidateEntry(path) && path.startsWith(`${templatesPrefix}/`));
|
|
502
|
+
const base = input.assetBase ?? "../../assets";
|
|
503
|
+
const themePath = resolveThemePath(root, input.theme);
|
|
504
|
+
const themeCss = await readProjectThemeCss(themePath);
|
|
505
|
+
const themeFile = themeCss === void 0 || themePath === void 0 ? void 0 : projectThemePath(root, themePath);
|
|
506
|
+
const brandResolution = await resolveProjectBrand({
|
|
507
|
+
root,
|
|
508
|
+
files: themeFile === void 0 || themeCss === void 0 ? files : {
|
|
509
|
+
...files,
|
|
510
|
+
[themeFile]: themeCss
|
|
511
|
+
},
|
|
512
|
+
themePath: themeFile,
|
|
513
|
+
env: await loadBrandEnv(root)
|
|
514
|
+
});
|
|
515
|
+
const renderer = await createEmailRenderer({
|
|
516
|
+
assetBase: base,
|
|
517
|
+
...themeCss === void 0 || themeFile === void 0 ? {} : { tailwind: {
|
|
518
|
+
css: themeCss,
|
|
519
|
+
cssPath: themeFile
|
|
520
|
+
} },
|
|
521
|
+
...brandResolution.ok && brandResolution.brand !== void 0 ? { brand: brandResolution.brand } : {}
|
|
522
|
+
});
|
|
523
|
+
try {
|
|
524
|
+
const catalog = await loadEmailCatalog({
|
|
525
|
+
renderer,
|
|
526
|
+
files,
|
|
527
|
+
entryPaths
|
|
528
|
+
});
|
|
529
|
+
const diagnostics = [...brandDiagnostics(brandResolution), ...catalog.diagnostics];
|
|
530
|
+
const written = await Promise.all(catalog.templates.map(async (entry) => {
|
|
531
|
+
const directory = exportPath(out, "templates", entry.id);
|
|
532
|
+
if (directory === void 0) return {
|
|
533
|
+
entry: void 0,
|
|
534
|
+
diagnostics: [unsafeExportName("Template id", entry.id)]
|
|
535
|
+
};
|
|
536
|
+
await mkdir(directory, { recursive: true });
|
|
537
|
+
const rendered = renderEmailFixtures(entry, { relativeUrls: true });
|
|
538
|
+
const pages = await Promise.all(rendered.map(async (result) => {
|
|
539
|
+
if (result.rendered === void 0) return {
|
|
540
|
+
fixture: void 0,
|
|
541
|
+
diagnostics: []
|
|
542
|
+
};
|
|
543
|
+
const htmlPath = exportPath(out, "templates", entry.id, `${result.fixture}.html`);
|
|
544
|
+
const textPath = exportPath(out, "templates", entry.id, `${result.fixture}.txt`);
|
|
545
|
+
if (htmlPath === void 0 || textPath === void 0) return {
|
|
546
|
+
fixture: void 0,
|
|
547
|
+
diagnostics: [unsafeExportName("Fixture name", result.fixture)]
|
|
548
|
+
};
|
|
549
|
+
await Promise.all([writeFile(htmlPath, result.rendered.html, "utf8"), writeFile(textPath, result.rendered.text, "utf8")]);
|
|
550
|
+
return {
|
|
551
|
+
fixture: {
|
|
552
|
+
fixture: result.fixture,
|
|
553
|
+
html: toPosix(relative(out, htmlPath)),
|
|
554
|
+
text: toPosix(relative(out, textPath))
|
|
555
|
+
},
|
|
556
|
+
diagnostics: []
|
|
557
|
+
};
|
|
558
|
+
}));
|
|
559
|
+
return {
|
|
560
|
+
entry: {
|
|
561
|
+
id: entry.id,
|
|
562
|
+
entryPath: entry.entryPath,
|
|
563
|
+
fixtures: pages.map((result) => result.fixture).filter((fixture) => fixture !== void 0)
|
|
564
|
+
},
|
|
565
|
+
diagnostics: [...rendered.flatMap((result) => result.diagnostics), ...pages.flatMap((result) => result.diagnostics)]
|
|
566
|
+
};
|
|
567
|
+
}));
|
|
568
|
+
const templates = written.map((result) => result.entry).filter((entry) => entry !== void 0);
|
|
569
|
+
diagnostics.push(...written.flatMap((result) => result.diagnostics));
|
|
570
|
+
const assetDirectory = join(out, "assets");
|
|
571
|
+
if (catalog.assets.length > 0) await mkdir(assetDirectory, { recursive: true });
|
|
572
|
+
await Promise.all(catalog.assets.map((asset) => {
|
|
573
|
+
const bytes = files[asset.path];
|
|
574
|
+
return bytes === void 0 || typeof bytes === "string" ? Promise.resolve() : writeFile(join(assetDirectory, asset.fileName), bytes);
|
|
575
|
+
}));
|
|
576
|
+
const receipt = {
|
|
577
|
+
templates,
|
|
578
|
+
assets: {
|
|
579
|
+
base,
|
|
580
|
+
assets: catalog.assets
|
|
581
|
+
},
|
|
582
|
+
diagnostics
|
|
583
|
+
};
|
|
584
|
+
await mkdir(out, { recursive: true });
|
|
585
|
+
await writeFile(join(out, "catalog.json"), `${JSON.stringify({
|
|
586
|
+
...receipt,
|
|
587
|
+
assets: {
|
|
588
|
+
base,
|
|
589
|
+
assets: catalog.assets.map((asset) => ({
|
|
590
|
+
...asset,
|
|
591
|
+
url: assetUrl(asset, base)
|
|
592
|
+
}))
|
|
593
|
+
}
|
|
594
|
+
}, null, 2)}\n`, "utf8");
|
|
595
|
+
return receipt;
|
|
596
|
+
} finally {
|
|
597
|
+
await renderer.close();
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
//#endregion
|
|
601
|
+
export { loadEmailCatalog as a, projectThemePath as c, BRAND_SNAPSHOT_DIR as d, brandDiagnostics as f, isObject as g, resolveProjectBrand as h, isTemplateDefinition$1 as i, readProjectThemeCss as l, loadBrandEnv as m, createEmailProjectSession as n, readEmailProject as o, createBrandResolver as p, exportEmailProject as r, renderEmailFixtures as s, DEFAULT_EXPORT_ASSET_BASE as t, resolveThemePath as u };
|
|
602
|
+
|
|
603
|
+
//# sourceMappingURL=project-RON2Ei7d.js.map
|
package/dist/project.d.ts
CHANGED
|
@@ -1,8 +1,60 @@
|
|
|
1
1
|
import { EmailProjectSource, EmailRenderer, LoadedEmailEntry } from "./email.js";
|
|
2
|
-
import { AssetEntry, RenderedEmail, TransformOptions } from "@samva/markup/compiler";
|
|
2
|
+
import { AssetEntry, EmailBrand, RenderedEmail, TransformOptions } from "@samva/markup/compiler";
|
|
3
3
|
import { TemplateDefinition, isTemplateDefinition } from "@samva/markup/template";
|
|
4
4
|
import { EmailDiagnostic } from "@samva/markup/email/diagnostics";
|
|
5
5
|
import { SerializeEmailOptions } from "@samva/markup/email/html";
|
|
6
|
+
//#region src/brand.d.ts
|
|
7
|
+
/** Where `samva brands pull` writes snapshots, relative to the project root. */
|
|
8
|
+
declare const BRAND_SNAPSHOT_DIR = ".samva/brands";
|
|
9
|
+
declare const SNAPSHOT_VERSION = 1;
|
|
10
|
+
/** A brand as a build compiles it, with the digest the API knows its content by. */
|
|
11
|
+
interface ResolvedBrand extends EmailBrand {
|
|
12
|
+
readonly digest: string;
|
|
13
|
+
}
|
|
14
|
+
/** The file `samva brands pull` writes. */
|
|
15
|
+
interface BrandSnapshot extends ResolvedBrand {
|
|
16
|
+
readonly version: typeof SNAPSHOT_VERSION;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly isDefault: boolean;
|
|
19
|
+
/** ISO timestamp of the pull. */
|
|
20
|
+
readonly pulledAt: string;
|
|
21
|
+
}
|
|
22
|
+
type BrandWarningCode = "brand-snapshot-stale" | "brand-offline";
|
|
23
|
+
interface BrandWarning {
|
|
24
|
+
readonly code: BrandWarningCode;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
}
|
|
27
|
+
type BrandResolution = {
|
|
28
|
+
readonly ok: true;
|
|
29
|
+
/** `undefined` when the project imports no brand. */
|
|
30
|
+
readonly brand: ResolvedBrand | undefined;
|
|
31
|
+
readonly source: "live" | "snapshot" | "none";
|
|
32
|
+
readonly warnings: readonly BrandWarning[];
|
|
33
|
+
} | {
|
|
34
|
+
readonly ok: false;
|
|
35
|
+
readonly message: string;
|
|
36
|
+
};
|
|
37
|
+
interface ResolveProjectBrandInput {
|
|
38
|
+
readonly root: string;
|
|
39
|
+
readonly files: EmailProjectSource;
|
|
40
|
+
/** Project-relative theme path; `undefined` when theming is disabled. */
|
|
41
|
+
readonly themePath?: string | undefined;
|
|
42
|
+
/** Environment holding `SAMVA_API_KEY`, `SAMVA_API_URL` and `PATH`; see `loadBrandEnv`. */
|
|
43
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the brand a project's `samva:brand` import names: live when the
|
|
47
|
+
* environment's credentials reach the API, else the pulled snapshot with a
|
|
48
|
+
* warning, else a failure naming both fixes.
|
|
49
|
+
*/
|
|
50
|
+
declare const resolveProjectBrand: (input: ResolveProjectBrandInput) => Promise<BrandResolution>;
|
|
51
|
+
/**
|
|
52
|
+
* The environment a local build resolves the brand with: the process
|
|
53
|
+
* environment plus the project's `.env` files, as the editor's dev server
|
|
54
|
+
* reads them, so `SAMVA_API_KEY` in `.env` reaches `check` and `export` too.
|
|
55
|
+
*/
|
|
56
|
+
declare const loadBrandEnv: (root: string) => Promise<Readonly<Record<string, string | undefined>>>;
|
|
57
|
+
//#endregion
|
|
6
58
|
//#region src/project.d.ts
|
|
7
59
|
/**
|
|
8
60
|
* Read a project directory into the source map the compiler consumes. Text
|
|
@@ -106,11 +158,18 @@ declare const DEFAULT_EXPORT_ASSET_BASE = "../../assets";
|
|
|
106
158
|
* the https origin it will be served from instead.
|
|
107
159
|
*/
|
|
108
160
|
declare const exportEmailProject: (input: {
|
|
161
|
+
/** Directory holding the templates; every `.tsx` under it is a candidate entry. */
|
|
109
162
|
readonly dir: string;
|
|
163
|
+
/**
|
|
164
|
+
* Project root holding shared imports, `theme.css` and `.samva/brands`; defaults to `dir`.
|
|
165
|
+
* `samva templates export` passes the working directory, as `check` and `build` do.
|
|
166
|
+
*/
|
|
167
|
+
readonly root?: string | undefined;
|
|
110
168
|
readonly out: string;
|
|
111
169
|
readonly assetBase?: string | undefined;
|
|
112
|
-
|
|
170
|
+
/** Project theme file relative to `root`, as in `buildTemplates()`; `false` disables it. */
|
|
171
|
+
readonly theme?: string | false | undefined;
|
|
113
172
|
}) => Promise<EmailExportReceipt>;
|
|
114
173
|
//#endregion
|
|
115
|
-
export { DEFAULT_EXPORT_ASSET_BASE, EmailCatalog, EmailCatalogEntry, EmailExportReceipt, EmailProjectSession, RenderedFixture, createEmailProjectSession, exportEmailProject, isTemplateDefinition, loadEmailCatalog, readEmailProject, renderEmailFixtures };
|
|
174
|
+
export { BRAND_SNAPSHOT_DIR, type BrandResolution, type BrandSnapshot, type BrandWarning, DEFAULT_EXPORT_ASSET_BASE, EmailCatalog, EmailCatalogEntry, EmailExportReceipt, EmailProjectSession, RenderedFixture, type ResolvedBrand, createEmailProjectSession, exportEmailProject, isTemplateDefinition, loadBrandEnv, loadEmailCatalog, readEmailProject, renderEmailFixtures, resolveProjectBrand };
|
|
116
175
|
//# sourceMappingURL=project.d.ts.map
|