@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/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