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