@samva/vite 0.1.1 → 0.2.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/dist/index.js CHANGED
@@ -1,27 +1,22 @@
1
- import { a as discoverTemplateFiles, r as resolveThemePath, t as ThemeState } from "./theme-CfJe8s3k.js";
2
- import { compileSourcePreview, emitModulePreview } from "./emit.js";
1
+ import { n as resolveThemePath, r as discoverTemplateFiles, t as readProjectThemeCss } from "./theme-CBF6Czjm.js";
2
+ import { emitModulePreview } from "./emit.js";
3
+ import { createEmailRenderer } from "./email.js";
4
+ import { isTemplateDefinition, readEmailProject, renderEmailFixtures } from "./project.js";
3
5
  import { existsSync } from "node:fs";
4
- import { readFile, realpath, rename, writeFile } from "node:fs/promises";
5
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
7
+ import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
- import { Predicate, Result } from "effect";
9
+ import { checkEmailCompatibility, emailSelections } from "@samva/markup/compiler";
10
+ import { Predicate } from "effect";
8
11
  import { createServerModuleRunner, loadEnv } from "vite";
9
12
  import { createHash, randomUUID } from "node:crypto";
10
- import { variableReferences } from "@samva/markup/interpolate";
11
- import { applyOps } from "@samva/markup/ops";
12
- //#region src/editor-store.ts
13
- const noop = () => {};
14
13
  const hashSource = (source) => createHash("sha256").update(source, "utf8").digest("hex").slice(0, 16);
15
14
  const deriveName = (id) => {
16
- return id.slice(id.lastIndexOf("/") + 1).replace(/\.(?:samva|tsx)$/, "");
17
- };
18
- const kindForId = (id) => {
19
- if (id.endsWith(".samva")) return "samva";
20
- if (id.endsWith(".tsx")) return "tsx";
15
+ return id.slice(id.lastIndexOf("/") + 1).replace(/\.tsx$/, "");
21
16
  };
17
+ const kindForId = (id) => id.endsWith(".tsx") ? "tsx" : void 0;
22
18
  const within = (child, parent) => child === parent || child.startsWith(parent + sep);
23
19
  const flattenCompiledDiagnostics = (template) => {
24
- if (template.compiled.channel === "email") return [...template.diagnostics, ...template.compiled.bases.flatMap((base) => base.diagnostics ?? [])];
25
20
  return [...template.diagnostics, ...template.compiled.diagnostics ?? []];
26
21
  };
