@samva/vite 0.1.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 +8 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/build.d.ts +42 -0
- package/dist/build.js +99 -0
- package/dist/build.js.map +1 -0
- package/dist/editor/assets/authoring-editor-CSA7Zv4v-B3TgD9LK.js +145 -0
- package/dist/editor/assets/authoring-editor-CSA7Zv4v-B3TgD9LK.js.map +1 -0
- package/dist/editor/assets/index-C1g6_O2Y.js +86 -0
- package/dist/editor/assets/index-C1g6_O2Y.js.map +1 -0
- package/dist/editor/assets/index-j-Fc0EML.css +2 -0
- package/dist/editor/index.html +13 -0
- package/dist/emit.d.ts +116 -0
- package/dist/emit.js +236 -0
- package/dist/emit.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +760 -0
- package/dist/index.js.map +1 -0
- package/dist/theme-CfJe8s3k.js +138 -0
- package/dist/theme-CfJe8s3k.js.map +1 -0
- package/package.json +85 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { a as discoverTemplateFiles, r as resolveThemePath, t as ThemeState } from "./theme-CfJe8s3k.js";
|
|
2
|
+
import { compileSourcePreview, emitModulePreview } from "./emit.js";
|
|
3
|
+
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 { fileURLToPath } from "node:url";
|
|
7
|
+
import { Predicate, Result } from "effect";
|
|
8
|
+
import { createServerModuleRunner, loadEnv } from "vite";
|
|
9
|
+
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
|
+
const hashSource = (source) => createHash("sha256").update(source, "utf8").digest("hex").slice(0, 16);
|
|
15
|
+
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";
|
|
21
|
+
};
|
|
22
|
+
const within = (child, parent) => child === parent || child.startsWith(parent + sep);
|
|
23
|
+
const flattenCompiledDiagnostics = (template) => {
|
|
24
|
+
if (template.compiled.channel === "email") return [...template.diagnostics, ...template.compiled.bases.flatMap((base) => base.diagnostics ?? [])];
|
|
25
|
+
return [...template.diagnostics, ...template.compiled.diagnostics ?? []];
|
|
26
|
+
};
|
|
27
|
+
const diagnostic = (file, severity, code, message, span) => ({
|
|
28
|
+
file,
|
|
29
|
+
severity,
|
|
30
|
+
code,
|
|
31
|
+
message,
|
|
32
|
+
span
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
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
|
+
*/
|
|
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) {
|
|
46
|
+
const problems = flattenCompiledDiagnostics(template);
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
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.")]
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
source: template.source,
|
|
55
|
+
channel: template.channel,
|
|
56
|
+
variables: template.variables
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
var EditorFileStore = class {
|
|
60
|
+
dir;
|
|
61
|
+
evaluateTsx;
|
|
62
|
+
compile;
|
|
63
|
+
realDir;
|
|
64
|
+
loaded = false;
|
|
65
|
+
initialLoad;
|
|
66
|
+
entries = /* @__PURE__ */ new Map();
|
|
67
|
+
diagnostics = /* @__PURE__ */ new Map();
|
|
68
|
+
currentRev = /* @__PURE__ */ new Map();
|
|
69
|
+
writing = /* @__PURE__ */ new Set();
|
|
70
|
+
saveChain = /* @__PURE__ */ new Map();
|
|
71
|
+
session = /* @__PURE__ */ new Map();
|
|
72
|
+
listeners = /* @__PURE__ */ new Set();
|
|
73
|
+
catalogListeners = /* @__PURE__ */ new Set();
|
|
74
|
+
constructor(templatesDir, options = {}) {
|
|
75
|
+
this.dir = templatesDir;
|
|
76
|
+
this.evaluateTsx = options.evaluateTsx;
|
|
77
|
+
this.compile = options.compile ?? (() => void 0);
|
|
78
|
+
}
|
|
79
|
+
async resolvePath(id) {
|
|
80
|
+
if (kindForId(id) === void 0 || id.includes("\0")) return void 0;
|
|
81
|
+
const full = resolve(this.dir, id);
|
|
82
|
+
if (!within(full, this.dir)) return void 0;
|
|
83
|
+
const realDir = await this.resolveRealDir();
|
|
84
|
+
if (realDir === void 0) return void 0;
|
|
85
|
+
const real = await realpath(full).catch(() => void 0);
|
|
86
|
+
if (real === void 0 || !within(real, realDir)) return void 0;
|
|
87
|
+
return real;
|
|
88
|
+
}
|
|
89
|
+
async resolveRealDir() {
|
|
90
|
+
if (this.realDir === void 0) this.realDir = await realpath(this.dir).catch(() => void 0);
|
|
91
|
+
return this.realDir;
|
|
92
|
+
}
|
|
93
|
+
idFor(path) {
|
|
94
|
+
const full = isAbsolute(path) ? path : join(this.dir, path);
|
|
95
|
+
if (!within(full, this.dir)) return void 0;
|
|
96
|
+
const rel = full.slice(this.dir.length + 1).split("\\").join("/");
|
|
97
|
+
return kindForId(rel) === void 0 ? void 0 : rel;
|
|
98
|
+
}
|
|
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.")] };
|
|
102
|
+
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);
|
|
131
|
+
return {
|
|
132
|
+
entry: {
|
|
133
|
+
template: {
|
|
134
|
+
id: file.id,
|
|
135
|
+
name,
|
|
136
|
+
channel: preview.channel,
|
|
137
|
+
sourceKind: "samva",
|
|
138
|
+
access: "editable"
|
|
139
|
+
},
|
|
140
|
+
source: authoredSource,
|
|
141
|
+
rev,
|
|
142
|
+
variables: [...variableReferences(authoredSource)].sort().map((variableName) => ({
|
|
143
|
+
name: variableName,
|
|
144
|
+
required: false
|
|
145
|
+
}))
|
|
146
|
+
},
|
|
147
|
+
diagnostics: []
|
|
148
|
+
};
|
|
149
|
+
}
|
|
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);
|
|
157
|
+
return {
|
|
158
|
+
entry: {
|
|
159
|
+
template: {
|
|
160
|
+
id: file.id,
|
|
161
|
+
name,
|
|
162
|
+
channel: evaluated.channel,
|
|
163
|
+
sourceKind: "tsx",
|
|
164
|
+
access: "readonly"
|
|
165
|
+
},
|
|
166
|
+
source: evaluated.source,
|
|
167
|
+
authoredSource,
|
|
168
|
+
rev,
|
|
169
|
+
variables: evaluated.variables.map((variable) => ({
|
|
170
|
+
name: variable.name,
|
|
171
|
+
required: variable.required
|
|
172
|
+
}))
|
|
173
|
+
},
|
|
174
|
+
diagnostics: []
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catalogSnapshot() {
|
|
178
|
+
return {
|
|
179
|
+
templates: [...this.entries.values()].map((entry) => entry.template).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
180
|
+
diagnostics: [...this.diagnostics.values()].flat()
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
ensureLoaded() {
|
|
184
|
+
if (this.loaded) return Promise.resolve(this.catalogSnapshot());
|
|
185
|
+
if (this.initialLoad !== void 0) return this.initialLoad;
|
|
186
|
+
const loading = this.rebuild(false);
|
|
187
|
+
this.initialLoad = loading;
|
|
188
|
+
loading.then(() => {
|
|
189
|
+
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
190
|
+
}, () => {
|
|
191
|
+
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
192
|
+
});
|
|
193
|
+
return loading;
|
|
194
|
+
}
|
|
195
|
+
async rebuild(emitDocuments) {
|
|
196
|
+
const previous = new Map(this.entries);
|
|
197
|
+
const files = await discoverTemplateFiles(this.dir);
|
|
198
|
+
const loaded = await Promise.all(files.map(async (file) => [file.id, await this.load(file)]));
|
|
199
|
+
this.entries.clear();
|
|
200
|
+
this.diagnostics.clear();
|
|
201
|
+
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);
|
|
206
|
+
if (result.diagnostics.length > 0) this.diagnostics.set(id, result.diagnostics);
|
|
207
|
+
}
|
|
208
|
+
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
|
+
});
|
|
217
|
+
}
|
|
218
|
+
for (const listener of this.catalogListeners) listener();
|
|
219
|
+
return this.catalogSnapshot();
|
|
220
|
+
}
|
|
221
|
+
async catalog() {
|
|
222
|
+
return this.ensureLoaded();
|
|
223
|
+
}
|
|
224
|
+
/** Re-evaluate discovery and every TSX entry after template/dependency/theme HMR. */
|
|
225
|
+
async refreshAll(options = {}) {
|
|
226
|
+
const loading = this.initialLoad;
|
|
227
|
+
if (loading !== void 0) await loading;
|
|
228
|
+
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();
|
|
251
|
+
}
|
|
252
|
+
async list() {
|
|
253
|
+
return (await this.catalog()).templates;
|
|
254
|
+
}
|
|
255
|
+
async open(id) {
|
|
256
|
+
if (!this.loaded) await this.ensureLoaded();
|
|
257
|
+
const entry = this.entries.get(id);
|
|
258
|
+
if (entry === void 0) return void 0;
|
|
259
|
+
const meta = this.session.get(id);
|
|
260
|
+
const fields = {
|
|
261
|
+
id: null,
|
|
262
|
+
name: meta?.name ?? entry.template.name,
|
|
263
|
+
channel: entry.template.channel,
|
|
264
|
+
source: entry.source,
|
|
265
|
+
rev: entry.rev,
|
|
266
|
+
variables: entry.variables,
|
|
267
|
+
metadata: meta?.metadata ?? {}
|
|
268
|
+
};
|
|
269
|
+
if (entry.template.sourceKind === "tsx") return {
|
|
270
|
+
...fields,
|
|
271
|
+
origin: {
|
|
272
|
+
kind: "tsx",
|
|
273
|
+
file: id,
|
|
274
|
+
authoredSource: entry.authoredSource ?? ""
|
|
275
|
+
},
|
|
276
|
+
access: {
|
|
277
|
+
kind: "readonly",
|
|
278
|
+
reason: "TSX templates are code-authored. Edit the default export in your code editor."
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
...fields,
|
|
283
|
+
origin: {
|
|
284
|
+
kind: "samva",
|
|
285
|
+
file: id
|
|
286
|
+
},
|
|
287
|
+
access: { kind: "editable" }
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
async save(id, update, client) {
|
|
291
|
+
if (kindForId(id) === "tsx") return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: "TSX templates are read-only; edit the default export in source."
|
|
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);
|
|
310
|
+
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();
|
|
320
|
+
}
|
|
321
|
+
const current = await readFile(path, "utf8").catch(() => void 0);
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
rev: current === void 0 ? this.currentRev.get(id) ?? "" : hashSource(current)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return this.withLock(id, () => this.commit(id, path, update, client));
|
|
328
|
+
}
|
|
329
|
+
async commit(id, path, update, client) {
|
|
330
|
+
const current = await readFile(path, "utf8").catch(() => void 0);
|
|
331
|
+
if (current === void 0) return {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: "template not found"
|
|
334
|
+
};
|
|
335
|
+
const actualRev = hashSource(current);
|
|
336
|
+
if (update.baseRev !== actualRev) return {
|
|
337
|
+
ok: false,
|
|
338
|
+
conflict: {
|
|
339
|
+
expectedRev: update.baseRev,
|
|
340
|
+
actualRev
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
let nextSource;
|
|
344
|
+
if (update.kind === "ops") {
|
|
345
|
+
const applied = applyOps(current, [...update.ops]);
|
|
346
|
+
if (Result.isFailure(applied)) return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: applied.failure.message
|
|
349
|
+
};
|
|
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
|
+
}
|
|
362
|
+
this.publish({
|
|
363
|
+
id,
|
|
364
|
+
rev,
|
|
365
|
+
source: nextSource,
|
|
366
|
+
sourceClient: client
|
|
367
|
+
});
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
rev
|
|
371
|
+
};
|
|
372
|
+
}
|
|
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
|
+
});
|
|
399
|
+
const change = {
|
|
400
|
+
id,
|
|
401
|
+
rev,
|
|
402
|
+
source,
|
|
403
|
+
sourceClient: void 0
|
|
404
|
+
};
|
|
405
|
+
this.publish(change);
|
|
406
|
+
return change;
|
|
407
|
+
}
|
|
408
|
+
publish(change) {
|
|
409
|
+
for (const listener of this.listeners) listener(change);
|
|
410
|
+
}
|
|
411
|
+
subscribe(listener) {
|
|
412
|
+
this.listeners.add(listener);
|
|
413
|
+
return () => this.listeners.delete(listener);
|
|
414
|
+
}
|
|
415
|
+
subscribeCatalog(listener) {
|
|
416
|
+
this.catalogListeners.add(listener);
|
|
417
|
+
return () => this.catalogListeners.delete(listener);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/editor.ts
|
|
422
|
+
const DEFAULT_ROUTE = "/";
|
|
423
|
+
const MODULE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
424
|
+
".cjs",
|
|
425
|
+
".css",
|
|
426
|
+
".cts",
|
|
427
|
+
".js",
|
|
428
|
+
".json",
|
|
429
|
+
".jsx",
|
|
430
|
+
".mjs",
|
|
431
|
+
".mts",
|
|
432
|
+
".ts",
|
|
433
|
+
".tsx"
|
|
434
|
+
]);
|
|
435
|
+
const packagedEditorDir = fileURLToPath(new URL("./editor/", import.meta.url));
|
|
436
|
+
const EDITOR_DIR = existsSync(packagedEditorDir) ? packagedEditorDir : fileURLToPath(new URL("../dist/editor/", import.meta.url));
|
|
437
|
+
const MIME = {
|
|
438
|
+
".html": "text/html; charset=utf-8",
|
|
439
|
+
".js": "text/javascript; charset=utf-8",
|
|
440
|
+
".css": "text/css; charset=utf-8",
|
|
441
|
+
".svg": "image/svg+xml",
|
|
442
|
+
".json": "application/json",
|
|
443
|
+
".map": "application/json",
|
|
444
|
+
".ico": "image/x-icon",
|
|
445
|
+
".png": "image/png",
|
|
446
|
+
".woff2": "font/woff2"
|
|
447
|
+
};
|
|
448
|
+
/** Normalize + validate the mount route: absolute, root allowed, no trailing slash. */
|
|
449
|
+
const normalizeRoute = (raw) => {
|
|
450
|
+
const route = raw === "/" ? "/" : raw.replace(/\/+$/, "");
|
|
451
|
+
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)})`);
|
|
452
|
+
return route;
|
|
453
|
+
};
|
|
454
|
+
const sendJson = (res, status, body) => {
|
|
455
|
+
res.statusCode = status;
|
|
456
|
+
res.setHeader("content-type", "application/json");
|
|
457
|
+
res.end(JSON.stringify(body));
|
|
458
|
+
};
|
|
459
|
+
/** Serve a file under the built editor dir; returns false (untouched res) if absent/outside. */
|
|
460
|
+
const serveStatic = async (res, filePath) => {
|
|
461
|
+
const resolved = resolve(filePath);
|
|
462
|
+
const root = EDITOR_DIR.replace(/\/$/, "");
|
|
463
|
+
if (resolved !== root && !resolved.startsWith(`${root}/`)) return false;
|
|
464
|
+
const body = await readFile(resolved).catch(() => void 0);
|
|
465
|
+
if (body === void 0) return false;
|
|
466
|
+
res.statusCode = 200;
|
|
467
|
+
res.setHeader("content-type", MIME[extname(resolved)] ?? "application/octet-stream");
|
|
468
|
+
res.end(body);
|
|
469
|
+
return true;
|
|
470
|
+
};
|
|
471
|
+
const VITE_INTERNAL_PREFIXES = [
|
|
472
|
+
"/@vite/",
|
|
473
|
+
"/@id/",
|
|
474
|
+
"/@fs/",
|
|
475
|
+
"/@react-refresh",
|
|
476
|
+
"/__vite",
|
|
477
|
+
"/node_modules/.vite/"
|
|
478
|
+
];
|
|
479
|
+
const isViteInternalPath = (pathname) => VITE_INTERNAL_PREFIXES.some((prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix));
|
|
480
|
+
const hasFileExtension = (pathname) => extname(pathname) !== "";
|
|
481
|
+
const underRoute = (route, pathname) => route === "/" || pathname === route || pathname.startsWith(`${route}/`);
|
|
482
|
+
const childRoute = (route, child) => route === "/" ? `/${child}` : `${route}/${child}`;
|
|
483
|
+
const readBody = (req) => new Promise((done, fail) => {
|
|
484
|
+
let data = "";
|
|
485
|
+
req.on("data", (chunk) => {
|
|
486
|
+
data += chunk;
|
|
487
|
+
});
|
|
488
|
+
req.on("end", () => done(data));
|
|
489
|
+
req.on("error", fail);
|
|
490
|
+
});
|
|
491
|
+
const createSseClient = (res, docId, clientId) => {
|
|
492
|
+
let pending;
|
|
493
|
+
let draining = false;
|
|
494
|
+
const flush = () => {
|
|
495
|
+
if (pending === void 0) {
|
|
496
|
+
draining = false;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const frame = pending;
|
|
500
|
+
pending = void 0;
|
|
501
|
+
if (res.write(frame)) flush();
|
|
502
|
+
else draining = true;
|
|
503
|
+
};
|
|
504
|
+
res.on("drain", flush);
|
|
505
|
+
return {
|
|
506
|
+
res,
|
|
507
|
+
docId,
|
|
508
|
+
clientId,
|
|
509
|
+
push(payload) {
|
|
510
|
+
const frame = `event: samva:change\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
511
|
+
if (draining) {
|
|
512
|
+
pending = frame;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!res.write(frame)) draining = true;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
const samvaEditor = (options = {}) => {
|
|
520
|
+
const route = normalizeRoute(options.route ?? DEFAULT_ROUTE);
|
|
521
|
+
const apiPrefix = childRoute(route, "api");
|
|
522
|
+
let root = process.cwd();
|
|
523
|
+
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) });
|
|
526
|
+
let authenticated = false;
|
|
527
|
+
const clients = /* @__PURE__ */ new Set();
|
|
528
|
+
const catalogClients = /* @__PURE__ */ new Set();
|
|
529
|
+
let refreshTail = Promise.resolve();
|
|
530
|
+
const queueStoreRefresh = (server, run) => {
|
|
531
|
+
refreshTail = refreshTail.then(run, run).catch((cause) => {
|
|
532
|
+
server.config.logger.error(`samvaEditor refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
533
|
+
});
|
|
534
|
+
return refreshTail;
|
|
535
|
+
};
|
|
536
|
+
const queueRefresh = (server, documents) => queueStoreRefresh(server, () => store.refreshAll({ documents }));
|
|
537
|
+
return {
|
|
538
|
+
name: "samva-editor",
|
|
539
|
+
config() {
|
|
540
|
+
return {
|
|
541
|
+
oxc: { jsx: {
|
|
542
|
+
runtime: "automatic",
|
|
543
|
+
importSource: "@samva/markup"
|
|
544
|
+
} },
|
|
545
|
+
optimizeDeps: { exclude: ["@samva/markup"] }
|
|
546
|
+
};
|
|
547
|
+
},
|
|
548
|
+
configResolved(resolved) {
|
|
549
|
+
root = resolved.root;
|
|
550
|
+
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) });
|
|
553
|
+
const env = loadEnv(resolved.mode, root, "");
|
|
554
|
+
authenticated = Boolean(env.SAMVA_API_KEY);
|
|
555
|
+
},
|
|
556
|
+
async configureServer(server) {
|
|
557
|
+
await theme.refresh();
|
|
558
|
+
const evaluator = createServerModuleRunner(server.environments.ssr, { hmr: false });
|
|
559
|
+
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);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
server.watcher.add(templatesDir);
|
|
567
|
+
if (theme.themePath !== void 0) server.watcher.add(theme.themePath);
|
|
568
|
+
const refreshTheme = () => {
|
|
569
|
+
theme.refresh().then(() => queueRefresh(server, false));
|
|
570
|
+
};
|
|
571
|
+
const refreshTsxDependents = (path, shapeChange) => {
|
|
572
|
+
const ssr = server.environments.ssr;
|
|
573
|
+
if (ssr === void 0) return;
|
|
574
|
+
const changedModules = ssr.moduleGraph.getModulesByFile(path) ?? /* @__PURE__ */ new Set();
|
|
575
|
+
const directTemplate = path.endsWith(".tsx") && store.idFor(path) !== void 0;
|
|
576
|
+
const potentialModuleShapeChange = shapeChange && changedModules.size === 0 && MODULE_EXTENSIONS.has(extname(path));
|
|
577
|
+
if (!directTemplate && changedModules.size === 0 && !potentialModuleShapeChange) return;
|
|
578
|
+
queueStoreRefresh(server, async () => {
|
|
579
|
+
const invalidated = /* @__PURE__ */ new Set();
|
|
580
|
+
const collectImporters = (module) => {
|
|
581
|
+
if (invalidated.has(module)) return;
|
|
582
|
+
invalidated.add(module);
|
|
583
|
+
for (const importer of module.importers) collectImporters(importer);
|
|
584
|
+
};
|
|
585
|
+
for (const module of changedModules) collectImporters(module);
|
|
586
|
+
if (shapeChange) ssr.moduleGraph.invalidateAll();
|
|
587
|
+
else for (const module of invalidated) ssr.moduleGraph.invalidateModule(module);
|
|
588
|
+
evaluator.evaluatedModules.clear();
|
|
589
|
+
return store.refreshAll({ documents: true });
|
|
590
|
+
});
|
|
591
|
+
};
|
|
592
|
+
const onChange = (path) => {
|
|
593
|
+
if (theme.themePath !== void 0 && path === theme.themePath) {
|
|
594
|
+
refreshTheme();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
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
|
+
refreshTsxDependents(path, false);
|
|
604
|
+
};
|
|
605
|
+
const onCatalogShape = (path) => {
|
|
606
|
+
if (theme.themePath !== void 0 && path === theme.themePath) {
|
|
607
|
+
refreshTheme();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (store.idFor(path)?.endsWith(".samva")) {
|
|
611
|
+
queueRefresh(server, true);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
refreshTsxDependents(path, true);
|
|
615
|
+
};
|
|
616
|
+
server.watcher.on("change", onChange);
|
|
617
|
+
server.watcher.on("add", onCatalogShape);
|
|
618
|
+
server.watcher.on("unlink", onCatalogShape);
|
|
619
|
+
const unsubscribe = store.subscribe((change) => {
|
|
620
|
+
server.ws.send({
|
|
621
|
+
type: "custom",
|
|
622
|
+
event: "samva:document",
|
|
623
|
+
data: change
|
|
624
|
+
});
|
|
625
|
+
for (const client of clients) {
|
|
626
|
+
if (client.docId !== change.id) continue;
|
|
627
|
+
const origin = change.sourceClient !== void 0 && change.sourceClient === client.clientId ? "self" : "external";
|
|
628
|
+
client.push({
|
|
629
|
+
id: change.id,
|
|
630
|
+
rev: change.rev,
|
|
631
|
+
source: change.source,
|
|
632
|
+
authoredSource: change.authoredSource,
|
|
633
|
+
origin
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
const unsubscribeCatalog = store.subscribeCatalog(() => {
|
|
638
|
+
for (const res of catalogClients) res.write("event: samva:catalog\ndata: refresh\n\n");
|
|
639
|
+
server.ws.send({
|
|
640
|
+
type: "custom",
|
|
641
|
+
event: "samva:catalog"
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
let cleaned = false;
|
|
645
|
+
const cleanup = () => {
|
|
646
|
+
if (cleaned) return;
|
|
647
|
+
cleaned = true;
|
|
648
|
+
server.watcher.off("change", onChange);
|
|
649
|
+
server.watcher.off("add", onCatalogShape);
|
|
650
|
+
server.watcher.off("unlink", onCatalogShape);
|
|
651
|
+
server.watcher.off("close", cleanup);
|
|
652
|
+
server.httpServer?.off("close", cleanup);
|
|
653
|
+
unsubscribe();
|
|
654
|
+
unsubscribeCatalog();
|
|
655
|
+
evaluator.close();
|
|
656
|
+
for (const client of clients) client.res.end();
|
|
657
|
+
clients.clear();
|
|
658
|
+
for (const res of catalogClients) res.end();
|
|
659
|
+
catalogClients.clear();
|
|
660
|
+
};
|
|
661
|
+
server.httpServer?.once("close", cleanup);
|
|
662
|
+
server.watcher.once("close", cleanup);
|
|
663
|
+
const handler = (req, res, next) => {
|
|
664
|
+
const url = req.url ?? "";
|
|
665
|
+
const pathname = url.split("?")[0] || "/";
|
|
666
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
667
|
+
const apiRequest = pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`);
|
|
668
|
+
if (!apiRequest && !underRoute(route, pathname)) return next();
|
|
669
|
+
if (!apiRequest && route === "/" && isViteInternalPath(pathname)) return next();
|
|
670
|
+
(async () => {
|
|
671
|
+
if (pathname === `${apiPrefix}/capabilities`) return sendJson(res, 200, { authenticated });
|
|
672
|
+
if (pathname === `${apiPrefix}/templates`) {
|
|
673
|
+
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
|
+
return sendJson(res, 200, {
|
|
683
|
+
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)
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (pathname === `${apiPrefix}/document`) {
|
|
693
|
+
const id = query.get("id");
|
|
694
|
+
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
695
|
+
if (req.method === "POST") {
|
|
696
|
+
const raw = await readBody(req);
|
|
697
|
+
let update;
|
|
698
|
+
try {
|
|
699
|
+
update = JSON.parse(raw);
|
|
700
|
+
} catch {
|
|
701
|
+
return sendJson(res, 400, { error: "malformed JSON body" });
|
|
702
|
+
}
|
|
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);
|
|
705
|
+
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 });
|
|
708
|
+
}
|
|
709
|
+
const snapshot = await store.open(id);
|
|
710
|
+
if (snapshot === void 0) return sendJson(res, 404, { error: "template not found" });
|
|
711
|
+
return sendJson(res, 200, snapshot);
|
|
712
|
+
}
|
|
713
|
+
if (pathname === `${apiPrefix}/events`) {
|
|
714
|
+
const id = query.get("id");
|
|
715
|
+
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
716
|
+
res.statusCode = 200;
|
|
717
|
+
res.setHeader("content-type", "text/event-stream");
|
|
718
|
+
res.setHeader("cache-control", "no-cache");
|
|
719
|
+
res.setHeader("connection", "keep-alive");
|
|
720
|
+
res.write(": connected\n\n");
|
|
721
|
+
const client = createSseClient(res, id, query.get("client") ?? void 0);
|
|
722
|
+
clients.add(client);
|
|
723
|
+
req.on("close", () => clients.delete(client));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (pathname === `${apiPrefix}/catalog-events`) {
|
|
727
|
+
res.statusCode = 200;
|
|
728
|
+
res.setHeader("content-type", "text/event-stream");
|
|
729
|
+
res.setHeader("cache-control", "no-cache");
|
|
730
|
+
res.setHeader("connection", "keep-alive");
|
|
731
|
+
res.write(": connected\n\n");
|
|
732
|
+
catalogClients.add(res);
|
|
733
|
+
req.on("close", () => catalogClients.delete(res));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (apiRequest) return sendJson(res, 404, { error: "not found" });
|
|
737
|
+
if (route !== "/" && pathname === route) {
|
|
738
|
+
res.statusCode = 302;
|
|
739
|
+
res.setHeader("location", `${route}/`);
|
|
740
|
+
res.end();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const rel = route === "/" ? pathname.replace(/^\//, "") : pathname.slice(route.length).replace(/^\//, "");
|
|
744
|
+
if (await serveStatic(res, join(EDITOR_DIR, rel === "" ? "index.html" : rel))) return;
|
|
745
|
+
const assetPrefix = childRoute(route, "assets");
|
|
746
|
+
if (hasFileExtension(pathname) || pathname === assetPrefix || pathname.startsWith(`${assetPrefix}/`)) return next();
|
|
747
|
+
if (await serveStatic(res, join(EDITOR_DIR, "index.html"))) return;
|
|
748
|
+
return next();
|
|
749
|
+
})().catch((error) => {
|
|
750
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
server.middlewares.use(handler);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
};
|
|
757
|
+
//#endregion
|
|
758
|
+
export { samvaEditor };
|
|
759
|
+
|
|
760
|
+
//# sourceMappingURL=index.js.map
|