@samva/vite 0.7.0 → 0.9.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 +63 -54
- package/README.md +151 -79
- package/dist/build.d.ts +47 -42
- package/dist/build.js +89 -191
- package/dist/dev.d.ts +22 -0
- package/dist/dev.js +43 -0
- package/dist/editor/assets/index-BtfwuWcY.js +32 -0
- package/dist/editor/assets/index-DIuS_EZu.css +2 -0
- package/dist/editor/index.html +2 -2
- package/dist/editor-Cow7zZ_9.js +780 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1 -1015
- package/dist/project-CiUuzKsC.d.ts +271 -0
- package/dist/project-Cp8qb3s0.js +774 -0
- package/dist/project.d.ts +2 -116
- package/dist/project.js +2 -294
- package/dist/render.d.ts +54 -0
- package/dist/render.js +98 -0
- package/package.json +11 -12
- package/dist/editor/assets/index-BeVA-Ur2.js +0 -32
- package/dist/editor/assets/index-Dz2CE64u.css +0 -2
- package/dist/email.d.ts +0 -60
- package/dist/email.js +0 -126
- package/dist/emit.d.ts +0 -99
- package/dist/emit.js +0 -142
- package/dist/theme-CBF6Czjm.js +0 -50
package/dist/index.js
CHANGED
|
@@ -1,1016 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { emitModulePreview } from "./emit.js";
|
|
3
|
-
import { createEmailRenderer } from "./email.js";
|
|
4
|
-
import { isTemplateDefinition, readEmailProject, renderEmailFixtures } from "./project.js";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
6
|
-
import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
7
|
-
import { isIP } from "node:net";
|
|
8
|
-
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { checkEmailCompatibility, emailSelections } from "@samva/markup/compiler";
|
|
11
|
-
import { createServerModuleRunner, loadEnv } from "vite";
|
|
12
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
-
/** The refusal both the store and the dev-server route return for an oversized entry. */
|
|
14
|
-
const EDITOR_SOURCE_TOO_LARGE_MESSAGE = `Authored source exceeds the 1 MiB limit.`;
|
|
15
|
-
const hashSource = (source) => createHash("sha256").update(source, "utf8").digest("hex").slice(0, 16);
|
|
16
|
-
const deriveName = (id) => {
|
|
17
|
-
return id.slice(id.lastIndexOf("/") + 1).replace(/\.tsx$/, "");
|
|
18
|
-
};
|
|
19
|
-
const kindForId = (id) => id.endsWith(".tsx") ? "tsx" : void 0;
|
|
20
|
-
const within = (child, parent) => child === parent || child.startsWith(parent + sep);
|
|
21
|
-
const flattenCompiledDiagnostics = (template) => {
|
|
22
|
-
return [...template.diagnostics, ...template.compiled.diagnostics ?? []];
|
|
23
|
-
};
|
|
24
|
-
const diagnostic = (file, severity, code, message, span) => ({
|
|
25
|
-
file,
|
|
26
|
-
severity,
|
|
27
|
-
code,
|
|
28
|
-
message,
|
|
29
|
-
span
|
|
30
|
-
});
|
|
31
|
-
/** A catalog finding, said in the language the email document reports findings in. */
|
|
32
|
-
const asEmailDiagnostic = (item) => ({
|
|
33
|
-
code: item.code,
|
|
34
|
-
severity: item.severity,
|
|
35
|
-
message: item.message,
|
|
36
|
-
origins: []
|
|
37
|
-
});
|
|
38
|
-
/**
|
|
39
|
-
* Enforces the TSX file contract for an SML template after Vite evaluates the
|
|
40
|
-
* module. Named exports are deliberately never inspected or invoked; only
|
|
41
|
-
* `default` can be a template.
|
|
42
|
-
*/
|
|
43
|
-
const evaluateDefaultExport = (id, name, moduleExports) => {
|
|
44
|
-
const template = emitModulePreview(name, moduleExports).templates[0];
|
|
45
|
-
if (template === void 0) return { kind: "helper" };
|
|
46
|
-
if (!template.ok || template.source === void 0 || template.channel === "unknown") {
|
|
47
|
-
const problems = flattenCompiledDiagnostics(template);
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
diagnostics: problems.length > 0 ? problems.map((problem) => diagnostic(id, "error", problem.code, problem.message, problem.span)) : [diagnostic(id, "error", "invalid-default-export", "The default-exported template could not emit valid SML.")]
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
ok: true,
|
|
55
|
-
template: {
|
|
56
|
-
channel: template.channel,
|
|
57
|
-
source: template.source
|
|
58
|
-
},
|
|
59
|
-
variables: template.variables.map((variable) => ({
|
|
60
|
-
name: variable.name,
|
|
61
|
-
required: variable.required
|
|
62
|
-
}))
|
|
63
|
-
};
|
|
64
|
-
};
|
|
65
|
-
/** Two builds of one document differ when what the editor shows differs. */
|
|
66
|
-
const moved = (before, entry) => {
|
|
67
|
-
if (before === void 0) return true;
|
|
68
|
-
if (before.rev !== entry.rev || before.authoredSource !== entry.authoredSource) return true;
|
|
69
|
-
if (before.content.channel !== entry.content.channel) return true;
|
|
70
|
-
return before.content.channel === "email" && entry.content.channel === "email" && before.content.fixture !== entry.content.fixture;
|
|
71
|
-
};
|
|
72
|
-
var EditorFileStore = class {
|
|
73
|
-
dir;
|
|
74
|
-
projectRoot;
|
|
75
|
-
evaluator;
|
|
76
|
-
realDir;
|
|
77
|
-
loaded = false;
|
|
78
|
-
mutationQueue = Promise.resolve();
|
|
79
|
-
serialize(operation) {
|
|
80
|
-
const running = this.mutationQueue.then(operation, operation);
|
|
81
|
-
this.mutationQueue = running.catch(() => void 0);
|
|
82
|
-
return running;
|
|
83
|
-
}
|
|
84
|
-
initialLoad;
|
|
85
|
-
entries = /* @__PURE__ */ new Map();
|
|
86
|
-
diagnostics = /* @__PURE__ */ new Map();
|
|
87
|
-
session = /* @__PURE__ */ new Map();
|
|
88
|
-
listeners = /* @__PURE__ */ new Set();
|
|
89
|
-
catalogListeners = /* @__PURE__ */ new Set();
|
|
90
|
-
constructor(templatesDir, options = {}) {
|
|
91
|
-
this.dir = templatesDir;
|
|
92
|
-
this.projectRoot = options.projectRoot ?? templatesDir;
|
|
93
|
-
this.evaluator = options.evaluator;
|
|
94
|
-
}
|
|
95
|
-
async resolvePath(id) {
|
|
96
|
-
if (kindForId(id) === void 0 || id.includes("\0")) return void 0;
|
|
97
|
-
const full = resolve(this.dir, id);
|
|
98
|
-
if (!within(full, this.dir)) return void 0;
|
|
99
|
-
const realDir = await this.resolveRealDir();
|
|
100
|
-
if (realDir === void 0) return void 0;
|
|
101
|
-
const real = await realpath(full).catch(() => void 0);
|
|
102
|
-
if (real === void 0 || !within(real, realDir)) return void 0;
|
|
103
|
-
return real;
|
|
104
|
-
}
|
|
105
|
-
async resolveRealDir() {
|
|
106
|
-
if (this.realDir === void 0) this.realDir = await realpath(this.dir).catch(() => void 0);
|
|
107
|
-
return this.realDir;
|
|
108
|
-
}
|
|
109
|
-
idFor(path) {
|
|
110
|
-
const full = isAbsolute(path) ? path : join(this.dir, path);
|
|
111
|
-
if (!within(full, this.dir)) return void 0;
|
|
112
|
-
const rel = full.slice(this.dir.length + 1).split("\\").join("/");
|
|
113
|
-
return kindForId(rel) === void 0 ? void 0 : rel;
|
|
114
|
-
}
|
|
115
|
-
async load(id, previous, fixture = this.session.get(id)?.fixture) {
|
|
116
|
-
const path = await this.resolvePath(id);
|
|
117
|
-
if (path === void 0) return { diagnostics: [diagnostic(id, "error", "template-unavailable", "Template file is missing or resolves outside the configured templates directory.")] };
|
|
118
|
-
const authoredSource = await readFile(path, "utf8").catch(() => void 0);
|
|
119
|
-
if (authoredSource === void 0) return { diagnostics: [diagnostic(id, "error", "template-read-error", "Template file could not be read.")] };
|
|
120
|
-
const name = this.session.get(id)?.name ?? deriveName(id);
|
|
121
|
-
if (this.evaluator === void 0) return {
|
|
122
|
-
authoredSource,
|
|
123
|
-
diagnostics: [diagnostic(id, "error", "tsx-evaluator-unavailable", "TSX templates require the Vite evaluator.")]
|
|
124
|
-
};
|
|
125
|
-
const evaluated = await this.evaluator.evaluate({
|
|
126
|
-
path,
|
|
127
|
-
id,
|
|
128
|
-
name,
|
|
129
|
-
fixture
|
|
130
|
-
}).catch((cause) => ({
|
|
131
|
-
ok: false,
|
|
132
|
-
diagnostics: [diagnostic(id, "error", "module-load-error", cause instanceof Error ? cause.message : String(cause))]
|
|
133
|
-
}));
|
|
134
|
-
if ("kind" in evaluated) return {
|
|
135
|
-
ignored: true,
|
|
136
|
-
authoredSource,
|
|
137
|
-
diagnostics: []
|
|
138
|
-
};
|
|
139
|
-
if (!evaluated.ok) {
|
|
140
|
-
if (previous?.content.channel !== "email") return {
|
|
141
|
-
authoredSource,
|
|
142
|
-
diagnostics: evaluated.diagnostics
|
|
143
|
-
};
|
|
144
|
-
const diagnostics = evaluated.diagnostics.map(asEmailDiagnostic);
|
|
145
|
-
return {
|
|
146
|
-
entry: {
|
|
147
|
-
template: {
|
|
148
|
-
...previous.template,
|
|
149
|
-
name,
|
|
150
|
-
stale: false
|
|
151
|
-
},
|
|
152
|
-
content: {
|
|
153
|
-
channel: "email",
|
|
154
|
-
fixtures: [],
|
|
155
|
-
fixture: null,
|
|
156
|
-
render: null,
|
|
157
|
-
diagnostics,
|
|
158
|
-
incompatibilities: []
|
|
159
|
-
},
|
|
160
|
-
authoredSource,
|
|
161
|
-
rev: hashSource(authoredSource + JSON.stringify(diagnostics)),
|
|
162
|
-
variables: []
|
|
163
|
-
},
|
|
164
|
-
authoredSource,
|
|
165
|
-
diagnostics: evaluated.diagnostics
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
const built = evaluated.template;
|
|
169
|
-
const rev = hashSource(authoredSource + (built.channel === "email" ? built.identity : built.source));
|
|
170
|
-
const content = built.channel === "email" ? {
|
|
171
|
-
channel: "email",
|
|
172
|
-
fixtures: built.fixtures,
|
|
173
|
-
fixture: built.fixture,
|
|
174
|
-
render: built.render === null ? null : {
|
|
175
|
-
revision: rev,
|
|
176
|
-
...built.render
|
|
177
|
-
},
|
|
178
|
-
diagnostics: built.diagnostics,
|
|
179
|
-
incompatibilities: built.incompatibilities
|
|
180
|
-
} : {
|
|
181
|
-
channel: built.channel,
|
|
182
|
-
source: built.source
|
|
183
|
-
};
|
|
184
|
-
return {
|
|
185
|
-
entry: {
|
|
186
|
-
template: {
|
|
187
|
-
id,
|
|
188
|
-
name,
|
|
189
|
-
channel: built.channel,
|
|
190
|
-
sourceKind: "tsx",
|
|
191
|
-
stale: false
|
|
192
|
-
},
|
|
193
|
-
content,
|
|
194
|
-
authoredSource,
|
|
195
|
-
rev,
|
|
196
|
-
variables: evaluated.variables
|
|
197
|
-
},
|
|
198
|
-
diagnostics: evaluated.diagnostics ?? []
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
catalogSnapshot() {
|
|
202
|
-
return {
|
|
203
|
-
templates: [...this.entries.values()].map((entry) => entry.template).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
204
|
-
diagnostics: [...this.diagnostics.values()].flat()
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
ensureLoaded() {
|
|
208
|
-
if (this.loaded) return Promise.resolve(this.catalogSnapshot());
|
|
209
|
-
if (this.initialLoad !== void 0) return this.initialLoad;
|
|
210
|
-
const loading = this.rebuild(false);
|
|
211
|
-
this.initialLoad = loading;
|
|
212
|
-
loading.then(() => {
|
|
213
|
-
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
214
|
-
}, () => {
|
|
215
|
-
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
216
|
-
});
|
|
217
|
-
return loading;
|
|
218
|
-
}
|
|
219
|
-
async rebuild(emitDocuments) {
|
|
220
|
-
const previous = new Map(this.entries);
|
|
221
|
-
await this.evaluator?.refresh();
|
|
222
|
-
const files = await discoverTemplateFiles(this.dir);
|
|
223
|
-
const loaded = await Promise.all(files.map(async (file) => [file.id, await this.load(file.id, previous.get(file.id))]));
|
|
224
|
-
this.entries.clear();
|
|
225
|
-
this.diagnostics.clear();
|
|
226
|
-
for (const [id, result] of loaded) {
|
|
227
|
-
if (result.entry !== void 0) this.entries.set(id, result.entry);
|
|
228
|
-
else if (!result.ignored && previous.has(id)) {
|
|
229
|
-
const lastGood = previous.get(id);
|
|
230
|
-
const stale = {
|
|
231
|
-
...lastGood,
|
|
232
|
-
template: {
|
|
233
|
-
...lastGood.template,
|
|
234
|
-
stale: true
|
|
235
|
-
},
|
|
236
|
-
authoredSource: result.authoredSource ?? lastGood.authoredSource
|
|
237
|
-
};
|
|
238
|
-
this.entries.set(id, stale);
|
|
239
|
-
}
|
|
240
|
-
if (result.diagnostics.length > 0) this.diagnostics.set(id, result.diagnostics);
|
|
241
|
-
}
|
|
242
|
-
this.loaded = true;
|
|
243
|
-
if (emitDocuments) {
|
|
244
|
-
for (const [id, entry] of this.entries) if (moved(previous.get(id), entry)) this.publishEntry(id, entry);
|
|
245
|
-
}
|
|
246
|
-
for (const listener of this.catalogListeners) listener();
|
|
247
|
-
return this.catalogSnapshot();
|
|
248
|
-
}
|
|
249
|
-
async catalog() {
|
|
250
|
-
return this.ensureLoaded();
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Re-evaluate discovery and every TSX entry after template/dependency/theme HMR.
|
|
254
|
-
* `changed` names the file whose change asked for this refresh.
|
|
255
|
-
*
|
|
256
|
-
* This store writes template files itself, so an editor save comes back as a
|
|
257
|
-
* change event indistinguishable from an author's own edit in another program.
|
|
258
|
-
* Re-running the project for it costs a second full evaluation — the price of
|
|
259
|
-
* one save is the whole catalog, twice — and republishes the document with no
|
|
260
|
-
* client attached, which is exactly how a foreign edit reaches the editor. A
|
|
261
|
-
* changed file that still holds the bytes its current entry was built from is
|
|
262
|
-
* already evaluated, so there is nothing to publish and nothing to rebuild.
|
|
263
|
-
* Every other path, and every file whose bytes have moved, is a real change.
|
|
264
|
-
*/
|
|
265
|
-
async refreshAll(options = {}) {
|
|
266
|
-
const loading = this.initialLoad;
|
|
267
|
-
if (loading !== void 0) await loading;
|
|
268
|
-
else if (!this.loaded) return this.ensureLoaded();
|
|
269
|
-
return this.serialize(async () => await this.alreadyBuilt(options.changed) ? this.catalogSnapshot() : this.rebuild(options.documents ?? true));
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* True when `path` is a discovered entry whose file still holds the exact bytes
|
|
273
|
-
* that entry was evaluated from. Read inside the mutation queue, so a save's own
|
|
274
|
-
* rebuild has already landed by the time its change event asks.
|
|
275
|
-
*/
|
|
276
|
-
async alreadyBuilt(path) {
|
|
277
|
-
if (path === void 0) return false;
|
|
278
|
-
const id = this.idFor(path);
|
|
279
|
-
if (id === void 0) return false;
|
|
280
|
-
const entry = this.entries.get(id);
|
|
281
|
-
if (entry === void 0 || entry.template.stale || entry.authoredSource === void 0) return false;
|
|
282
|
-
const resolved = await this.resolvePath(id);
|
|
283
|
-
if (resolved === void 0) return false;
|
|
284
|
-
return await readFile(resolved, "utf8").catch(() => void 0) === entry.authoredSource;
|
|
285
|
-
}
|
|
286
|
-
async list() {
|
|
287
|
-
return (await this.catalog()).templates;
|
|
288
|
-
}
|
|
289
|
-
async open(id) {
|
|
290
|
-
if (!this.loaded) await this.ensureLoaded();
|
|
291
|
-
const entry = this.entries.get(id);
|
|
292
|
-
if (entry === void 0) return void 0;
|
|
293
|
-
const meta = this.session.get(id);
|
|
294
|
-
return {
|
|
295
|
-
id: null,
|
|
296
|
-
name: meta?.name ?? entry.template.name,
|
|
297
|
-
rev: entry.rev,
|
|
298
|
-
variables: entry.variables,
|
|
299
|
-
metadata: meta?.metadata ?? {},
|
|
300
|
-
...entry.content,
|
|
301
|
-
origin: {
|
|
302
|
-
kind: "tsx",
|
|
303
|
-
file: relative(this.projectRoot, resolve(this.dir, id)).split(sep).join("/"),
|
|
304
|
-
authoredSource: entry.authoredSource ?? ""
|
|
305
|
-
},
|
|
306
|
-
access: entry.content.channel === "email" ? { kind: "editable" } : {
|
|
307
|
-
kind: "readonly",
|
|
308
|
-
reason: "This channel has no local source-save adapter."
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Serialize editor saves and atomically replace the complete entry after a fresh disk check.
|
|
314
|
-
* Arbitrary external writers do not participate in this queue: the final check and rename
|
|
315
|
-
* are not a filesystem compare-and-swap, so authors must resolve overlapping saves externally.
|
|
316
|
-
*/
|
|
317
|
-
async saveSource(id, baseRev, authoredSource, client) {
|
|
318
|
-
if (Buffer.byteLength(authoredSource, "utf8") > 1048576) return {
|
|
319
|
-
ok: false,
|
|
320
|
-
kind: "refused",
|
|
321
|
-
error: EDITOR_SOURCE_TOO_LARGE_MESSAGE
|
|
322
|
-
};
|
|
323
|
-
await this.ensureLoaded();
|
|
324
|
-
return this.serialize(async () => {
|
|
325
|
-
const entry = this.entries.get(id);
|
|
326
|
-
const path = await this.resolvePath(id);
|
|
327
|
-
if (entry === void 0 || path === void 0) return {
|
|
328
|
-
ok: false,
|
|
329
|
-
kind: "missing",
|
|
330
|
-
error: "Template not found."
|
|
331
|
-
};
|
|
332
|
-
if (entry.content.channel !== "email") return {
|
|
333
|
-
ok: false,
|
|
334
|
-
kind: "refused",
|
|
335
|
-
error: "Only email source is editable here."
|
|
336
|
-
};
|
|
337
|
-
const disk = await readFile(path, "utf8");
|
|
338
|
-
if (entry.rev !== baseRev || disk !== entry.authoredSource) {
|
|
339
|
-
await this.rebuild(true);
|
|
340
|
-
return {
|
|
341
|
-
ok: false,
|
|
342
|
-
kind: "conflict",
|
|
343
|
-
error: "Source changed on disk. Reload before saving.",
|
|
344
|
-
actualRev: this.entries.get(id)?.rev ?? entry.rev
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
348
|
-
const mode = (await stat(path)).mode;
|
|
349
|
-
const file = await open(temporary, "wx", mode);
|
|
350
|
-
try {
|
|
351
|
-
await file.writeFile(authoredSource, "utf8");
|
|
352
|
-
await file.chmod(mode);
|
|
353
|
-
await file.sync();
|
|
354
|
-
await file.close();
|
|
355
|
-
if (await readFile(path, "utf8") !== disk) {
|
|
356
|
-
await this.rebuild(true);
|
|
357
|
-
return {
|
|
358
|
-
ok: false,
|
|
359
|
-
kind: "conflict",
|
|
360
|
-
error: "Source changed on disk. Reload before saving.",
|
|
361
|
-
actualRev: this.entries.get(id)?.rev ?? entry.rev
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
await rename(temporary, path);
|
|
365
|
-
} finally {
|
|
366
|
-
await file.close().finally(() => rm(temporary, { force: true }));
|
|
367
|
-
}
|
|
368
|
-
const previous = new Map(this.entries);
|
|
369
|
-
await this.rebuild(false);
|
|
370
|
-
for (const [changedId, changed] of this.entries) if (moved(previous.get(changedId), changed)) this.publishEntry(changedId, changed, changedId === id ? client : void 0);
|
|
371
|
-
const saved = this.entries.get(id);
|
|
372
|
-
return saved === void 0 ? {
|
|
373
|
-
ok: false,
|
|
374
|
-
kind: "missing",
|
|
375
|
-
error: "The saved entry is no longer discoverable."
|
|
376
|
-
} : {
|
|
377
|
-
ok: true,
|
|
378
|
-
rev: saved.rev
|
|
379
|
-
};
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Show another declared fixture of an email document. It re-renders the same
|
|
384
|
-
* build, so the revision does not move; the resulting document reaches every
|
|
385
|
-
* watcher on the change stream, attributed to the client that asked.
|
|
386
|
-
*
|
|
387
|
-
* It runs in the mutation queue for the same reason a save does: the viewed
|
|
388
|
-
* fixture is part of the entry, and a rebuild that straddled the switch would
|
|
389
|
-
* take its own snapshot from before it and its evaluation from after it, then
|
|
390
|
-
* publish the difference with no client — the reader's own fixture switch,
|
|
391
|
-
* returned to them as somebody else's edit.
|
|
392
|
-
*/
|
|
393
|
-
async viewFixture(id, fixture, client) {
|
|
394
|
-
if (!this.loaded) await this.ensureLoaded();
|
|
395
|
-
return this.serialize(async () => {
|
|
396
|
-
const entry = this.entries.get(id);
|
|
397
|
-
if (entry === void 0) return {
|
|
398
|
-
ok: false,
|
|
399
|
-
kind: "missing",
|
|
400
|
-
error: "template not found"
|
|
401
|
-
};
|
|
402
|
-
if (entry.content.channel !== "email") return {
|
|
403
|
-
ok: false,
|
|
404
|
-
kind: "refused",
|
|
405
|
-
error: `A ${entry.content.channel} template renders no fixture.`
|
|
406
|
-
};
|
|
407
|
-
if (!entry.content.fixtures.includes(fixture)) return {
|
|
408
|
-
ok: false,
|
|
409
|
-
kind: "refused",
|
|
410
|
-
error: `Template declares no fixture named ${JSON.stringify(fixture)}.`
|
|
411
|
-
};
|
|
412
|
-
const meta = this.session.get(id);
|
|
413
|
-
const loaded = await this.load(id, entry, fixture);
|
|
414
|
-
if (loaded.entry === void 0) {
|
|
415
|
-
if (loaded.diagnostics.length > 0) this.diagnostics.set(id, loaded.diagnostics);
|
|
416
|
-
return {
|
|
417
|
-
ok: false,
|
|
418
|
-
kind: "refused",
|
|
419
|
-
error: `Template ${id} could not be re-rendered.`
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
this.session.set(id, {
|
|
423
|
-
...meta,
|
|
424
|
-
metadata: meta?.metadata ?? {},
|
|
425
|
-
fixture
|
|
426
|
-
});
|
|
427
|
-
this.entries.set(id, loaded.entry);
|
|
428
|
-
this.publishEntry(id, loaded.entry, client);
|
|
429
|
-
return { ok: true };
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
publishEntry(id, entry, client) {
|
|
433
|
-
const change = {
|
|
434
|
-
id,
|
|
435
|
-
rev: entry.rev,
|
|
436
|
-
authoredSource: entry.authoredSource,
|
|
437
|
-
...client === void 0 ? {} : { sourceClient: client },
|
|
438
|
-
...entry.content
|
|
439
|
-
};
|
|
440
|
-
for (const listener of this.listeners) listener(change);
|
|
441
|
-
}
|
|
442
|
-
subscribe(listener) {
|
|
443
|
-
this.listeners.add(listener);
|
|
444
|
-
return () => this.listeners.delete(listener);
|
|
445
|
-
}
|
|
446
|
-
subscribeCatalog(listener) {
|
|
447
|
-
this.catalogListeners.add(listener);
|
|
448
|
-
return () => this.catalogListeners.delete(listener);
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
//#endregion
|
|
452
|
-
//#region src/internal/guards.ts
|
|
453
|
-
/** A non-null, non-array object, narrowed for property access. */
|
|
454
|
-
const isObject = (input) => typeof input === "object" && input !== null && !Array.isArray(input);
|
|
455
|
-
//#endregion
|
|
456
|
-
//#region src/editor.ts
|
|
457
|
-
const DEFAULT_ROUTE = "/";
|
|
458
|
-
const MODULE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
459
|
-
".cjs",
|
|
460
|
-
".css",
|
|
461
|
-
".cts",
|
|
462
|
-
".js",
|
|
463
|
-
".json",
|
|
464
|
-
".jsx",
|
|
465
|
-
".mjs",
|
|
466
|
-
".mts",
|
|
467
|
-
".ts",
|
|
468
|
-
".tsx"
|
|
469
|
-
]);
|
|
470
|
-
const packagedEditorDir = fileURLToPath(new URL("./editor/", import.meta.url));
|
|
471
|
-
const EDITOR_DIR = existsSync(packagedEditorDir) ? packagedEditorDir : fileURLToPath(new URL("../dist/editor/", import.meta.url));
|
|
472
|
-
const MIME = {
|
|
473
|
-
".html": "text/html; charset=utf-8",
|
|
474
|
-
".js": "text/javascript; charset=utf-8",
|
|
475
|
-
".css": "text/css; charset=utf-8",
|
|
476
|
-
".svg": "image/svg+xml",
|
|
477
|
-
".json": "application/json",
|
|
478
|
-
".map": "application/json",
|
|
479
|
-
".ico": "image/x-icon",
|
|
480
|
-
".png": "image/png",
|
|
481
|
-
".woff2": "font/woff2"
|
|
482
|
-
};
|
|
483
|
-
/** Normalize + validate the mount route: absolute, root allowed, no trailing slash. */
|
|
484
|
-
const normalizeRoute = (raw) => {
|
|
485
|
-
const route = raw === "/" ? "/" : raw.replace(/\/+$/, "");
|
|
486
|
-
if (!route.startsWith("/") || route === "" || route.includes("//") || route.includes("?") || route.includes("#")) throw new Error(`samvaEditor: \`route\` must be an absolute URL path (received ${JSON.stringify(raw)})`);
|
|
487
|
-
return route;
|
|
488
|
-
};
|
|
489
|
-
const sendJson = (res, status, body) => {
|
|
490
|
-
res.statusCode = status;
|
|
491
|
-
res.setHeader("content-type", "application/json");
|
|
492
|
-
res.end(JSON.stringify(body));
|
|
493
|
-
};
|
|
494
|
-
/** Serve a file under the built editor dir; returns false (untouched res) if absent/outside. */
|
|
495
|
-
const serveStatic = async (res, filePath) => {
|
|
496
|
-
const resolved = resolve(filePath);
|
|
497
|
-
const root = EDITOR_DIR.replace(/\/$/, "");
|
|
498
|
-
if (resolved !== root && !resolved.startsWith(`${root}/`)) return false;
|
|
499
|
-
const body = await readFile(resolved).catch(() => void 0);
|
|
500
|
-
if (body === void 0) return false;
|
|
501
|
-
res.statusCode = 200;
|
|
502
|
-
res.setHeader("content-type", MIME[extname(resolved)] ?? "application/octet-stream");
|
|
503
|
-
res.end(body);
|
|
504
|
-
return true;
|
|
505
|
-
};
|
|
506
|
-
const VITE_INTERNAL_PREFIXES = [
|
|
507
|
-
"/@vite/",
|
|
508
|
-
"/@id/",
|
|
509
|
-
"/@fs/",
|
|
510
|
-
"/@react-refresh",
|
|
511
|
-
"/__vite",
|
|
512
|
-
"/node_modules/.vite/"
|
|
513
|
-
];
|
|
514
|
-
const isViteInternalPath = (pathname) => VITE_INTERNAL_PREFIXES.some((prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix));
|
|
515
|
-
const hasFileExtension = (pathname) => extname(pathname) !== "";
|
|
516
|
-
const underRoute = (route, pathname) => route === "/" || pathname === route || pathname.startsWith(`${route}/`);
|
|
517
|
-
const childRoute = (route, child) => route === "/" ? `/${child}` : `${route}/${child}`;
|
|
518
|
-
const isLoopbackHostname = (hostname) => {
|
|
519
|
-
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
520
|
-
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true;
|
|
521
|
-
if (isIP(normalized) !== 4) return false;
|
|
522
|
-
return normalized.split(".")[0] === "127";
|
|
523
|
-
};
|
|
524
|
-
const isLoopbackAddress = (address) => {
|
|
525
|
-
if (address === void 0) return false;
|
|
526
|
-
const normalized = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
527
|
-
return isLoopbackHostname(normalized);
|
|
528
|
-
};
|
|
529
|
-
const isTrustedSourceSave = (req) => {
|
|
530
|
-
if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
|
|
531
|
-
const origin = req.headers.origin;
|
|
532
|
-
if (origin === void 0) return false;
|
|
533
|
-
try {
|
|
534
|
-
const parsed = new URL(origin);
|
|
535
|
-
return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username === "" && parsed.password === "" && isLoopbackHostname(parsed.hostname);
|
|
536
|
-
} catch {
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
};
|
|
540
|
-
const MAX_BODY_BYTES = 6295552;
|
|
541
|
-
var EditorBodyTooLarge = class extends Error {};
|
|
542
|
-
const readBody = (req) => new Promise((done, fail) => {
|
|
543
|
-
const chunks = [];
|
|
544
|
-
let bytes = 0;
|
|
545
|
-
req.on("data", (chunk) => {
|
|
546
|
-
bytes += chunk.length;
|
|
547
|
-
if (bytes > MAX_BODY_BYTES) {
|
|
548
|
-
chunks.length = 0;
|
|
549
|
-
fail(new EditorBodyTooLarge("Request body exceeds the editor limit."));
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
chunks.push(chunk);
|
|
553
|
-
});
|
|
554
|
-
req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
|
|
555
|
-
req.on("error", fail);
|
|
556
|
-
req.on("aborted", () => fail(/* @__PURE__ */ new Error("Request body aborted.")));
|
|
557
|
-
});
|
|
558
|
-
const createSseClient = (res, docId, clientId) => {
|
|
559
|
-
let pending;
|
|
560
|
-
let draining = false;
|
|
561
|
-
const flush = () => {
|
|
562
|
-
if (pending === void 0) {
|
|
563
|
-
draining = false;
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
const frame = pending;
|
|
567
|
-
pending = void 0;
|
|
568
|
-
if (res.write(frame)) flush();
|
|
569
|
-
else draining = true;
|
|
570
|
-
};
|
|
571
|
-
res.on("drain", flush);
|
|
572
|
-
return {
|
|
573
|
-
res,
|
|
574
|
-
docId,
|
|
575
|
-
clientId,
|
|
576
|
-
push(payload) {
|
|
577
|
-
const frame = `event: samva:change\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
578
|
-
if (draining) {
|
|
579
|
-
pending = frame;
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
if (!res.write(frame)) draining = true;
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
};
|
|
586
|
-
/**
|
|
587
|
-
* The dev server is the origin, and it is not https, so a local render points
|
|
588
|
-
* at its asset route by path. Every render here is a preview or the input to a
|
|
589
|
-
* finding — none of it is delivered — and the base a publication serves from is
|
|
590
|
-
* a build input rather than something the author wrote, so nothing is hidden by
|
|
591
|
-
* accepting one here.
|
|
592
|
-
*/
|
|
593
|
-
const LOCAL_RENDER = { relativeUrls: true };
|
|
594
|
-
/**
|
|
595
|
-
* A template's input contract is a JSON Schema, so its variables are that
|
|
596
|
-
* schema's top-level properties. Nested shapes are not flattened into names —
|
|
597
|
-
* a template's input is a document, not a bag of placeholders.
|
|
598
|
-
*/
|
|
599
|
-
const variablesOf = (definition) => {
|
|
600
|
-
const properties = definition.schema.schema["properties"];
|
|
601
|
-
if (!isObject(properties)) return [];
|
|
602
|
-
const required = definition.schema.schema["required"];
|
|
603
|
-
const names = Array.isArray(required) ? required : [];
|
|
604
|
-
return Object.entries(properties).map(([name, property]) => {
|
|
605
|
-
const examples = isObject(property) ? property["examples"] : void 0;
|
|
606
|
-
const example = Array.isArray(examples) ? examples[0] : void 0;
|
|
607
|
-
return {
|
|
608
|
-
name,
|
|
609
|
-
required: names.includes(name),
|
|
610
|
-
...typeof example === "string" ? { sample: example } : {}
|
|
611
|
-
};
|
|
612
|
-
});
|
|
613
|
-
};
|
|
614
|
-
/** One executed render of the viewed fixture, stamped so a click resolves to one element. */
|
|
615
|
-
const previewRender = (entry, fixture) => {
|
|
616
|
-
try {
|
|
617
|
-
const rendered = entry.loaded.renderEmail(entry.definition, entry.definition.fixtures[fixture], {
|
|
618
|
-
...LOCAL_RENDER,
|
|
619
|
-
instancePaths: true
|
|
620
|
-
});
|
|
621
|
-
return { render: {
|
|
622
|
-
subject: rendered.subject,
|
|
623
|
-
...rendered.preheader === void 0 ? {} : { preheader: rendered.preheader },
|
|
624
|
-
html: rendered.html,
|
|
625
|
-
text: rendered.text,
|
|
626
|
-
selections: emailSelections(rendered.positions)
|
|
627
|
-
} };
|
|
628
|
-
} catch {
|
|
629
|
-
return { render: null };
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
/** An entry that is a template but did not build: a document state, and a catalog finding. */
|
|
633
|
-
const emailBuildFailure = (id, message) => ({
|
|
634
|
-
ok: true,
|
|
635
|
-
variables: [],
|
|
636
|
-
diagnostics: [{
|
|
637
|
-
file: id,
|
|
638
|
-
severity: "error",
|
|
639
|
-
code: "email-build-error",
|
|
640
|
-
message
|
|
641
|
-
}],
|
|
642
|
-
template: {
|
|
643
|
-
channel: "email",
|
|
644
|
-
fixtures: [],
|
|
645
|
-
fixture: null,
|
|
646
|
-
render: null,
|
|
647
|
-
diagnostics: [{
|
|
648
|
-
code: "email-build-error",
|
|
649
|
-
severity: "error",
|
|
650
|
-
message,
|
|
651
|
-
origins: []
|
|
652
|
-
}],
|
|
653
|
-
incompatibilities: [],
|
|
654
|
-
identity: JSON.stringify({ error: message })
|
|
655
|
-
}
|
|
656
|
-
});
|
|
657
|
-
/** Compile, evaluate and render one email entry through the compiler boundary. */
|
|
658
|
-
const evaluateEmailEntry = async (input) => {
|
|
659
|
-
const loaded = await input.renderer.load(input.files, input.entryPath, input.entryPath).then((value) => ({
|
|
660
|
-
ok: true,
|
|
661
|
-
value
|
|
662
|
-
}), (cause) => ({
|
|
663
|
-
ok: false,
|
|
664
|
-
message: cause instanceof Error ? cause.message : String(cause)
|
|
665
|
-
}));
|
|
666
|
-
if (!loaded.ok) return emailBuildFailure(input.id, loaded.message);
|
|
667
|
-
const definition = loaded.value.template;
|
|
668
|
-
if (!isTemplateDefinition(definition)) return emailBuildFailure(input.id, `${input.id} default-exports something that is not a template definition.`);
|
|
669
|
-
for (const asset of loaded.value.compiled.assets) {
|
|
670
|
-
const bytes = input.files[asset.path];
|
|
671
|
-
if (bytes !== void 0 && typeof bytes !== "string") input.assets.set(asset.fileName, {
|
|
672
|
-
contentType: asset.contentType,
|
|
673
|
-
bytes
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
const entry = {
|
|
677
|
-
id: definition.id,
|
|
678
|
-
entryPath: input.entryPath,
|
|
679
|
-
definition,
|
|
680
|
-
loaded: loaded.value
|
|
681
|
-
};
|
|
682
|
-
const fixtures = Object.keys(definition.fixtures);
|
|
683
|
-
const fixture = input.fixture !== void 0 && fixtures.includes(input.fixture) ? input.fixture : fixtures[0] ?? null;
|
|
684
|
-
const delivered = renderEmailFixtures(entry, LOCAL_RENDER);
|
|
685
|
-
const diagnostics = [...loaded.value.compiled.diagnostics, ...delivered.flatMap((result) => result.diagnostics.map((diagnostic) => ({
|
|
686
|
-
...diagnostic,
|
|
687
|
-
fixtures: [result.fixture]
|
|
688
|
-
})))];
|
|
689
|
-
const incompatibilities = delivered.flatMap((result) => result.rendered === void 0 ? [] : checkEmailCompatibility({
|
|
690
|
-
html: result.rendered.html,
|
|
691
|
-
fixture: result.fixture,
|
|
692
|
-
positions: result.rendered.positions
|
|
693
|
-
}));
|
|
694
|
-
return {
|
|
695
|
-
ok: true,
|
|
696
|
-
variables: variablesOf(definition),
|
|
697
|
-
template: {
|
|
698
|
-
channel: "email",
|
|
699
|
-
fixtures,
|
|
700
|
-
fixture,
|
|
701
|
-
...fixture === null ? { render: null } : previewRender(entry, fixture),
|
|
702
|
-
diagnostics,
|
|
703
|
-
incompatibilities,
|
|
704
|
-
identity: JSON.stringify({
|
|
705
|
-
diagnostics,
|
|
706
|
-
incompatibilities,
|
|
707
|
-
fixtures: delivered.map((result) => ({
|
|
708
|
-
fixture: result.fixture,
|
|
709
|
-
subject: result.rendered?.subject ?? null,
|
|
710
|
-
preheader: result.rendered?.preheader ?? null,
|
|
711
|
-
html: result.rendered?.html ?? null,
|
|
712
|
-
text: result.rendered?.text ?? null
|
|
713
|
-
}))
|
|
714
|
-
})
|
|
715
|
-
}
|
|
716
|
-
};
|
|
717
|
-
};
|
|
718
|
-
const samvaEditor = (options = {}) => {
|
|
719
|
-
const route = normalizeRoute(options.route ?? DEFAULT_ROUTE);
|
|
720
|
-
const apiPrefix = childRoute(route, "api");
|
|
721
|
-
let root = process.cwd();
|
|
722
|
-
let templatesDir = resolve(root, options.templatesDir ?? "emails");
|
|
723
|
-
let themePath = resolveThemePath(root, options.theme);
|
|
724
|
-
let store = new EditorFileStore(templatesDir, { projectRoot: root });
|
|
725
|
-
let authenticated = false;
|
|
726
|
-
const clients = /* @__PURE__ */ new Set();
|
|
727
|
-
const catalogClients = /* @__PURE__ */ new Set();
|
|
728
|
-
let refreshTail = Promise.resolve();
|
|
729
|
-
const queueStoreRefresh = (server, run) => {
|
|
730
|
-
refreshTail = refreshTail.then(run, run).catch((cause) => {
|
|
731
|
-
server.config.logger.error(`samvaEditor refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
732
|
-
});
|
|
733
|
-
return refreshTail;
|
|
734
|
-
};
|
|
735
|
-
return {
|
|
736
|
-
name: "samva-editor",
|
|
737
|
-
config() {
|
|
738
|
-
return {
|
|
739
|
-
oxc: { jsx: {
|
|
740
|
-
runtime: "automatic",
|
|
741
|
-
importSource: "@samva/markup"
|
|
742
|
-
} },
|
|
743
|
-
optimizeDeps: { exclude: ["@samva/markup"] },
|
|
744
|
-
server: {
|
|
745
|
-
host: "127.0.0.1",
|
|
746
|
-
allowedHosts: ["localhost", ".localhost"]
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
|
-
},
|
|
750
|
-
configResolved(resolved) {
|
|
751
|
-
root = resolved.root;
|
|
752
|
-
templatesDir = isAbsolute(options.templatesDir ?? "") ? options.templatesDir : resolve(root, options.templatesDir ?? "emails");
|
|
753
|
-
themePath = resolveThemePath(root, options.theme);
|
|
754
|
-
store = new EditorFileStore(templatesDir, { projectRoot: root });
|
|
755
|
-
const env = loadEnv(resolved.mode, root, "");
|
|
756
|
-
authenticated = Boolean(env.SAMVA_API_KEY);
|
|
757
|
-
},
|
|
758
|
-
async configureServer(server) {
|
|
759
|
-
const moduleRunner = createServerModuleRunner(server.environments.ssr, { hmr: false });
|
|
760
|
-
const assets = /* @__PURE__ */ new Map();
|
|
761
|
-
let themeCss;
|
|
762
|
-
let emailRenderer;
|
|
763
|
-
let emailRendererLoading;
|
|
764
|
-
const emailRendererFor = () => emailRendererLoading ??= createEmailRenderer({
|
|
765
|
-
assetBase: childRoute(apiPrefix, "assets"),
|
|
766
|
-
...themeCss === void 0 ? {} : { tailwind: { css: themeCss } }
|
|
767
|
-
}).then((renderer) => {
|
|
768
|
-
emailRenderer = renderer;
|
|
769
|
-
return renderer;
|
|
770
|
-
});
|
|
771
|
-
let projectFiles;
|
|
772
|
-
let emailRenderTail = Promise.resolve();
|
|
773
|
-
store = new EditorFileStore(templatesDir, {
|
|
774
|
-
projectRoot: root,
|
|
775
|
-
evaluator: {
|
|
776
|
-
refresh: async () => {
|
|
777
|
-
projectFiles = void 0;
|
|
778
|
-
const nextThemeCss = await readProjectThemeCss(themePath);
|
|
779
|
-
if (nextThemeCss !== themeCss) {
|
|
780
|
-
await emailRendererLoading?.then((renderer) => renderer.close());
|
|
781
|
-
emailRenderer = void 0;
|
|
782
|
-
emailRendererLoading = void 0;
|
|
783
|
-
themeCss = nextThemeCss;
|
|
784
|
-
}
|
|
785
|
-
emailRenderer?.invalidate();
|
|
786
|
-
},
|
|
787
|
-
evaluate: async ({ path, id, name, fixture }) => {
|
|
788
|
-
const moduleExports = await moduleRunner.import(path);
|
|
789
|
-
if (!isTemplateDefinition(moduleExports["default"])) return evaluateDefaultExport(id, name, moduleExports);
|
|
790
|
-
const render = async () => evaluateEmailEntry({
|
|
791
|
-
renderer: await emailRendererFor(),
|
|
792
|
-
files: await (projectFiles ??= readEmailProject(root)),
|
|
793
|
-
entryPath: relative(root, path).split(sep).join("/"),
|
|
794
|
-
assets,
|
|
795
|
-
id,
|
|
796
|
-
fixture
|
|
797
|
-
});
|
|
798
|
-
const pending = emailRenderTail.then(render, render);
|
|
799
|
-
emailRenderTail = pending;
|
|
800
|
-
return pending;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
server.watcher.add(templatesDir);
|
|
805
|
-
if (themePath !== void 0) server.watcher.add(themePath);
|
|
806
|
-
const refreshTheme = () => {
|
|
807
|
-
queueStoreRefresh(server, () => store.refreshAll({ documents: true }));
|
|
808
|
-
};
|
|
809
|
-
let burst = /* @__PURE__ */ new Set();
|
|
810
|
-
let burstShape = false;
|
|
811
|
-
let burstQueued = false;
|
|
812
|
-
const refreshTsxDependents = (path, shapeChange) => {
|
|
813
|
-
const ssr = server.environments.ssr;
|
|
814
|
-
if (ssr === void 0) return;
|
|
815
|
-
const changedModules = ssr.moduleGraph.getModulesByFile(path) ?? /* @__PURE__ */ new Set();
|
|
816
|
-
const directTemplate = path.endsWith(".tsx") && store.idFor(path) !== void 0;
|
|
817
|
-
const potentialModuleShapeChange = shapeChange && changedModules.size === 0 && MODULE_EXTENSIONS.has(extname(path));
|
|
818
|
-
if (!directTemplate && changedModules.size === 0 && !potentialModuleShapeChange) return;
|
|
819
|
-
burst.add(path);
|
|
820
|
-
burstShape ||= shapeChange;
|
|
821
|
-
if (burstQueued) return;
|
|
822
|
-
burstQueued = true;
|
|
823
|
-
queueStoreRefresh(server, async () => {
|
|
824
|
-
const paths = burst;
|
|
825
|
-
const shape = burstShape;
|
|
826
|
-
burst = /* @__PURE__ */ new Set();
|
|
827
|
-
burstShape = false;
|
|
828
|
-
burstQueued = false;
|
|
829
|
-
const invalidated = /* @__PURE__ */ new Set();
|
|
830
|
-
const collectImporters = (module) => {
|
|
831
|
-
if (invalidated.has(module)) return;
|
|
832
|
-
invalidated.add(module);
|
|
833
|
-
for (const importer of module.importers) collectImporters(importer);
|
|
834
|
-
};
|
|
835
|
-
for (const changed of paths) for (const module of ssr.moduleGraph.getModulesByFile(changed) ?? []) collectImporters(module);
|
|
836
|
-
if (shape) ssr.moduleGraph.invalidateAll();
|
|
837
|
-
else for (const module of invalidated) ssr.moduleGraph.invalidateModule(module);
|
|
838
|
-
moduleRunner.evaluatedModules.clear();
|
|
839
|
-
const only = paths.size === 1 ? [...paths][0] : void 0;
|
|
840
|
-
return store.refreshAll({
|
|
841
|
-
documents: true,
|
|
842
|
-
changed: only
|
|
843
|
-
});
|
|
844
|
-
});
|
|
845
|
-
};
|
|
846
|
-
const onChange = (path) => {
|
|
847
|
-
if (themePath !== void 0 && path === themePath) {
|
|
848
|
-
refreshTheme();
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
refreshTsxDependents(path, false);
|
|
852
|
-
};
|
|
853
|
-
const onCatalogShape = (path) => {
|
|
854
|
-
if (themePath !== void 0 && path === themePath) {
|
|
855
|
-
refreshTheme();
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
refreshTsxDependents(path, true);
|
|
859
|
-
};
|
|
860
|
-
server.watcher.on("change", onChange);
|
|
861
|
-
server.watcher.on("add", onCatalogShape);
|
|
862
|
-
server.watcher.on("unlink", onCatalogShape);
|
|
863
|
-
const unsubscribe = store.subscribe((change) => {
|
|
864
|
-
const { sourceClient, ...document } = change;
|
|
865
|
-
server.ws.send({
|
|
866
|
-
type: "custom",
|
|
867
|
-
event: "samva:document",
|
|
868
|
-
data: document
|
|
869
|
-
});
|
|
870
|
-
for (const client of clients) {
|
|
871
|
-
if (client.docId !== change.id) continue;
|
|
872
|
-
const origin = sourceClient !== void 0 && sourceClient === client.clientId ? "self" : "external";
|
|
873
|
-
client.push({
|
|
874
|
-
...document,
|
|
875
|
-
origin
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
});
|
|
879
|
-
const unsubscribeCatalog = store.subscribeCatalog(() => {
|
|
880
|
-
for (const res of catalogClients) res.write("event: samva:catalog\ndata: refresh\n\n");
|
|
881
|
-
server.ws.send({
|
|
882
|
-
type: "custom",
|
|
883
|
-
event: "samva:catalog"
|
|
884
|
-
});
|
|
885
|
-
});
|
|
886
|
-
let cleaned = false;
|
|
887
|
-
const cleanup = () => {
|
|
888
|
-
if (cleaned) return;
|
|
889
|
-
cleaned = true;
|
|
890
|
-
server.watcher.off("change", onChange);
|
|
891
|
-
server.watcher.off("add", onCatalogShape);
|
|
892
|
-
server.watcher.off("unlink", onCatalogShape);
|
|
893
|
-
server.watcher.off("close", cleanup);
|
|
894
|
-
server.httpServer?.off("close", cleanup);
|
|
895
|
-
unsubscribe();
|
|
896
|
-
unsubscribeCatalog();
|
|
897
|
-
moduleRunner.close();
|
|
898
|
-
emailRendererLoading?.then((renderer) => renderer.close());
|
|
899
|
-
for (const client of clients) client.res.end();
|
|
900
|
-
clients.clear();
|
|
901
|
-
for (const res of catalogClients) res.end();
|
|
902
|
-
catalogClients.clear();
|
|
903
|
-
};
|
|
904
|
-
server.httpServer?.once("close", cleanup);
|
|
905
|
-
server.watcher.once("close", cleanup);
|
|
906
|
-
const handler = (req, res, next) => {
|
|
907
|
-
const url = req.url ?? "";
|
|
908
|
-
const pathname = url.split("?")[0] || "/";
|
|
909
|
-
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
910
|
-
const apiRequest = pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`);
|
|
911
|
-
if (!apiRequest && !underRoute(route, pathname)) return next();
|
|
912
|
-
if (!apiRequest && route === "/" && isViteInternalPath(pathname)) return next();
|
|
913
|
-
(async () => {
|
|
914
|
-
if (pathname === `${apiPrefix}/capabilities`) return sendJson(res, 200, { authenticated });
|
|
915
|
-
if (pathname === `${apiPrefix}/templates`) {
|
|
916
|
-
const catalog = await store.catalog();
|
|
917
|
-
return sendJson(res, 200, {
|
|
918
|
-
templates: catalog.templates,
|
|
919
|
-
diagnostics: catalog.diagnostics
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
if (pathname === `${apiPrefix}/document`) {
|
|
923
|
-
const id = query.get("id");
|
|
924
|
-
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
925
|
-
if (req.method === "POST") {
|
|
926
|
-
if (!isTrustedSourceSave(req)) return sendJson(res, 403, { error: "Source saves require the editor origin." });
|
|
927
|
-
let body;
|
|
928
|
-
try {
|
|
929
|
-
body = JSON.parse(await readBody(req));
|
|
930
|
-
} catch (error) {
|
|
931
|
-
if (error instanceof EditorBodyTooLarge) return sendJson(res, 413, { error: error.message });
|
|
932
|
-
return sendJson(res, 400, { error: "malformed JSON body" });
|
|
933
|
-
}
|
|
934
|
-
if (!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." });
|
|
935
|
-
if (Buffer.byteLength(body["authoredSource"], "utf8") > 1048576) return sendJson(res, 413, { error: EDITOR_SOURCE_TOO_LARGE_MESSAGE });
|
|
936
|
-
const outcome = await store.saveSource(id, body["baseRev"], body["authoredSource"], query.get("client") ?? void 0);
|
|
937
|
-
if (outcome.ok) return sendJson(res, 200, { rev: outcome.rev });
|
|
938
|
-
return sendJson(res, outcome.kind === "conflict" ? 409 : outcome.kind === "missing" ? 404 : 422, outcome);
|
|
939
|
-
}
|
|
940
|
-
if (req.method !== "GET") return sendJson(res, 405, { error: "Use GET or POST." });
|
|
941
|
-
const snapshot = await store.open(id);
|
|
942
|
-
if (snapshot === void 0) return sendJson(res, 404, { error: "template not found" });
|
|
943
|
-
return sendJson(res, 200, snapshot);
|
|
944
|
-
}
|
|
945
|
-
if (pathname === `${apiPrefix}/fixture`) {
|
|
946
|
-
const id = query.get("id");
|
|
947
|
-
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
948
|
-
if (req.method !== "POST") return sendJson(res, 405, { error: "viewing a fixture is a POST" });
|
|
949
|
-
const raw = await readBody(req);
|
|
950
|
-
let body;
|
|
951
|
-
try {
|
|
952
|
-
body = JSON.parse(raw);
|
|
953
|
-
} catch {
|
|
954
|
-
return sendJson(res, 400, { error: "malformed JSON body" });
|
|
955
|
-
}
|
|
956
|
-
if (!isObject(body) || typeof body["fixture"] !== "string") return sendJson(res, 400, { error: "malformed JSON body" });
|
|
957
|
-
const outcome = await store.viewFixture(id, body["fixture"], query.get("client") ?? void 0);
|
|
958
|
-
if (outcome.ok) return sendJson(res, 200, {});
|
|
959
|
-
return sendJson(res, outcome.kind === "missing" ? 404 : 422, { error: outcome.error });
|
|
960
|
-
}
|
|
961
|
-
if (pathname.startsWith(`${apiPrefix}/assets/`)) {
|
|
962
|
-
const asset = assets.get(pathname.slice(`${apiPrefix}/assets/`.length));
|
|
963
|
-
if (asset === void 0) return sendJson(res, 404, { error: "asset not found" });
|
|
964
|
-
res.statusCode = 200;
|
|
965
|
-
res.setHeader("content-type", asset.contentType);
|
|
966
|
-
res.end(Buffer.from(asset.bytes));
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
if (pathname === `${apiPrefix}/events`) {
|
|
970
|
-
const id = query.get("id");
|
|
971
|
-
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
972
|
-
res.statusCode = 200;
|
|
973
|
-
res.setHeader("content-type", "text/event-stream");
|
|
974
|
-
res.setHeader("cache-control", "no-cache");
|
|
975
|
-
res.setHeader("connection", "keep-alive");
|
|
976
|
-
res.write(": connected\n\n");
|
|
977
|
-
const client = createSseClient(res, id, query.get("client") ?? void 0);
|
|
978
|
-
clients.add(client);
|
|
979
|
-
req.on("close", () => clients.delete(client));
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
if (pathname === `${apiPrefix}/catalog-events`) {
|
|
983
|
-
res.statusCode = 200;
|
|
984
|
-
res.setHeader("content-type", "text/event-stream");
|
|
985
|
-
res.setHeader("cache-control", "no-cache");
|
|
986
|
-
res.setHeader("connection", "keep-alive");
|
|
987
|
-
res.write(": connected\n\n");
|
|
988
|
-
catalogClients.add(res);
|
|
989
|
-
req.on("close", () => catalogClients.delete(res));
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
992
|
-
if (apiRequest) return sendJson(res, 404, { error: "not found" });
|
|
993
|
-
if (route !== "/" && pathname === route) {
|
|
994
|
-
res.statusCode = 302;
|
|
995
|
-
res.setHeader("location", `${route}/`);
|
|
996
|
-
res.end();
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
const rel = route === "/" ? pathname.replace(/^\//, "") : pathname.slice(route.length).replace(/^\//, "");
|
|
1000
|
-
if (await serveStatic(res, join(EDITOR_DIR, rel === "" ? "index.html" : rel))) return;
|
|
1001
|
-
const assetPrefix = childRoute(route, "assets");
|
|
1002
|
-
if (hasFileExtension(pathname) || pathname === assetPrefix || pathname.startsWith(`${assetPrefix}/`)) return next();
|
|
1003
|
-
if (await serveStatic(res, join(EDITOR_DIR, "index.html"))) return;
|
|
1004
|
-
return next();
|
|
1005
|
-
})().catch((error) => {
|
|
1006
|
-
sendJson(res, error instanceof EditorBodyTooLarge ? 413 : 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1007
|
-
});
|
|
1008
|
-
};
|
|
1009
|
-
server.middlewares.use(handler);
|
|
1010
|
-
}
|
|
1011
|
-
};
|
|
1012
|
-
};
|
|
1013
|
-
//#endregion
|
|
1
|
+
import { t as samvaEditor } from "./editor-Cow7zZ_9.js";
|
|
1014
2
|
export { samvaEditor };
|
|
1015
|
-
|
|
1016
|
-
//# sourceMappingURL=index.js.map
|