27
22
  const diagnostic = (file, severity, code, message, span) => ({
@@ -31,18 +26,22 @@ const diagnostic = (file, severity, code, message, span) => ({
31
26
  message,
32
27
  span
33
28
  });
29
+ /** A catalog finding, said in the language the email document reports findings in. */
30
+ const asEmailDiagnostic = (item) => ({
31
+ code: item.code,
32
+ severity: item.severity,
33
+ message: item.message,
34
+ origins: []
35
+ });
34
36
  /**
35
- * Enforces the TSX file contract after Vite evaluates the module. Named exports
36
- * are deliberately never inspected or invoked; only `default` can be a template.
37
+ * Enforces the TSX file contract for an SML template after Vite evaluates the
38
+ * module. Named exports are deliberately never inspected or invoked; only
39
+ * `default` can be a template.
37
40
  */
38
- const evaluateDefaultExport = (id, name, moduleExports, compile) => {
39
- const preview = emitModulePreview(name, moduleExports, compile);
40
- const template = preview.templates[0];
41
- if (template === void 0) return {
42
- ok: false,
43
- diagnostics: preview.diagnostics.map((problem) => diagnostic(id, problem.code === "missing-default-export" ? "warning" : "error", problem.code, problem.message, problem.span))
44
- };
45
- if (!template.ok || template.source === void 0) {
41
+ const evaluateDefaultExport = (id, name, moduleExports) => {
42
+ const template = emitModulePreview(name, moduleExports).templates[0];
43
+ if (template === void 0) return { kind: "helper" };
44
+ if (!template.ok || template.source === void 0 || template.channel === "unknown") {
46
45
  const problems = flattenCompiledDiagnostics(template);
47
46
  return {
48
47
  ok: false,
@@ -51,30 +50,45 @@ const evaluateDefaultExport = (id, name, moduleExports, compile) => {
51
50
  }
52
51
  return {
53
52
  ok: true,
54
- source: template.source,
55
- channel: template.channel,
56
- variables: template.variables
53
+ template: {
54
+ channel: template.channel,
55
+ source: template.source
56
+ },
57
+ variables: template.variables.map((variable) => ({
58
+ name: variable.name,
59
+ required: variable.required
60
+ }))
57
61
  };
58
62
  };
63
+ /** Two builds of one document differ when what the editor shows differs. */
64
+ const moved = (before, entry) => {
65
+ if (before === void 0) return true;
66
+ if (before.rev !== entry.rev || before.authoredSource !== entry.authoredSource) return true;
67
+ if (before.content.channel !== entry.content.channel) return true;
68
+ return before.content.channel === "email" && entry.content.channel === "email" && before.content.fixture !== entry.content.fixture;
69
+ };
59
70
  var EditorFileStore = class {
60
71
  dir;
61
- evaluateTsx;
62
- compile;
72
+ projectRoot;
73
+ evaluator;
63
74
  realDir;
64
75
  loaded = false;
76
+ mutationQueue = Promise.resolve();
77
+ serialize(operation) {
78
+ const running = this.mutationQueue.then(operation, operation);
79
+ this.mutationQueue = running.catch(() => void 0);
80
+ return running;
81
+ }
65
82
  initialLoad;
66
83
  entries = /* @__PURE__ */ new Map();
67
84
  diagnostics = /* @__PURE__ */ new Map();
68
- currentRev = /* @__PURE__ */ new Map();
69
- writing = /* @__PURE__ */ new Set();
70
- saveChain = /* @__PURE__ */ new Map();
71
85
  session = /* @__PURE__ */ new Map();
72
86
  listeners = /* @__PURE__ */ new Set();
73
87
  catalogListeners = /* @__PURE__ */ new Set();
74
88
  constructor(templatesDir, options = {}) {
75
89
  this.dir = templatesDir;
76
- this.evaluateTsx = options.evaluateTsx;
77
- this.compile = options.compile ?? (() => void 0);
90
+ this.projectRoot = options.projectRoot ?? templatesDir;
91
+ this.evaluator = options.evaluator;
78
92
  }
79
93
  async resolvePath(id) {
80
94
  if (kindForId(id) === void 0 || id.includes("\0")) return void 0;
@@ -96,82 +110,91 @@ var EditorFileStore = class {
96
110
  const rel = full.slice(this.dir.length + 1).split("\\").join("/");
97
111
  return kindForId(rel) === void 0 ? void 0 : rel;
98
112
  }
99
- async load(file) {
100
- const path = await this.resolvePath(file.id);
101
- if (path === void 0) return { diagnostics: [diagnostic(file.id, "error", "template-unavailable", "Template file is missing or resolves outside the configured templates directory.")] };
113
+ async load(id, previous) {
114
+ const path = await this.resolvePath(id);
115
+ if (path === void 0) return { diagnostics: [diagnostic(id, "error", "template-unavailable", "Template file is missing or resolves outside the configured templates directory.")] };
102
116
  const authoredSource = await readFile(path, "utf8").catch(() => void 0);
103
- if (authoredSource === void 0) return { diagnostics: [diagnostic(file.id, "error", "template-read-error", "Template file could not be read.")] };
104
- const name = this.session.get(file.id)?.name ?? deriveName(file.id);
105
- if (file.kind === "samva") {
106
- const preview = compileSourcePreview(name, authoredSource, this.compile());
107
- if (!preview.ok) {
108
- const problems = flattenCompiledDiagnostics(preview);
109
- const previous = this.entries.get(file.id);
110
- const diagnostics = problems.map((problem) => diagnostic(file.id, "error", problem.code, problem.message, problem.span));
111
- if (previous?.template.sourceKind !== "samva") return { diagnostics };
112
- const rev = hashSource(authoredSource);
113
- return {
114
- entry: {
115
- ...previous,
116
- template: {
117
- ...previous.template,
118
- name
119
- },
120
- source: authoredSource,
121
- rev,
122
- variables: [...variableReferences(authoredSource)].sort().map((variableName) => ({
123
- name: variableName,
124
- required: false
125
- }))
126
- },
127
- diagnostics
128
- };
129
- }
130
- const rev = hashSource(authoredSource);
117
+ if (authoredSource === void 0) return { diagnostics: [diagnostic(id, "error", "template-read-error", "Template file could not be read.")] };
118
+ const session = this.session.get(id);
119
+ const name = session?.name ?? deriveName(id);
120
+ if (this.evaluator === void 0) return {
121
+ authoredSource,
122
+ diagnostics: [diagnostic(id, "error", "tsx-evaluator-unavailable", "TSX templates require the Vite evaluator.")]
123
+ };
124
+ const evaluated = await this.evaluator.evaluate({
125
+ path,
126
+ id,
127
+ name,
128
+ fixture: session?.fixture
129
+ }).catch((cause) => ({
130
+ ok: false,
131
+ diagnostics: [diagnostic(id, "error", "module-load-error", cause instanceof Error ? cause.message : String(cause))]
132
+ }));
133
+ if ("kind" in evaluated) return {
134
+ ignored: true,
135
+ authoredSource,
136
+ diagnostics: []
137
+ };
138
+ if (!evaluated.ok) {
139
+ if (previous?.content.channel !== "email") return {
140
+ authoredSource,
141
+ diagnostics: evaluated.diagnostics
142
+ };
143
+ const diagnostics = evaluated.diagnostics.map(asEmailDiagnostic);
131
144
  return {
132
145
  entry: {
133
146
  template: {
134
- id: file.id,
147
+ ...previous.template,
135
148
  name,
136
- channel: preview.channel,
137
- sourceKind: "samva",
138
- access: "editable"
149
+ stale: false
139
150
  },
140
- source: authoredSource,
141
- rev,
142
- variables: [...variableReferences(authoredSource)].sort().map((variableName) => ({
143
- name: variableName,
144
- required: false
145
- }))
151
+ content: {
152
+ channel: "email",
153
+ fixtures: [],
154
+ fixture: null,
155
+ render: null,
156
+ diagnostics,
157
+ incompatibilities: []
158
+ },
159
+ authoredSource,
160
+ rev: hashSource(authoredSource + JSON.stringify(diagnostics)),
161
+ variables: []
146
162
  },
147
- diagnostics: []
163
+ authoredSource,
164
+ diagnostics: evaluated.diagnostics
148
165
  };
149
166
  }
150
- if (this.evaluateTsx === void 0) return { diagnostics: [diagnostic(file.id, "error", "tsx-evaluator-unavailable", "TSX templates require the Vite evaluator.")] };
151
- const evaluated = await this.evaluateTsx(path, file.id, name, this.compile()).catch((cause) => ({
152
- ok: false,
153
- diagnostics: [diagnostic(file.id, "error", "module-load-error", cause instanceof Error ? cause.message : String(cause))]
154
- }));
155
- if (!evaluated.ok) return { diagnostics: evaluated.diagnostics };
156
- const rev = hashSource(evaluated.source);
167
+ const built = evaluated.template;
168
+ const rev = hashSource(authoredSource + (built.channel === "email" ? built.identity : built.source));
169
+ const content = built.channel === "email" ? {
170
+ channel: "email",
171
+ fixtures: built.fixtures,
172
+ fixture: built.fixture,
173
+ render: built.render === null ? null : {
174
+ revision: rev,
175
+ ...built.render
176
+ },
177
+ diagnostics: built.diagnostics,
178
+ incompatibilities: built.incompatibilities
179
+ } : {
180
+ channel: built.channel,
181
+ source: built.source
182
+ };
157
183
  return {
158
184
  entry: {
159
185
  template: {
160
- id: file.id,
186
+ id,
161
187
  name,
162
- channel: evaluated.channel,
188
+ channel: built.channel,
163
189
  sourceKind: "tsx",
164
- access: "readonly"
190
+ stale: false
165
191
  },
166
- source: evaluated.source,
192
+ content,
167
193
  authoredSource,
168
194
  rev,
169
- variables: evaluated.variables.map((variable) => ({
170
- name: variable.name,
171
- required: variable.required
172
- }))
195
+ variables: evaluated.variables
173
196
  },
174
- diagnostics: []
197
+ diagnostics: evaluated.diagnostics ?? []
175
198
  };
176
199
  }
177
200
  catalogSnapshot() {
@@ -194,26 +217,30 @@ var EditorFileStore = class {
194
217
  }
195
218
  async rebuild(emitDocuments) {
196
219
  const previous = new Map(this.entries);
220
+ await this.evaluator?.refresh();
197
221
  const files = await discoverTemplateFiles(this.dir);
198
- const loaded = await Promise.all(files.map(async (file) => [file.id, await this.load(file)]));
222
+ const loaded = await Promise.all(files.map(async (file) => [file.id, await this.load(file.id, previous.get(file.id))]));
199
223
  this.entries.clear();
200
224
  this.diagnostics.clear();
201
225
  for (const [id, result] of loaded) {
202
- if (result.entry !== void 0) {
203
- this.entries.set(id, result.entry);
204
- this.currentRev.set(id, result.entry.rev);
205
- } else this.currentRev.delete(id);
226
+ if (result.entry !== void 0) this.entries.set(id, result.entry);
227
+ else if (!result.ignored && previous.has(id)) {
228
+ const lastGood = previous.get(id);
229
+ const stale = {
230
+ ...lastGood,
231
+ template: {
232
+ ...lastGood.template,
233
+ stale: true
234
+ },
235
+ authoredSource: result.authoredSource ?? lastGood.authoredSource
236
+ };
237
+ this.entries.set(id, stale);
238
+ }
206
239
  if (result.diagnostics.length > 0) this.diagnostics.set(id, result.diagnostics);
207
240
  }
208
241
  this.loaded = true;
209
- if (emitDocuments) for (const [id, entry] of this.entries) {
210
- const before = previous.get(id);
211
- if (before === void 0 || before.source !== entry.source || before.authoredSource !== entry.authoredSource) this.publish({
212
- id,
213
- rev: entry.rev,
214
- source: entry.source,
215
- authoredSource: entry.authoredSource
216
- });
242
+ if (emitDocuments) {
243
+ for (const [id, entry] of this.entries) if (moved(previous.get(id), entry)) this.publishEntry(id, entry);
217
244
  }
218
245
  for (const listener of this.catalogListeners) listener();
219
246
  return this.catalogSnapshot();
@@ -226,28 +253,7 @@ var EditorFileStore = class {
226
253
  const loading = this.initialLoad;
227
254
  if (loading !== void 0) await loading;
228
255
  else if (!this.loaded) return this.ensureLoaded();
229
- return this.rebuild(options.documents ?? true);
230
- }
231
- /** Recompile one changed editable file without re-evaluating unrelated TSX modules. */
232
- async refreshSamva(id) {
233
- if (kindForId(id) !== "samva") return this.catalog();
234
- if (!this.loaded) return this.ensureLoaded();
235
- const result = await this.load({
236
- id,
237
- path: join(this.dir, id),
238
- kind: "samva"
239
- });
240
- if (result.entry !== void 0) {
241
- this.entries.set(id, result.entry);
242
- this.currentRev.set(id, result.entry.rev);
243
- } else {
244
- this.entries.delete(id);
245
- this.currentRev.delete(id);
246
- }
247
- if (result.diagnostics.length > 0) this.diagnostics.set(id, result.diagnostics);
248
- else this.diagnostics.delete(id);
249
- for (const listener of this.catalogListeners) listener();
250
- return this.catalogSnapshot();
256
+ return this.serialize(() => this.rebuild(options.documents ?? true));
251
257
  }
252
258
  async list() {
253
259
  return (await this.catalog()).templates;
@@ -257,155 +263,144 @@ var EditorFileStore = class {
257
263
  const entry = this.entries.get(id);
258
264
  if (entry === void 0) return void 0;
259
265
  const meta = this.session.get(id);
260
- const fields = {
266
+ return {
261
267
  id: null,
262
268
  name: meta?.name ?? entry.template.name,
263
- channel: entry.template.channel,
264
- source: entry.source,
265
269
  rev: entry.rev,
266
270
  variables: entry.variables,
267
- metadata: meta?.metadata ?? {}
268
- };
269
- if (entry.template.sourceKind === "tsx") return {
270
- ...fields,
271
+ metadata: meta?.metadata ?? {},
272
+ ...entry.content,
271
273
  origin: {
272
274
  kind: "tsx",
273
- file: id,
275
+ file: relative(this.projectRoot, resolve(this.dir, id)).split(sep).join("/"),
274
276
  authoredSource: entry.authoredSource ?? ""
275
277
  },
276
- access: {
278
+ access: entry.content.channel === "email" ? { kind: "editable" } : {
277
279
  kind: "readonly",
278
- reason: "TSX templates are code-authored. Edit the default export in your code editor."
280
+ reason: "This channel has no local source-save adapter."
279
281
  }
280
282
  };
281
- return {
282
- ...fields,
283
- origin: {
284
- kind: "samva",
285
- file: id
286
- },
287
- access: { kind: "editable" }
288
- };
289
283
  }
290
- async save(id, update, client) {
291
- if (kindForId(id) === "tsx") return {
284
+ /**
285
+ * Serialize editor saves and atomically replace the complete entry after a fresh disk check.
286
+ * Arbitrary external writers do not participate in this queue: the final check and rename
287
+ * are not a filesystem compare-and-swap, so authors must resolve overlapping saves externally.
288
+ */
289
+ async saveSource(id, baseRev, authoredSource, client) {
290
+ if (Buffer.byteLength(authoredSource, "utf8") > 1048576) return {
292
291
  ok: false,
293
- error: "TSX templates are read-only; edit the default export in source."
292
+ kind: "refused",
293
+ error: "Authored source exceeds the 1 MiB limit."
294
294
  };
295
- const path = await this.resolvePath(id);
296
- if (path === void 0 || kindForId(id) !== "samva") return {
297
- ok: false,
298
- error: "template not found"
299
- };
300
- if (update.kind === "metadata") {
301
- const prev = this.session.get(id) ?? { metadata: {} };
302
- const next = {
303
- name: update.patch.name ?? prev.name,
304
- metadata: {
305
- fromDefault: update.patch.fromDefault === void 0 ? prev.metadata.fromDefault : update.patch.fromDefault ?? void 0,
306
- replyToDefault: update.patch.replyToDefault === void 0 ? prev.metadata.replyToDefault : update.patch.replyToDefault ?? void 0
307
- }
308
- };
309
- this.session.set(id, next);
295
+ await this.ensureLoaded();
296
+ return this.serialize(async () => {
310
297
  const entry = this.entries.get(id);
311
- if (entry !== void 0 && next.name !== void 0) {
312
- this.entries.set(id, {
313
- ...entry,
314
- template: {
315
- ...entry.template,
316
- name: next.name
317
- }
318
- });
319
- for (const listener of this.catalogListeners) listener();
298
+ const path = await this.resolvePath(id);
299
+ if (entry === void 0 || path === void 0) return {
300
+ ok: false,
301
+ kind: "missing",
302
+ error: "Template not found."
303
+ };
304
+ if (entry.content.channel !== "email") return {
305
+ ok: false,
306
+ kind: "refused",
307
+ error: "Only email source is editable here."
308
+ };
309
+ const disk = await readFile(path, "utf8");
310
+ if (entry.rev !== baseRev || disk !== entry.authoredSource) {
311
+ await this.rebuild(true);
312
+ return {
313
+ ok: false,
314
+ kind: "conflict",
315
+ error: "Source changed on disk. Reload before saving.",
316
+ actualRev: this.entries.get(id)?.rev ?? entry.rev
317
+ };
320
318
  }
321
- const current = await readFile(path, "utf8").catch(() => void 0);
322
- return {
319
+ const temporary = `${path}.${randomUUID()}.tmp`;
320
+ const mode = (await stat(path)).mode;
321
+ const file = await open(temporary, "wx", mode);
322
+ try {
323
+ await file.writeFile(authoredSource, "utf8");
324
+ await file.chmod(mode);
325
+ await file.sync();
326
+ await file.close();
327
+ if (await readFile(path, "utf8") !== disk) {
328
+ await this.rebuild(true);
329
+ return {
330
+ ok: false,
331
+ kind: "conflict",
332
+ error: "Source changed on disk. Reload before saving.",
333
+ actualRev: this.entries.get(id)?.rev ?? entry.rev
334
+ };
335
+ }
336
+ await rename(temporary, path);
337
+ } finally {
338
+ await file.close().finally(() => rm(temporary, { force: true }));
339
+ }
340
+ const previous = new Map(this.entries);
341
+ await this.rebuild(false);
342
+ for (const [changedId, changed] of this.entries) if (moved(previous.get(changedId), changed)) this.publishEntry(changedId, changed, changedId === id ? client : void 0);
343
+ const saved = this.entries.get(id);
344
+ return saved === void 0 ? {
345
+ ok: false,
346
+ kind: "missing",
347
+ error: "The saved entry is no longer discoverable."
348
+ } : {
323
349
  ok: true,
324
- rev: current === void 0 ? this.currentRev.get(id) ?? "" : hashSource(current)
350
+ rev: saved.rev
325
351
  };
326
- }
327
- return this.withLock(id, () => this.commit(id, path, update, client));
352
+ });
328
353
  }
329
- async commit(id, path, update, client) {
330
- const current = await readFile(path, "utf8").catch(() => void 0);
331
- if (current === void 0) return {
354
+ /**
355
+ * Show another declared fixture of an email document. It re-renders the same
356
+ * build, so the revision does not move; the resulting document reaches every
357
+ * watcher on the change stream, attributed to the client that asked.
358
+ */
359
+ async viewFixture(id, fixture, client) {
360
+ if (!this.loaded) await this.ensureLoaded();
361
+ const entry = this.entries.get(id);
362
+ if (entry === void 0) return {
332
363
  ok: false,
364
+ kind: "missing",
333
365
  error: "template not found"
334
366
  };
335
- const actualRev = hashSource(current);
336
- if (update.baseRev !== actualRev) return {
367
+ if (entry.content.channel !== "email") return {
337
368
  ok: false,
338
- conflict: {
339
- expectedRev: update.baseRev,
340
- actualRev
341
- }
369
+ kind: "refused",
370
+ error: `A ${entry.content.channel} template renders no fixture.`
371
+ };
372
+ if (!entry.content.fixtures.includes(fixture)) return {
373
+ ok: false,
374
+ kind: "refused",
375
+ error: `Template declares no fixture named ${JSON.stringify(fixture)}.`
342
376
  };
343
- let nextSource;
344
- if (update.kind === "ops") {
345
- const applied = applyOps(current, [...update.ops]);
346
- if (Result.isFailure(applied)) return {
377
+ const meta = this.session.get(id);
378
+ this.session.set(id, {
379
+ ...meta,
380
+ metadata: meta?.metadata ?? {},
381
+ fixture
382
+ });
383
+ const loaded = await this.load(id, entry);
384
+ if (loaded.entry === void 0) {
385
+ if (loaded.diagnostics.length > 0) this.diagnostics.set(id, loaded.diagnostics);
386
+ return {
347
387
  ok: false,
348
- error: applied.failure.message
388
+ kind: "refused",
389
+ error: `Template ${id} could not be re-rendered.`
349
390
  };
350
- nextSource = applied.success.source;
351
- } else nextSource = update.source;
352
- const rev = hashSource(nextSource);
353
- this.writing.add(id);
354
- try {
355
- const temp = join(dirname(path), `.${randomUUID()}.samva.tmp`);
356
- await writeFile(temp, nextSource, "utf8");
357
- await rename(temp, path);
358
- await this.refreshSamva(id);
359
- } finally {
360
- this.writing.delete(id);
361
391
  }
362
- this.publish({
363
- id,
364
- rev,
365
- source: nextSource,
366
- sourceClient: client
367
- });
368
- return {
369
- ok: true,
370
- rev
371
- };
392
+ this.entries.set(id, loaded.entry);
393
+ this.publishEntry(id, loaded.entry, client);
394
+ return { ok: true };
372
395
  }
373
- withLock(id, run) {
374
- const next = (this.saveChain.get(id) ?? Promise.resolve()).then(run, run);
375
- this.saveChain.set(id, next.then(noop, noop));
376
- return next;
377
- }
378
- async handleFileChange(path) {
379
- const id = this.idFor(path);
380
- if (id === void 0 || kindForId(id) !== "samva") return void 0;
381
- if (this.writing.has(id)) return void 0;
382
- const real = await this.resolvePath(id);
383
- if (real === void 0) return void 0;
384
- const source = await readFile(real, "utf8").catch(() => void 0);
385
- if (source === void 0) return void 0;
386
- const rev = hashSource(source);
387
- if (rev === this.currentRev.get(id)) return void 0;
388
- this.currentRev.set(id, rev);
389
- const previous = this.entries.get(id);
390
- if (previous !== void 0) this.entries.set(id, {
391
- ...previous,
392
- source,
393
- rev,
394
- variables: [...variableReferences(source)].sort().map((name) => ({
395
- name,
396
- required: false
397
- }))
398
- });
396
+ publishEntry(id, entry, client) {
399
397
  const change = {
400
398
  id,
401
- rev,
402
- source,
403
- sourceClient: void 0
399
+ rev: entry.rev,
400
+ authoredSource: entry.authoredSource,
401
+ ...client === void 0 ? {} : { sourceClient: client },
402
+ ...entry.content
404
403
  };
405
- this.publish(change);
406
- return change;
407
- }
408
- publish(change) {
409
404
  for (const listener of this.listeners) listener(change);
410
405
  }
411
406
  subscribe(listener) {
@@ -480,13 +475,23 @@ const isViteInternalPath = (pathname) => VITE_INTERNAL_PREFIXES.some((prefix) =>
480
475
  const hasFileExtension = (pathname) => extname(pathname) !== "";
481
476
  const underRoute = (route, pathname) => route === "/" || pathname === route || pathname.startsWith(`${route}/`);
482
477
  const childRoute = (route, child) => route === "/" ? `/${child}` : `${route}/${child}`;
478
+ const MAX_BODY_BYTES = 6295552;
479
+ var EditorBodyTooLarge = class extends Error {};
483
480
  const readBody = (req) => new Promise((done, fail) => {
484
- let data = "";
481
+ const chunks = [];
482
+ let bytes = 0;
485
483
  req.on("data", (chunk) => {
486
- data += chunk;
484
+ bytes += chunk.length;
485
+ if (bytes > MAX_BODY_BYTES) {
486
+ chunks.length = 0;
487
+ fail(new EditorBodyTooLarge("Request body exceeds the editor limit."));
488
+ return;
489
+ }
490
+ chunks.push(chunk);
487
491
  });
488
- req.on("end", () => done(data));
492
+ req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
489
493
  req.on("error", fail);
494
+ req.on("aborted", () => fail(/* @__PURE__ */ new Error("Request body aborted.")));
490
495
  });
491
496
  const createSseClient = (res, docId, clientId) => {
492
497
  let pending;
@@ -516,13 +521,145 @@ const createSseClient = (res, docId, clientId) => {
516
521
  }
517
522
  };
518
523
  };
524
+ /**
525
+ * The dev server is the origin, and it is not https, so a local render points
526
+ * at its asset route by path. Every render here is a preview or the input to a
527
+ * finding — none of it is delivered — and the base a publication serves from is
528
+ * a build input rather than something the author wrote, so nothing is hidden by
529
+ * accepting one here.
530
+ */
531
+ const LOCAL_RENDER = { relativeUrls: true };
532
+ /**
533
+ * A template's input contract is a JSON Schema, so its variables are that
534
+ * schema's top-level properties. Nested shapes are not flattened into names —
535
+ * a template's input is a document, not a bag of placeholders.
536
+ */
537
+ const variablesOf = (definition) => {
538
+ const properties = definition.schema.schema["properties"];
539
+ if (!Predicate.isObject(properties)) return [];
540
+ const required = definition.schema.schema["required"];
541
+ const names = Array.isArray(required) ? required : [];
542
+ return Object.entries(properties).map(([name, property]) => {
543
+ const examples = Predicate.isObject(property) ? property["examples"] : void 0;
544
+ const example = Array.isArray(examples) ? examples[0] : void 0;
545
+ return {
546
+ name,
547
+ required: names.includes(name),
548
+ ...typeof example === "string" ? { sample: example } : {}
549
+ };
550
+ });
551
+ };
552
+ /** One executed render of the viewed fixture, stamped so a click resolves to one element. */
553
+ const previewRender = (entry, fixture) => {
554
+ try {
555
+ const rendered = entry.loaded.renderEmail(entry.definition, entry.definition.fixtures[fixture], {
556
+ ...LOCAL_RENDER,
557
+ instancePaths: true
558
+ });
559
+ return { render: {
560
+ subject: rendered.subject,
561
+ ...rendered.preheader === void 0 ? {} : { preheader: rendered.preheader },
562
+ html: rendered.html,
563
+ text: rendered.text,
564
+ selections: emailSelections(rendered.positions)
565
+ } };
566
+ } catch {
567
+ return { render: null };
568
+ }
569
+ };
570
+ /** An entry that is a template but did not build: a document state, and a catalog finding. */
571
+ const emailBuildFailure = (id, message) => ({
572
+ ok: true,
573
+ variables: [],
574
+ diagnostics: [{
575
+ file: id,
576
+ severity: "error",
577
+ code: "email-build-error",
578
+ message
579
+ }],
580
+ template: {
581
+ channel: "email",
582
+ fixtures: [],
583
+ fixture: null,
584
+ render: null,
585
+ diagnostics: [{
586
+ code: "email-build-error",
587
+ severity: "error",
588
+ message,
589
+ origins: []
590
+ }],
591
+ incompatibilities: [],
592
+ identity: JSON.stringify({ error: message })
593
+ }
594
+ });
595
+ /** Compile, evaluate and render one email entry through the compiler boundary. */
596
+ const evaluateEmailEntry = async (input) => {
597
+ const loaded = await input.renderer.load(input.files, input.entryPath, input.entryPath).then((value) => ({
598
+ ok: true,
599
+ value
600
+ }), (cause) => ({
601
+ ok: false,
602
+ message: cause instanceof Error ? cause.message : String(cause)
603
+ }));
604
+ if (!loaded.ok) return emailBuildFailure(input.id, loaded.message);
605
+ const definition = loaded.value.template;
606
+ if (!isTemplateDefinition(definition)) return emailBuildFailure(input.id, `${input.id} default-exports something that is not a template definition.`);
607
+ for (const asset of loaded.value.compiled.assets) {
608
+ const bytes = input.files[asset.path];
609
+ if (bytes !== void 0 && typeof bytes !== "string") input.assets.set(asset.fileName, {
610
+ contentType: asset.contentType,
611
+ bytes
612
+ });
613
+ }
614
+ const entry = {
615
+ id: definition.id,
616
+ entryPath: input.entryPath,
617
+ definition,
618
+ loaded: loaded.value
619
+ };
620
+ const fixtures = Object.keys(definition.fixtures);
621
+ const fixture = input.fixture !== void 0 && fixtures.includes(input.fixture) ? input.fixture : fixtures[0] ?? null;
622
+ const delivered = renderEmailFixtures(entry, LOCAL_RENDER);
623
+ const diagnostics = [...loaded.value.compiled.diagnostics, ...delivered.flatMap((result) => result.diagnostics.map((diagnostic) => ({
624
+ ...diagnostic,
625
+ fixtures: [result.fixture]
626
+ })))];
627
+ const incompatibilities = delivered.flatMap((result) => result.rendered === void 0 ? [] : checkEmailCompatibility({
628
+ html: result.rendered.html,
629
+ fixture: result.fixture,
630
+ positions: result.rendered.positions
631
+ }));
632
+ return {
633
+ ok: true,
634
+ variables: variablesOf(definition),
635
+ template: {
636
+ channel: "email",
637
+ fixtures,
638
+ fixture,
639
+ ...fixture === null ? { render: null } : previewRender(entry, fixture),
640
+ diagnostics,
641
+ incompatibilities,
642
+ identity: JSON.stringify({
643
+ diagnostics,
644
+ incompatibilities,
645
+ fixtures: delivered.map((result) => ({
646
+ fixture: result.fixture,
647
+ subject: result.rendered?.subject ?? null,
648
+ preheader: result.rendered?.preheader ?? null,
649
+ html: result.rendered?.html ?? null,
650
+ text: result.rendered?.text ?? null
651
+ }))
652
+ })
653
+ }
654
+ };
655
+ };
519
656
  const samvaEditor = (options = {}) => {
520
657
  const route = normalizeRoute(options.route ?? DEFAULT_ROUTE);
521
658
  const apiPrefix = childRoute(route, "api");
522
659
  let root = process.cwd();
523
660
  let templatesDir = resolve(root, options.templatesDir ?? "emails");
524
- let theme = new ThemeState(resolveThemePath(root, options.theme));
525
- let store = new EditorFileStore(templatesDir, { compile: () => theme.applyTo(options.compile) });
661
+ let themePath = resolveThemePath(root, options.theme);
662
+ let store = new EditorFileStore(templatesDir, { projectRoot: root });
526
663
  let authenticated = false;
527
664
  const clients = /* @__PURE__ */ new Set();
528
665
  const catalogClients = /* @__PURE__ */ new Set();
@@ -533,7 +670,6 @@ const samvaEditor = (options = {}) => {
533
670
  });
534
671
  return refreshTail;
535
672
  };
536
- const queueRefresh = (server, documents) => queueStoreRefresh(server, () => store.refreshAll({ documents }));
537
673
  return {
538
674
  name: "samva-editor",
539
675
  config() {
@@ -548,25 +684,61 @@ const samvaEditor = (options = {}) => {
548
684
  configResolved(resolved) {
549
685
  root = resolved.root;
550
686
  templatesDir = isAbsolute(options.templatesDir ?? "") ? options.templatesDir : resolve(root, options.templatesDir ?? "emails");
551
- theme = new ThemeState(resolveThemePath(root, options.theme));
552
- store = new EditorFileStore(templatesDir, { compile: () => theme.applyTo(options.compile) });
687
+ themePath = resolveThemePath(root, options.theme);
688
+ store = new EditorFileStore(templatesDir, { projectRoot: root });
553
689
  const env = loadEnv(resolved.mode, root, "");
554
690
  authenticated = Boolean(env.SAMVA_API_KEY);
555
691
  },
556
692
  async configureServer(server) {
557
- await theme.refresh();
558
- const evaluator = createServerModuleRunner(server.environments.ssr, { hmr: false });
693
+ const moduleRunner = createServerModuleRunner(server.environments.ssr, { hmr: false });
694
+ const assets = /* @__PURE__ */ new Map();
695
+ let themeCss;
696
+ let emailRenderer;
697
+ let emailRendererLoading;
698
+ const emailRendererFor = () => emailRendererLoading ??= createEmailRenderer({
699
+ assetBase: childRoute(apiPrefix, "assets"),
700
+ ...themeCss === void 0 ? {} : { tailwind: { css: themeCss } }
701
+ }).then((renderer) => {
702
+ emailRenderer = renderer;
703
+ return renderer;
704
+ });
705
+ let projectFiles;
706
+ let emailRenderTail = Promise.resolve();
559
707
  store = new EditorFileStore(templatesDir, {
560
- compile: () => theme.applyTo(options.compile),
561
- evaluateTsx: async (path, id, name, compile) => {
562
- const moduleExports = await evaluator.import(path);
563
- return evaluateDefaultExport(id, name, moduleExports, compile);
708
+ projectRoot: root,
709
+ evaluator: {
710
+ refresh: async () => {
711
+ projectFiles = void 0;
712
+ const nextThemeCss = await readProjectThemeCss(themePath);
713
+ if (nextThemeCss !== themeCss) {
714
+ await emailRendererLoading?.then((renderer) => renderer.close());
715
+ emailRenderer = void 0;
716
+ emailRendererLoading = void 0;
717
+ themeCss = nextThemeCss;
718
+ }
719
+ emailRenderer?.invalidate();
720
+ },
721
+ evaluate: async ({ path, id, name, fixture }) => {
722
+ const moduleExports = await moduleRunner.import(path);
723
+ if (!isTemplateDefinition(moduleExports["default"])) return evaluateDefaultExport(id, name, moduleExports);
724
+ const render = async () => evaluateEmailEntry({
725
+ renderer: await emailRendererFor(),
726
+ files: await (projectFiles ??= readEmailProject(root)),
727
+ entryPath: relative(root, path).split(sep).join("/"),
728
+ assets,
729
+ id,
730
+ fixture
731
+ });
732
+ const pending = emailRenderTail.then(render, render);
733
+ emailRenderTail = pending;
734
+ return pending;
735
+ }
564
736
  }
565
737
  });
566
738
  server.watcher.add(templatesDir);
567
- if (theme.themePath !== void 0) server.watcher.add(theme.themePath);
739
+ if (themePath !== void 0) server.watcher.add(themePath);
568
740
  const refreshTheme = () => {
569
- theme.refresh().then(() => queueRefresh(server, false));
741
+ queueStoreRefresh(server, () => store.refreshAll({ documents: true }));
570
742
  };
571
743
  const refreshTsxDependents = (path, shapeChange) => {
572
744
  const ssr = server.environments.ssr;
@@ -585,51 +757,39 @@ const samvaEditor = (options = {}) => {
585
757
  for (const module of changedModules) collectImporters(module);
586
758
  if (shapeChange) ssr.moduleGraph.invalidateAll();
587
759
  else for (const module of invalidated) ssr.moduleGraph.invalidateModule(module);
588
- evaluator.evaluatedModules.clear();
760
+ moduleRunner.evaluatedModules.clear();
589
761
  return store.refreshAll({ documents: true });
590
762
  });
591
763
  };
592
764
  const onChange = (path) => {
593
- if (theme.themePath !== void 0 && path === theme.themePath) {
765
+ if (themePath !== void 0 && path === themePath) {
594
766
  refreshTheme();
595
767
  return;
596
768
  }
597
- if (path.endsWith(".samva")) {
598
- store.handleFileChange(path).then((change) => {
599
- if (change !== void 0) queueStoreRefresh(server, () => store.refreshSamva(change.id));
600
- });
601
- return;
602
- }
603
769
  refreshTsxDependents(path, false);
604
770
  };
605
771
  const onCatalogShape = (path) => {
606
- if (theme.themePath !== void 0 && path === theme.themePath) {
772
+ if (themePath !== void 0 && path === themePath) {
607
773
  refreshTheme();
608
774
  return;
609
775
  }
610
- if (store.idFor(path)?.endsWith(".samva")) {
611
- queueRefresh(server, true);
612
- return;
613
- }
614
776
  refreshTsxDependents(path, true);
615
777
  };
616
778
  server.watcher.on("change", onChange);
617
779
  server.watcher.on("add", onCatalogShape);
618
780
  server.watcher.on("unlink", onCatalogShape);
619
781
  const unsubscribe = store.subscribe((change) => {
782
+ const { sourceClient, ...document } = change;
620
783
  server.ws.send({
621
784
  type: "custom",
622
785
  event: "samva:document",
623
- data: change
786
+ data: document
624
787
  });
625
788
  for (const client of clients) {
626
789
  if (client.docId !== change.id) continue;
627
- const origin = change.sourceClient !== void 0 && change.sourceClient === client.clientId ? "self" : "external";
790
+ const origin = sourceClient !== void 0 && sourceClient === client.clientId ? "self" : "external";
628
791
  client.push({
629
- id: change.id,
630
- rev: change.rev,
631
- source: change.source,
632
- authoredSource: change.authoredSource,
792
+ ...document,
633
793
  origin
634
794
  });
635
795
  }
@@ -652,7 +812,8 @@ const samvaEditor = (options = {}) => {
652
812
  server.httpServer?.off("close", cleanup);
653
813
  unsubscribe();
654
814
  unsubscribeCatalog();
655
- evaluator.close();
815
+ moduleRunner.close();
816
+ emailRendererLoading?.then((renderer) => renderer.close());
656
817
  for (const client of clients) client.res.end();
657
818
  clients.clear();
658
819
  for (const res of catalogClients) res.end();
@@ -671,45 +832,59 @@ const samvaEditor = (options = {}) => {
671
832
  if (pathname === `${apiPrefix}/capabilities`) return sendJson(res, 200, { authenticated });
672
833
  if (pathname === `${apiPrefix}/templates`) {
673
834
  const catalog = await store.catalog();
674
- const themeFile = theme.themePath === void 0 ? void 0 : relative(root, theme.themePath);
675
- const themeDiagnostics = theme.current().diagnostics.map((item) => ({
676
- file: themeFile ?? "theme.css",
677
- severity: "error",
678
- code: item.code,
679
- message: item.message,
680
- span: item.span
681
- }));
682
835
  return sendJson(res, 200, {
683
836
  templates: catalog.templates,
684
- diagnostics: [...catalog.diagnostics, ...themeDiagnostics],
685
- theme: {
686
- file: themeFile,
687
- tokens: theme.current().tokens
688
- },
689
- compile: theme.applyTo(options.compile)
837
+ diagnostics: catalog.diagnostics
690
838
  });
691
839
  }
692
840
  if (pathname === `${apiPrefix}/document`) {
693
841
  const id = query.get("id");
694
842
  if (!id) return sendJson(res, 400, { error: "missing id" });
695
843
  if (req.method === "POST") {
696
- const raw = await readBody(req);
697
- let update;
844
+ const origin = req.headers.origin;
845
+ if (origin !== void 0 && origin !== `http://${req.headers.host}` && origin !== `https://${req.headers.host}`) return sendJson(res, 403, { error: "Source saves require the editor origin." });
846
+ let body;
698
847
  try {
699
- update = JSON.parse(raw);
700
- } catch {
848
+ body = JSON.parse(await readBody(req));
849
+ } catch (error) {
850
+ if (error instanceof EditorBodyTooLarge) return sendJson(res, 413, { error: error.message });
701
851
  return sendJson(res, 400, { error: "malformed JSON body" });
702
852
  }
703
- if (!Predicate.isObject(update)) return sendJson(res, 400, { error: "malformed JSON body" });
704
- const outcome = await store.save(id, update, query.get("client") ?? void 0);
853
+ if (!Predicate.isObject(body) || body["kind"] !== "authoredSource" || typeof body["baseRev"] !== "string" || typeof body["authoredSource"] !== "string") return sendJson(res, 400, { error: "Expected a revision-bound authored source update." });
854
+ if (Buffer.byteLength(body["authoredSource"], "utf8") > 1048576) return sendJson(res, 413, { error: "Authored source exceeds the 1 MiB limit." });
855
+ const outcome = await store.saveSource(id, body["baseRev"], body["authoredSource"], query.get("client") ?? void 0);
705
856
  if (outcome.ok) return sendJson(res, 200, { rev: outcome.rev });
706
- if ("conflict" in outcome) return sendJson(res, 409, outcome.conflict);
707
- return sendJson(res, 422, { error: outcome.error });
857
+ return sendJson(res, outcome.kind === "conflict" ? 409 : outcome.kind === "missing" ? 404 : 422, outcome);
708
858
  }
859
+ if (req.method !== "GET") return sendJson(res, 405, { error: "Use GET or POST." });
709
860
  const snapshot = await store.open(id);
710
861
  if (snapshot === void 0) return sendJson(res, 404, { error: "template not found" });
711
862
  return sendJson(res, 200, snapshot);
712
863
  }
864
+ if (pathname === `${apiPrefix}/fixture`) {
865
+ const id = query.get("id");
866
+ if (!id) return sendJson(res, 400, { error: "missing id" });
867
+ if (req.method !== "POST") return sendJson(res, 405, { error: "viewing a fixture is a POST" });
868
+ const raw = await readBody(req);
869
+ let body;
870
+ try {
871
+ body = JSON.parse(raw);
872
+ } catch {
873
+ return sendJson(res, 400, { error: "malformed JSON body" });
874
+ }
875
+ if (!Predicate.isObject(body) || typeof body["fixture"] !== "string") return sendJson(res, 400, { error: "malformed JSON body" });
876
+ const outcome = await store.viewFixture(id, body["fixture"], query.get("client") ?? void 0);
877
+ if (outcome.ok) return sendJson(res, 200, {});
878
+ return sendJson(res, outcome.kind === "missing" ? 404 : 422, { error: outcome.error });
879
+ }
880
+ if (pathname.startsWith(`${apiPrefix}/assets/`)) {
881
+ const asset = assets.get(pathname.slice(`${apiPrefix}/assets/`.length));
882
+ if (asset === void 0) return sendJson(res, 404, { error: "asset not found" });
883
+ res.statusCode = 200;
884
+ res.setHeader("content-type", asset.contentType);
885
+ res.end(Buffer.from(asset.bytes));
886
+ return;
887
+ }
713
888
  if (pathname === `${apiPrefix}/events`) {
714
889
  const id = query.get("id");
715
890
  if (!id) return sendJson(res, 400, { error: "missing id" });
@@ -747,7 +922,7 @@ const samvaEditor = (options = {}) => {
747
922
  if (await serveStatic(res, join(EDITOR_DIR, "index.html"))) return;
748
923
  return next();
749
924
  })().catch((error) => {
750
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
925
+ sendJson(res, error instanceof EditorBodyTooLarge ? 413 : 500, { error: error instanceof Error ? error.message : String(error) });
751
926
  });
752
927
  };
753
928
  server.middlewares.use(handler);