@samva/vite 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +63 -54
- package/README.md +151 -79
- package/dist/build.d.ts +47 -42
- package/dist/build.js +89 -191
- package/dist/dev.d.ts +22 -0
- package/dist/dev.js +43 -0
- package/dist/editor/assets/index-BtfwuWcY.js +32 -0
- package/dist/editor/assets/index-DIuS_EZu.css +2 -0
- package/dist/editor/index.html +2 -2
- package/dist/editor-Cow7zZ_9.js +780 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1 -1015
- package/dist/project-CiUuzKsC.d.ts +271 -0
- package/dist/project-Cp8qb3s0.js +774 -0
- package/dist/project.d.ts +2 -116
- package/dist/project.js +2 -294
- package/dist/render.d.ts +54 -0
- package/dist/render.js +98 -0
- package/package.json +11 -12
- package/dist/editor/assets/index-BeVA-Ur2.js +0 -32
- package/dist/editor/assets/index-Dz2CE64u.css +0 -2
- package/dist/email.d.ts +0 -60
- package/dist/email.js +0 -126
- package/dist/emit.d.ts +0 -99
- package/dist/emit.js +0 -142
- package/dist/theme-CBF6Czjm.js +0 -50
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { E as projectPath, O as templateDirectories, T as isWithin, c as restoreBase, d as emailCompatibility, h as renderEmailFixtures, i as loadProject, k as isObject, m as renderChannelFixture, o as compileBase, v as createBrandResolver, w as isProjectFile } from "./project-Cp8qb3s0.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { loadEnv } from "vite";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { renderIrPreview } from "@samva/markup/preview";
|
|
10
|
+
/** The refusal both the store and the dev-server route return for an oversized entry. */
|
|
11
|
+
const EDITOR_SOURCE_TOO_LARGE_MESSAGE = `Authored source exceeds the 1 MiB limit.`;
|
|
12
|
+
const hashSource = (source) => createHash("sha256").update(source, "utf8").digest("hex").slice(0, 16);
|
|
13
|
+
const deriveName = (path) => path.slice(path.lastIndexOf("/") + 1).replace(/\.tsx$/, "");
|
|
14
|
+
/** The document id for one channel of an entry. Email is the entry's own path. */
|
|
15
|
+
const documentId = (entryPath, channel) => channel === "email" ? entryPath : `${entryPath}#${channel}`;
|
|
16
|
+
const catalogFinding = (file, item) => ({
|
|
17
|
+
file,
|
|
18
|
+
severity: item.severity,
|
|
19
|
+
code: item.code,
|
|
20
|
+
message: item.message
|
|
21
|
+
});
|
|
22
|
+
/** A template's input contract is a JSON Schema, so its variables are that schema's top-level properties. */
|
|
23
|
+
const variablesOf = (schema) => {
|
|
24
|
+
const properties = schema["properties"];
|
|
25
|
+
if (!isObject(properties)) return [];
|
|
26
|
+
const required = schema["required"];
|
|
27
|
+
const names = Array.isArray(required) ? required : [];
|
|
28
|
+
return Object.entries(properties).map(([name, property]) => {
|
|
29
|
+
const examples = isObject(property) ? property["examples"] : void 0;
|
|
30
|
+
const example = Array.isArray(examples) ? examples[0] : void 0;
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
required: names.includes(name),
|
|
34
|
+
...typeof example === "string" ? { sample: example } : {}
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const chooseFixture = (fixtures, wanted) => wanted !== void 0 && fixtures.includes(wanted) ? wanted : fixtures[0] ?? null;
|
|
39
|
+
/** One preview render of the viewed fixture, or null when the renderer refuses it. */
|
|
40
|
+
const previewRender = (entry, fixture, revision, assetBase) => {
|
|
41
|
+
try {
|
|
42
|
+
const preview = renderIrPreview(entry.ir, entry.fixtures[fixture]);
|
|
43
|
+
return {
|
|
44
|
+
revision,
|
|
45
|
+
subject: preview.subject,
|
|
46
|
+
...preview.preheader === void 0 ? {} : { preheader: preview.preheader },
|
|
47
|
+
html: restoreBase(preview.html, assetBase),
|
|
48
|
+
text: preview.text,
|
|
49
|
+
selections: preview.selections
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** Everything one channel of one entry shows, computed once per compile. */
|
|
56
|
+
const buildChannel = (entry, channel, assetBase) => {
|
|
57
|
+
const fixtures = Object.keys(entry.fixtures);
|
|
58
|
+
if (channel === "email") {
|
|
59
|
+
const delivered = renderEmailFixtures(entry);
|
|
60
|
+
const diagnostics = [...entry.diagnostics, ...delivered.flatMap((result) => result.diagnostics)];
|
|
61
|
+
const incompatibilities = emailCompatibility(entry, delivered);
|
|
62
|
+
return {
|
|
63
|
+
identity: JSON.stringify({
|
|
64
|
+
diagnostics,
|
|
65
|
+
incompatibilities,
|
|
66
|
+
fixtures: delivered.map((result) => ({
|
|
67
|
+
fixture: result.fixture,
|
|
68
|
+
subject: result.rendered?.subject ?? null,
|
|
69
|
+
preheader: result.rendered?.preheader ?? null,
|
|
70
|
+
html: result.rendered?.html ?? null,
|
|
71
|
+
text: result.rendered?.text ?? null
|
|
72
|
+
}))
|
|
73
|
+
}),
|
|
74
|
+
content: (revision, wanted) => {
|
|
75
|
+
const fixture = chooseFixture(fixtures, wanted);
|
|
76
|
+
return {
|
|
77
|
+
channel: "email",
|
|
78
|
+
preview: { status: "current" },
|
|
79
|
+
fixtures,
|
|
80
|
+
fixture,
|
|
81
|
+
render: fixture === null ? null : previewRender(entry, fixture, revision, assetBase),
|
|
82
|
+
diagnostics,
|
|
83
|
+
incompatibilities
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const rendered = fixtures.map((fixture) => ({
|
|
89
|
+
fixture,
|
|
90
|
+
result: renderChannelFixture(entry, channel, fixture)
|
|
91
|
+
}));
|
|
92
|
+
const diagnostics = [...entry.diagnostics, ...rendered.flatMap(({ result }) => result.ok ? [] : result.diagnostics)];
|
|
93
|
+
return {
|
|
94
|
+
identity: JSON.stringify({
|
|
95
|
+
diagnostics,
|
|
96
|
+
rendered
|
|
97
|
+
}),
|
|
98
|
+
content: (revision, wanted) => {
|
|
99
|
+
const fixture = chooseFixture(fixtures, wanted);
|
|
100
|
+
const result = rendered.find((item) => item.fixture === fixture)?.result;
|
|
101
|
+
return {
|
|
102
|
+
channel,
|
|
103
|
+
preview: { status: "current" },
|
|
104
|
+
fixtures,
|
|
105
|
+
fixture,
|
|
106
|
+
render: result?.ok !== true ? null : result.channel === "sms" ? {
|
|
107
|
+
revision,
|
|
108
|
+
text: result.text
|
|
109
|
+
} : {
|
|
110
|
+
revision,
|
|
111
|
+
...result.message
|
|
112
|
+
},
|
|
113
|
+
diagnostics
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/** Two builds of one document differ when what the editor shows differs. */
|
|
119
|
+
const moved = (before, entry) => {
|
|
120
|
+
if (before === void 0) return true;
|
|
121
|
+
if (before.rev !== entry.rev || before.authoredSource !== entry.authoredSource) return true;
|
|
122
|
+
return before.content.fixture !== entry.content.fixture;
|
|
123
|
+
};
|
|
124
|
+
var EditorFileStore = class {
|
|
125
|
+
projectOptions;
|
|
126
|
+
options;
|
|
127
|
+
root;
|
|
128
|
+
loaded = false;
|
|
129
|
+
mutationQueue = Promise.resolve();
|
|
130
|
+
serialize(operation) {
|
|
131
|
+
const running = this.mutationQueue.then(operation, operation);
|
|
132
|
+
this.mutationQueue = running.catch(() => void 0);
|
|
133
|
+
return running;
|
|
134
|
+
}
|
|
135
|
+
initialLoad;
|
|
136
|
+
project;
|
|
137
|
+
entries = /* @__PURE__ */ new Map();
|
|
138
|
+
catalogDiagnostics = [];
|
|
139
|
+
fixtures = /* @__PURE__ */ new Map();
|
|
140
|
+
listeners = /* @__PURE__ */ new Set();
|
|
141
|
+
catalogListeners = /* @__PURE__ */ new Set();
|
|
142
|
+
constructor(options = {}) {
|
|
143
|
+
this.options = options;
|
|
144
|
+
this.projectOptions = {
|
|
145
|
+
...options,
|
|
146
|
+
assetBase: compileBase(options.assetBase)
|
|
147
|
+
};
|
|
148
|
+
this.root = resolve(options.root ?? process.cwd());
|
|
149
|
+
}
|
|
150
|
+
/** The bytes of an imported asset from the latest compile, by its content-addressed file name. */
|
|
151
|
+
asset(fileName) {
|
|
152
|
+
const project = this.project;
|
|
153
|
+
const entry = project?.catalog.assets.find((asset) => asset.fileName === fileName);
|
|
154
|
+
const bytes = entry === void 0 ? void 0 : project?.files[entry.path];
|
|
155
|
+
return entry === void 0 || bytes === void 0 || typeof bytes === "string" ? void 0 : {
|
|
156
|
+
contentType: entry.contentType,
|
|
157
|
+
bytes
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The directories the store scans, for a host that watches them. */
|
|
161
|
+
directories() {
|
|
162
|
+
return templateDirectories(this.root, this.projectOptions.dir);
|
|
163
|
+
}
|
|
164
|
+
async resolvePath(entryPath) {
|
|
165
|
+
if (!entryPath.endsWith(".tsx") || entryPath.includes("\0")) return void 0;
|
|
166
|
+
const full = resolve(this.root, entryPath);
|
|
167
|
+
const dirs = await this.directories();
|
|
168
|
+
if (!dirs.some((dir) => isWithin(full, dir))) return void 0;
|
|
169
|
+
const real = await realpath(full).catch(() => void 0);
|
|
170
|
+
if (real === void 0) return void 0;
|
|
171
|
+
return (await Promise.all(dirs.map((dir) => realpath(dir).catch(() => void 0)))).some((dir) => dir !== void 0 && isWithin(real, dir)) ? real : void 0;
|
|
172
|
+
}
|
|
173
|
+
/** The entry path of an absolute file path, when a template entry could live there. */
|
|
174
|
+
entryPathFor(path) {
|
|
175
|
+
const full = isAbsolute(path) ? path : join(this.root, path);
|
|
176
|
+
const relative = projectPath(this.root, full);
|
|
177
|
+
if (!relative.endsWith(".tsx") || relative.startsWith("..")) return void 0;
|
|
178
|
+
return this.project?.catalog.templates.some((entry) => entry.entryPath === relative) === true || this.project?.catalog.failures.some((entry) => entry.entryPath === relative) === true ? relative : void 0;
|
|
179
|
+
}
|
|
180
|
+
build(entry, channel, authoredSource, id, fixture) {
|
|
181
|
+
const built = buildChannel(entry, channel, this.options.assetBase);
|
|
182
|
+
const rev = hashSource(authoredSource + built.identity);
|
|
183
|
+
const content = built.content(rev, fixture);
|
|
184
|
+
return {
|
|
185
|
+
template: {
|
|
186
|
+
id,
|
|
187
|
+
name: deriveName(entry.entryPath),
|
|
188
|
+
channel,
|
|
189
|
+
sourceKind: "tsx"
|
|
190
|
+
},
|
|
191
|
+
file: entry.entryPath,
|
|
192
|
+
content,
|
|
193
|
+
authoredSource,
|
|
194
|
+
rev,
|
|
195
|
+
variables: variablesOf(entry.ir.schema),
|
|
196
|
+
compiled: entry
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catalogSnapshot() {
|
|
200
|
+
return {
|
|
201
|
+
templates: [...this.entries.values()].map((entry) => entry.template).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
202
|
+
diagnostics: [...this.catalogDiagnostics]
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
ensureLoaded() {
|
|
206
|
+
if (this.loaded) return Promise.resolve(this.catalogSnapshot());
|
|
207
|
+
if (this.initialLoad !== void 0) return this.initialLoad;
|
|
208
|
+
const loading = this.rebuild(false);
|
|
209
|
+
this.initialLoad = loading;
|
|
210
|
+
loading.then(() => {
|
|
211
|
+
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
212
|
+
}, () => {
|
|
213
|
+
if (this.initialLoad === loading) this.initialLoad = void 0;
|
|
214
|
+
});
|
|
215
|
+
return loading;
|
|
216
|
+
}
|
|
217
|
+
async rebuild(emitDocuments) {
|
|
218
|
+
const previous = new Map(this.entries);
|
|
219
|
+
const project = await loadProject(this.projectOptions, this.options.brands);
|
|
220
|
+
this.project = project;
|
|
221
|
+
this.options.onProject?.(project);
|
|
222
|
+
this.entries.clear();
|
|
223
|
+
this.catalogDiagnostics.length = 0;
|
|
224
|
+
this.catalogDiagnostics.push(...project.diagnostics.map((item) => catalogFinding("", item)));
|
|
225
|
+
for (const compiled of project.catalog.templates) {
|
|
226
|
+
const source = project.files[compiled.entryPath];
|
|
227
|
+
const authoredSource = typeof source === "string" ? source : "";
|
|
228
|
+
for (const channel of compiled.channels) {
|
|
229
|
+
const id = documentId(compiled.entryPath, channel);
|
|
230
|
+
this.entries.set(id, this.build(compiled, channel, authoredSource, id, this.fixtures.get(id)));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const failed of project.catalog.failures) {
|
|
234
|
+
const source = project.files[failed.entryPath];
|
|
235
|
+
const authoredSource = typeof source === "string" ? source : "";
|
|
236
|
+
this.catalogDiagnostics.push(...failed.diagnostics.map((item) => catalogFinding(failed.entryPath, item)));
|
|
237
|
+
for (const [id, before] of previous) {
|
|
238
|
+
if (before.file !== failed.entryPath) continue;
|
|
239
|
+
this.entries.set(id, {
|
|
240
|
+
...before,
|
|
241
|
+
content: {
|
|
242
|
+
...before.content,
|
|
243
|
+
fixtures: [],
|
|
244
|
+
fixture: null,
|
|
245
|
+
render: null,
|
|
246
|
+
diagnostics: failed.diagnostics
|
|
247
|
+
},
|
|
248
|
+
authoredSource,
|
|
249
|
+
rev: hashSource(authoredSource + JSON.stringify(failed.diagnostics)),
|
|
250
|
+
variables: [],
|
|
251
|
+
compiled: void 0
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
this.loaded = true;
|
|
256
|
+
if (emitDocuments) {
|
|
257
|
+
for (const [id, entry] of this.entries) if (moved(previous.get(id), entry)) this.publishEntry(id, entry);
|
|
258
|
+
}
|
|
259
|
+
for (const listener of this.catalogListeners) listener();
|
|
260
|
+
return this.catalogSnapshot();
|
|
261
|
+
}
|
|
262
|
+
async catalog() {
|
|
263
|
+
return this.ensureLoaded();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Recompile the project after a template, partial, theme or asset changed.
|
|
267
|
+
* `changed` names the file whose change asked for this refresh.
|
|
268
|
+
*
|
|
269
|
+
* This store writes template files itself, so an editor save comes back as a
|
|
270
|
+
* change event indistinguishable from an author's own edit in another program.
|
|
271
|
+
* Recompiling for it would republish the document with no client attached,
|
|
272
|
+
* which is exactly how a foreign edit reaches the editor. A changed file that
|
|
273
|
+
* still holds the bytes the current compile read is already built, so there is
|
|
274
|
+
* nothing to publish and nothing to rebuild. Every other path is a real change.
|
|
275
|
+
*/
|
|
276
|
+
async refreshAll(options = {}) {
|
|
277
|
+
const loading = this.initialLoad;
|
|
278
|
+
if (loading !== void 0) await loading;
|
|
279
|
+
else if (!this.loaded) return this.ensureLoaded();
|
|
280
|
+
return this.serialize(async () => await this.alreadyBuilt(options.changed) ? this.catalogSnapshot() : this.rebuild(options.documents ?? true));
|
|
281
|
+
}
|
|
282
|
+
/** True when `path` still holds the exact bytes the latest compile read from it. */
|
|
283
|
+
async alreadyBuilt(path) {
|
|
284
|
+
if (path === void 0 || this.project === void 0) return false;
|
|
285
|
+
const full = isAbsolute(path) ? path : join(this.root, path);
|
|
286
|
+
const found = this.project.files[projectPath(this.root, full)];
|
|
287
|
+
if (found === void 0) return false;
|
|
288
|
+
const known = typeof found === "string" ? Buffer.from(found, "utf8") : found;
|
|
289
|
+
const disk = await readFile(full).catch(() => void 0);
|
|
290
|
+
if (disk === void 0) return false;
|
|
291
|
+
return Buffer.from(known).equals(disk);
|
|
292
|
+
}
|
|
293
|
+
async list() {
|
|
294
|
+
return (await this.catalog()).templates;
|
|
295
|
+
}
|
|
296
|
+
async open(id) {
|
|
297
|
+
if (!this.loaded) await this.ensureLoaded();
|
|
298
|
+
const entry = this.entries.get(id);
|
|
299
|
+
if (entry === void 0) return void 0;
|
|
300
|
+
return {
|
|
301
|
+
id: null,
|
|
302
|
+
name: entry.template.name,
|
|
303
|
+
rev: entry.rev,
|
|
304
|
+
variables: entry.variables,
|
|
305
|
+
metadata: {},
|
|
306
|
+
...entry.content,
|
|
307
|
+
origin: {
|
|
308
|
+
kind: "tsx",
|
|
309
|
+
file: entry.file,
|
|
310
|
+
authoredSource: entry.authoredSource
|
|
311
|
+
},
|
|
312
|
+
access: { kind: "editable" }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Serialize editor saves and atomically replace the complete entry after a fresh disk check.
|
|
317
|
+
* Arbitrary external writers do not participate in this queue: the final check and rename
|
|
318
|
+
* are not a filesystem compare-and-swap, so authors must resolve overlapping saves externally.
|
|
319
|
+
*/
|
|
320
|
+
async saveSource(id, baseRev, authoredSource, client) {
|
|
321
|
+
if (Buffer.byteLength(authoredSource, "utf8") > 1048576) return {
|
|
322
|
+
ok: false,
|
|
323
|
+
kind: "refused",
|
|
324
|
+
error: EDITOR_SOURCE_TOO_LARGE_MESSAGE
|
|
325
|
+
};
|
|
326
|
+
await this.ensureLoaded();
|
|
327
|
+
return this.serialize(async () => {
|
|
328
|
+
const entry = this.entries.get(id);
|
|
329
|
+
const path = entry === void 0 ? void 0 : await this.resolvePath(entry.file);
|
|
330
|
+
if (entry === void 0 || path === void 0) return {
|
|
331
|
+
ok: false,
|
|
332
|
+
kind: "missing",
|
|
333
|
+
error: "Template not found."
|
|
334
|
+
};
|
|
335
|
+
const disk = await readFile(path, "utf8");
|
|
336
|
+
if (entry.rev !== baseRev || disk !== entry.authoredSource) {
|
|
337
|
+
await this.rebuild(true);
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
kind: "conflict",
|
|
341
|
+
error: "Source changed on disk. Reload before saving.",
|
|
342
|
+
actualRev: this.entries.get(id)?.rev ?? entry.rev
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
346
|
+
const mode = (await stat(path)).mode;
|
|
347
|
+
const file = await open(temporary, "wx", mode);
|
|
348
|
+
try {
|
|
349
|
+
await file.writeFile(authoredSource, "utf8");
|
|
350
|
+
await file.chmod(mode);
|
|
351
|
+
await file.sync();
|
|
352
|
+
await file.close();
|
|
353
|
+
if (await readFile(path, "utf8") !== disk) {
|
|
354
|
+
await this.rebuild(true);
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
kind: "conflict",
|
|
358
|
+
error: "Source changed on disk. Reload before saving.",
|
|
359
|
+
actualRev: this.entries.get(id)?.rev ?? entry.rev
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
await rename(temporary, path);
|
|
363
|
+
} finally {
|
|
364
|
+
await file.close().finally(() => rm(temporary, { force: true }));
|
|
365
|
+
}
|
|
366
|
+
const previous = new Map(this.entries);
|
|
367
|
+
await this.rebuild(false);
|
|
368
|
+
for (const [changedId, changed] of this.entries) if (moved(previous.get(changedId), changed)) this.publishEntry(changedId, changed, changedId === id ? client : void 0);
|
|
369
|
+
const saved = this.entries.get(id);
|
|
370
|
+
return saved === void 0 ? {
|
|
371
|
+
ok: false,
|
|
372
|
+
kind: "missing",
|
|
373
|
+
error: "The saved entry is no longer discoverable."
|
|
374
|
+
} : {
|
|
375
|
+
ok: true,
|
|
376
|
+
rev: saved.rev
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Show another declared fixture of a document. It re-renders the same
|
|
382
|
+
* compile, so the revision does not move; the resulting document reaches every
|
|
383
|
+
* watcher on the change stream, attributed to the client that asked.
|
|
384
|
+
*
|
|
385
|
+
* It runs in the mutation queue for the same reason a save does: the viewed
|
|
386
|
+
* fixture is part of the entry, and a rebuild that straddled the switch would
|
|
387
|
+
* take its own snapshot from before it and its render from after it, then
|
|
388
|
+
* publish the difference with no client.
|
|
389
|
+
*/
|
|
390
|
+
async viewFixture(id, fixture, client) {
|
|
391
|
+
if (!this.loaded) await this.ensureLoaded();
|
|
392
|
+
return this.serialize(async () => {
|
|
393
|
+
const entry = this.entries.get(id);
|
|
394
|
+
if (entry === void 0) return {
|
|
395
|
+
ok: false,
|
|
396
|
+
kind: "missing",
|
|
397
|
+
error: "template not found"
|
|
398
|
+
};
|
|
399
|
+
if (!entry.content.fixtures.includes(fixture) || entry.compiled === void 0) return {
|
|
400
|
+
ok: false,
|
|
401
|
+
kind: "refused",
|
|
402
|
+
error: `Template declares no fixture named ${JSON.stringify(fixture)}.`
|
|
403
|
+
};
|
|
404
|
+
const next = this.build(entry.compiled, entry.template.channel, entry.authoredSource, id, fixture);
|
|
405
|
+
this.fixtures.set(id, fixture);
|
|
406
|
+
this.entries.set(id, next);
|
|
407
|
+
this.publishEntry(id, next, client);
|
|
408
|
+
return { ok: true };
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
publishEntry(id, entry, client) {
|
|
412
|
+
const change = {
|
|
413
|
+
id,
|
|
414
|
+
rev: entry.rev,
|
|
415
|
+
authoredSource: entry.authoredSource,
|
|
416
|
+
...client === void 0 ? {} : { sourceClient: client },
|
|
417
|
+
...entry.content
|
|
418
|
+
};
|
|
419
|
+
for (const listener of this.listeners) listener(change);
|
|
420
|
+
}
|
|
421
|
+
subscribe(listener) {
|
|
422
|
+
this.listeners.add(listener);
|
|
423
|
+
return () => this.listeners.delete(listener);
|
|
424
|
+
}
|
|
425
|
+
subscribeCatalog(listener) {
|
|
426
|
+
this.catalogListeners.add(listener);
|
|
427
|
+
return () => this.catalogListeners.delete(listener);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/editor.ts
|
|
432
|
+
const DEFAULT_ROUTE = "/";
|
|
433
|
+
const packagedEditorDir = fileURLToPath(new URL("./editor/", import.meta.url));
|
|
434
|
+
const EDITOR_DIR = existsSync(packagedEditorDir) ? packagedEditorDir : fileURLToPath(new URL("../dist/editor/", import.meta.url));
|
|
435
|
+
const MIME = {
|
|
436
|
+
".html": "text/html; charset=utf-8",
|
|
437
|
+
".js": "text/javascript; charset=utf-8",
|
|
438
|
+
".css": "text/css; charset=utf-8",
|
|
439
|
+
".svg": "image/svg+xml",
|
|
440
|
+
".json": "application/json",
|
|
441
|
+
".map": "application/json",
|
|
442
|
+
".ico": "image/x-icon",
|
|
443
|
+
".png": "image/png",
|
|
444
|
+
".woff2": "font/woff2"
|
|
445
|
+
};
|
|
446
|
+
/** Normalize + validate the mount route: absolute, root allowed, no trailing slash. */
|
|
447
|
+
const normalizeRoute = (raw) => {
|
|
448
|
+
const route = raw === "/" ? "/" : raw.replace(/\/+$/, "");
|
|
449
|
+
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)})`);
|
|
450
|
+
return route;
|
|
451
|
+
};
|
|
452
|
+
const sendJson = (res, status, body) => {
|
|
453
|
+
res.statusCode = status;
|
|
454
|
+
res.setHeader("content-type", "application/json");
|
|
455
|
+
res.end(JSON.stringify(body));
|
|
456
|
+
};
|
|
457
|
+
/** Serve a file under the built editor dir; returns false (untouched res) if absent/outside. */
|
|
458
|
+
const serveStatic = async (res, filePath) => {
|
|
459
|
+
const resolved = resolve(filePath);
|
|
460
|
+
const root = EDITOR_DIR.replace(/\/$/, "");
|
|
461
|
+
if (resolved !== root && !resolved.startsWith(`${root}/`)) return false;
|
|
462
|
+
const body = await readFile(resolved).catch(() => void 0);
|
|
463
|
+
if (body === void 0) return false;
|
|
464
|
+
res.statusCode = 200;
|
|
465
|
+
res.setHeader("content-type", MIME[extname(resolved)] ?? "application/octet-stream");
|
|
466
|
+
res.end(body);
|
|
467
|
+
return true;
|
|
468
|
+
};
|
|
469
|
+
const VITE_INTERNAL_PREFIXES = [
|
|
470
|
+
"/@vite/",
|
|
471
|
+
"/@id/",
|
|
472
|
+
"/@fs/",
|
|
473
|
+
"/@react-refresh",
|
|
474
|
+
"/__vite",
|
|
475
|
+
"/node_modules/.vite/"
|
|
476
|
+
];
|
|
477
|
+
const isViteInternalPath = (pathname) => VITE_INTERNAL_PREFIXES.some((prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix));
|
|
478
|
+
const hasFileExtension = (pathname) => extname(pathname) !== "";
|
|
479
|
+
const underRoute = (route, pathname) => route === "/" || pathname === route || pathname.startsWith(`${route}/`);
|
|
480
|
+
const childRoute = (route, child) => route === "/" ? `/${child}` : `${route}/${child}`;
|
|
481
|
+
const isLoopbackHostname = (hostname) => {
|
|
482
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
483
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true;
|
|
484
|
+
if (isIP(normalized) !== 4) return false;
|
|
485
|
+
return normalized.split(".")[0] === "127";
|
|
486
|
+
};
|
|
487
|
+
const isLoopbackAddress = (address) => {
|
|
488
|
+
if (address === void 0) return false;
|
|
489
|
+
const normalized = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
490
|
+
return isLoopbackHostname(normalized);
|
|
491
|
+
};
|
|
492
|
+
const isTrustedSourceSave = (req) => {
|
|
493
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
|
|
494
|
+
const origin = req.headers.origin;
|
|
495
|
+
if (origin === void 0) return false;
|
|
496
|
+
try {
|
|
497
|
+
const parsed = new URL(origin);
|
|
498
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username === "" && parsed.password === "" && isLoopbackHostname(parsed.hostname);
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const MAX_BODY_BYTES = 6295552;
|
|
504
|
+
var EditorBodyTooLarge = class extends Error {};
|
|
505
|
+
const readBody = (req) => new Promise((done, fail) => {
|
|
506
|
+
const chunks = [];
|
|
507
|
+
let bytes = 0;
|
|
508
|
+
req.on("data", (chunk) => {
|
|
509
|
+
bytes += chunk.length;
|
|
510
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
511
|
+
chunks.length = 0;
|
|
512
|
+
fail(new EditorBodyTooLarge("Request body exceeds the editor limit."));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
chunks.push(chunk);
|
|
516
|
+
});
|
|
517
|
+
req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
|
|
518
|
+
req.on("error", fail);
|
|
519
|
+
req.on("aborted", () => fail(/* @__PURE__ */ new Error("Request body aborted.")));
|
|
520
|
+
});
|
|
521
|
+
const createSseClient = (res, docId, clientId) => {
|
|
522
|
+
let pending;
|
|
523
|
+
let draining = false;
|
|
524
|
+
const flush = () => {
|
|
525
|
+
if (pending === void 0) {
|
|
526
|
+
draining = false;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const frame = pending;
|
|
530
|
+
pending = void 0;
|
|
531
|
+
if (res.write(frame)) flush();
|
|
532
|
+
else draining = true;
|
|
533
|
+
};
|
|
534
|
+
res.on("drain", flush);
|
|
535
|
+
return {
|
|
536
|
+
res,
|
|
537
|
+
docId,
|
|
538
|
+
clientId,
|
|
539
|
+
push(payload) {
|
|
540
|
+
const frame = `event: samva:change\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
541
|
+
if (draining) {
|
|
542
|
+
pending = frame;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (!res.write(frame)) draining = true;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
};
|
|
549
|
+
const samvaEditor = (options = {}) => {
|
|
550
|
+
const route = normalizeRoute(options.route ?? DEFAULT_ROUTE);
|
|
551
|
+
const apiPrefix = childRoute(route, "api");
|
|
552
|
+
let root = process.cwd();
|
|
553
|
+
let store = new EditorFileStore({ root });
|
|
554
|
+
let authenticated = false;
|
|
555
|
+
let env = {};
|
|
556
|
+
const clients = /* @__PURE__ */ new Set();
|
|
557
|
+
const catalogClients = /* @__PURE__ */ new Set();
|
|
558
|
+
let refreshTail = Promise.resolve();
|
|
559
|
+
const queueStoreRefresh = (server, run) => {
|
|
560
|
+
refreshTail = refreshTail.then(run, run).catch((cause) => {
|
|
561
|
+
server.config.logger.error(`samvaEditor refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
562
|
+
});
|
|
563
|
+
return refreshTail;
|
|
564
|
+
};
|
|
565
|
+
return {
|
|
566
|
+
name: "samva-editor",
|
|
567
|
+
config(user) {
|
|
568
|
+
return user.server?.host === void 0 ? { server: {
|
|
569
|
+
host: "127.0.0.1",
|
|
570
|
+
allowedHosts: ["localhost", ".localhost"]
|
|
571
|
+
} } : {};
|
|
572
|
+
},
|
|
573
|
+
configResolved(resolved) {
|
|
574
|
+
root = resolve(resolved.root, options.projectRoot ?? ".");
|
|
575
|
+
env = loadEnv(resolved.mode, root, "");
|
|
576
|
+
authenticated = Boolean(env.SAMVA_API_KEY);
|
|
577
|
+
},
|
|
578
|
+
async configureServer(server) {
|
|
579
|
+
const brands = createBrandResolver({
|
|
580
|
+
root,
|
|
581
|
+
env
|
|
582
|
+
});
|
|
583
|
+
let brandReport = "";
|
|
584
|
+
const reportBrand = (resolution) => {
|
|
585
|
+
const text = resolution.ok ? resolution.warnings.map((warning) => warning.message).join("\n") : resolution.message;
|
|
586
|
+
if (text === brandReport) return;
|
|
587
|
+
brandReport = text;
|
|
588
|
+
if (text === "") return;
|
|
589
|
+
if (resolution.ok) server.config.logger.warn(`samvaEditor: ${text}`);
|
|
590
|
+
else server.config.logger.error(`samvaEditor: ${text}`);
|
|
591
|
+
};
|
|
592
|
+
store = new EditorFileStore({
|
|
593
|
+
root,
|
|
594
|
+
dir: options.templatesDir,
|
|
595
|
+
theme: options.theme,
|
|
596
|
+
assetBase: childRoute(apiPrefix, "assets"),
|
|
597
|
+
brands,
|
|
598
|
+
onProject: (project) => reportBrand(project.brand)
|
|
599
|
+
});
|
|
600
|
+
server.watcher.add(root);
|
|
601
|
+
let burst = /* @__PURE__ */ new Set();
|
|
602
|
+
let burstQueued = false;
|
|
603
|
+
const relevant = (path) => {
|
|
604
|
+
const relative = projectPath(root, path);
|
|
605
|
+
if (relative.startsWith("..") || relative === ".samva") return false;
|
|
606
|
+
if (relative.startsWith(`.samva/`)) return false;
|
|
607
|
+
if (relative.split("/").includes("node_modules")) return false;
|
|
608
|
+
return isProjectFile(relative);
|
|
609
|
+
};
|
|
610
|
+
const onFileEvent = (path) => {
|
|
611
|
+
if (!relevant(path)) return;
|
|
612
|
+
burst.add(path);
|
|
613
|
+
if (burstQueued) return;
|
|
614
|
+
burstQueued = true;
|
|
615
|
+
queueStoreRefresh(server, async () => {
|
|
616
|
+
const paths = burst;
|
|
617
|
+
burst = /* @__PURE__ */ new Set();
|
|
618
|
+
burstQueued = false;
|
|
619
|
+
const only = paths.size === 1 ? [...paths][0] : void 0;
|
|
620
|
+
return store.refreshAll({
|
|
621
|
+
documents: true,
|
|
622
|
+
changed: only
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
server.watcher.on("change", onFileEvent);
|
|
627
|
+
server.watcher.on("add", onFileEvent);
|
|
628
|
+
server.watcher.on("unlink", onFileEvent);
|
|
629
|
+
const unsubscribe = store.subscribe((change) => {
|
|
630
|
+
const { sourceClient, ...document } = change;
|
|
631
|
+
server.ws.send({
|
|
632
|
+
type: "custom",
|
|
633
|
+
event: "samva:document",
|
|
634
|
+
data: document
|
|
635
|
+
});
|
|
636
|
+
for (const client of clients) {
|
|
637
|
+
if (client.docId !== change.id) continue;
|
|
638
|
+
const origin = sourceClient !== void 0 && sourceClient === client.clientId ? "self" : "external";
|
|
639
|
+
client.push({
|
|
640
|
+
...document,
|
|
641
|
+
origin
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
const unsubscribeCatalog = store.subscribeCatalog(() => {
|
|
646
|
+
for (const res of catalogClients) res.write("event: samva:catalog\ndata: refresh\n\n");
|
|
647
|
+
server.ws.send({
|
|
648
|
+
type: "custom",
|
|
649
|
+
event: "samva:catalog"
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
let cleaned = false;
|
|
653
|
+
const cleanup = () => {
|
|
654
|
+
if (cleaned) return;
|
|
655
|
+
cleaned = true;
|
|
656
|
+
server.watcher.off("change", onFileEvent);
|
|
657
|
+
server.watcher.off("add", onFileEvent);
|
|
658
|
+
server.watcher.off("unlink", onFileEvent);
|
|
659
|
+
server.watcher.off("close", cleanup);
|
|
660
|
+
server.httpServer?.off("close", cleanup);
|
|
661
|
+
unsubscribe();
|
|
662
|
+
unsubscribeCatalog();
|
|
663
|
+
for (const client of clients) client.res.end();
|
|
664
|
+
clients.clear();
|
|
665
|
+
for (const res of catalogClients) res.end();
|
|
666
|
+
catalogClients.clear();
|
|
667
|
+
};
|
|
668
|
+
server.httpServer?.once("close", cleanup);
|
|
669
|
+
server.watcher.once("close", cleanup);
|
|
670
|
+
const handler = (req, res, next) => {
|
|
671
|
+
const url = req.url ?? "";
|
|
672
|
+
const pathname = url.split("?")[0] || "/";
|
|
673
|
+
const query = new URLSearchParams(url.split("?")[1] ?? "");
|
|
674
|
+
const apiRequest = pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`);
|
|
675
|
+
if (!apiRequest && !underRoute(route, pathname)) return next();
|
|
676
|
+
if (!apiRequest && route === "/" && isViteInternalPath(pathname)) return next();
|
|
677
|
+
(async () => {
|
|
678
|
+
if (pathname === `${apiPrefix}/capabilities`) return sendJson(res, 200, { authenticated });
|
|
679
|
+
if (pathname === `${apiPrefix}/templates`) {
|
|
680
|
+
const catalog = await store.catalog();
|
|
681
|
+
return sendJson(res, 200, {
|
|
682
|
+
templates: catalog.templates,
|
|
683
|
+
diagnostics: catalog.diagnostics
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (pathname === `${apiPrefix}/document`) {
|
|
687
|
+
const id = query.get("id");
|
|
688
|
+
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
689
|
+
if (req.method === "POST") {
|
|
690
|
+
if (!isTrustedSourceSave(req)) return sendJson(res, 403, { error: "Source saves require the editor origin." });
|
|
691
|
+
let body;
|
|
692
|
+
try {
|
|
693
|
+
body = JSON.parse(await readBody(req));
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (error instanceof EditorBodyTooLarge) return sendJson(res, 413, { error: error.message });
|
|
696
|
+
return sendJson(res, 400, { error: "malformed JSON body" });
|
|
697
|
+
}
|
|
698
|
+
if (!isObject(body) || body["kind"] !== "authoredSource" || typeof body["baseRev"] !== "string" || typeof body["authoredSource"] !== "string") return sendJson(res, 400, { error: "Expected a revision-bound authored source update." });
|
|
699
|
+
if (Buffer.byteLength(body["authoredSource"], "utf8") > 1048576) return sendJson(res, 413, { error: EDITOR_SOURCE_TOO_LARGE_MESSAGE });
|
|
700
|
+
const outcome = await store.saveSource(id, body["baseRev"], body["authoredSource"], query.get("client") ?? void 0);
|
|
701
|
+
if (outcome.ok) return sendJson(res, 200, { rev: outcome.rev });
|
|
702
|
+
return sendJson(res, outcome.kind === "conflict" ? 409 : outcome.kind === "missing" ? 404 : 422, outcome);
|
|
703
|
+
}
|
|
704
|
+
if (req.method !== "GET") return sendJson(res, 405, { error: "Use GET or POST." });
|
|
705
|
+
const snapshot = await store.open(id);
|
|
706
|
+
if (snapshot === void 0) return sendJson(res, 404, { error: "template not found" });
|
|
707
|
+
return sendJson(res, 200, snapshot);
|
|
708
|
+
}
|
|
709
|
+
if (pathname === `${apiPrefix}/fixture`) {
|
|
710
|
+
const id = query.get("id");
|
|
711
|
+
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
712
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "viewing a fixture is a POST" });
|
|
713
|
+
const raw = await readBody(req);
|
|
714
|
+
let body;
|
|
715
|
+
try {
|
|
716
|
+
body = JSON.parse(raw);
|
|
717
|
+
} catch {
|
|
718
|
+
return sendJson(res, 400, { error: "malformed JSON body" });
|
|
719
|
+
}
|
|
720
|
+
if (!isObject(body) || typeof body["fixture"] !== "string") return sendJson(res, 400, { error: "malformed JSON body" });
|
|
721
|
+
const outcome = await store.viewFixture(id, body["fixture"], query.get("client") ?? void 0);
|
|
722
|
+
if (outcome.ok) return sendJson(res, 200, {});
|
|
723
|
+
return sendJson(res, outcome.kind === "missing" ? 404 : 422, { error: outcome.error });
|
|
724
|
+
}
|
|
725
|
+
if (pathname.startsWith(`${apiPrefix}/assets/`)) {
|
|
726
|
+
const asset = store.asset(pathname.slice(`${apiPrefix}/assets/`.length));
|
|
727
|
+
if (asset === void 0) return sendJson(res, 404, { error: "asset not found" });
|
|
728
|
+
res.statusCode = 200;
|
|
729
|
+
res.setHeader("content-type", asset.contentType);
|
|
730
|
+
res.end(Buffer.from(asset.bytes));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (pathname === `${apiPrefix}/events`) {
|
|
734
|
+
const id = query.get("id");
|
|
735
|
+
if (!id) return sendJson(res, 400, { error: "missing id" });
|
|
736
|
+
res.statusCode = 200;
|
|
737
|
+
res.setHeader("content-type", "text/event-stream");
|
|
738
|
+
res.setHeader("cache-control", "no-cache");
|
|
739
|
+
res.setHeader("connection", "keep-alive");
|
|
740
|
+
res.write(": connected\n\n");
|
|
741
|
+
const client = createSseClient(res, id, query.get("client") ?? void 0);
|
|
742
|
+
clients.add(client);
|
|
743
|
+
req.on("close", () => clients.delete(client));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (pathname === `${apiPrefix}/catalog-events`) {
|
|
747
|
+
res.statusCode = 200;
|
|
748
|
+
res.setHeader("content-type", "text/event-stream");
|
|
749
|
+
res.setHeader("cache-control", "no-cache");
|
|
750
|
+
res.setHeader("connection", "keep-alive");
|
|
751
|
+
res.write(": connected\n\n");
|
|
752
|
+
catalogClients.add(res);
|
|
753
|
+
req.on("close", () => catalogClients.delete(res));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (apiRequest) return sendJson(res, 404, { error: "not found" });
|
|
757
|
+
if (route !== "/" && pathname === route) {
|
|
758
|
+
res.statusCode = 302;
|
|
759
|
+
res.setHeader("location", `${route}/`);
|
|
760
|
+
res.end();
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const rel = route === "/" ? pathname.replace(/^\//, "") : pathname.slice(route.length).replace(/^\//, "");
|
|
764
|
+
if (await serveStatic(res, join(EDITOR_DIR, rel === "" ? "index.html" : rel))) return;
|
|
765
|
+
const assetPrefix = childRoute(route, "assets");
|
|
766
|
+
if (hasFileExtension(pathname) || pathname === assetPrefix || pathname.startsWith(`${assetPrefix}/`)) return next();
|
|
767
|
+
if (await serveStatic(res, join(EDITOR_DIR, "index.html"))) return;
|
|
768
|
+
return next();
|
|
769
|
+
})().catch((error) => {
|
|
770
|
+
sendJson(res, error instanceof EditorBodyTooLarge ? 413 : 500, { error: error instanceof Error ? error.message : String(error) });
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
server.middlewares.use(handler);
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
};
|
|
777
|
+
//#endregion
|
|
778
|
+
export { samvaEditor as t };
|
|
779
|
+
|
|
780
|
+
//# sourceMappingURL=editor-Cow7zZ_9.js.map
|