@loomhelix-cms/node 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loomhelix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @loomhelix-cms/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `createCms`: an optional adapter of [loomhelix-cms](https://github.com/loomhelix/loomhelix-cms) for a Node.js site. One handler gives the editor page,
4
+ the content API, the preview, and the pages of the site at their paths.
5
+
6
+ Status: draft. The API can change before version 1.0.
7
+
8
+ ```sh
9
+ npm install @loomhelix-cms/node @loomhelix-cms/render-html @loomhelix-cms/schema
10
+ ```
11
+
12
+ ```ts
13
+ import { createServer } from 'node:http';
14
+ import { createCms, createFolderStore } from '@loomhelix-cms/node';
15
+ import { templates } from './templates.ts';
16
+
17
+ const cms = await createCms({
18
+ schemaFile: 'blocks.ts',
19
+ templates,
20
+ store: createFolderStore('pages'),
21
+ canEdit: (request) => request.headers.host === 'localhost:3000',
22
+ });
23
+
24
+ createServer(cms.handle).listen(3000, '127.0.0.1');
25
+ ```
26
+
27
+ `canEdit` decides who can use the editor. On a real site, check the session of your login in it. The handler checks
28
+ the `Origin` header and the size of each save. [Add it to a Node.js site](https://github.com/loomhelix/loomhelix-cms#add-it-to-a-nodejs-site) gives all
29
+ steps.
@@ -0,0 +1,66 @@
1
+ import { Template } from "@loomhelix-cms/render-html";
2
+ import { RecordReader } from "@loomhelix-cms/schema";
3
+ import { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ interface StoredPage {
6
+ readonly id: string;
7
+ readonly text: string;
8
+ }
9
+ interface PageStore {
10
+ list(): Promise<readonly StoredPage[]>;
11
+ read(id: string): Promise<string | undefined>;
12
+ write(id: string, text: string): Promise<void>;
13
+ }
14
+ interface RecordSummary {
15
+ readonly id: string;
16
+ readonly label: string;
17
+ }
18
+ type RecordQuery = {
19
+ readonly text: string;
20
+ } | {
21
+ readonly ids: readonly string[];
22
+ };
23
+ interface RecordSource {
24
+ readonly lookup: (query: RecordQuery) => Promise<readonly RecordSummary[]>;
25
+ readonly read: RecordReader;
26
+ }
27
+ type RecordSources = Readonly<Record<string, RecordSource>>;
28
+ type ImageType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | 'image/avif';
29
+ interface ImageFormat {
30
+ readonly type: ImageType;
31
+ readonly extension: string;
32
+ }
33
+ interface UploadedImage extends ImageFormat {
34
+ readonly bytes: Uint8Array;
35
+ }
36
+ interface UploadedFile {
37
+ readonly size: number;
38
+ readonly stream: () => Readable;
39
+ }
40
+ interface UploadedFiles {
41
+ readonly path: string;
42
+ readonly open: (name: string) => Promise<UploadedFile | undefined>;
43
+ }
44
+ interface UploadStore {
45
+ readonly save: (image: UploadedImage) => Promise<string>;
46
+ readonly files?: UploadedFiles;
47
+ }
48
+ export declare function createFolderUploads(folder: string, path: string): UploadStore;
49
+ interface CmsOptions {
50
+ readonly schemaFile: string;
51
+ readonly templates: Readonly<Record<string, Template>>;
52
+ readonly store: PageStore;
53
+ readonly canEdit: (request: IncomingMessage) => boolean | Promise<boolean>;
54
+ readonly blockPreviewPage?: (blockHtml: string) => string;
55
+ readonly records?: RecordSources;
56
+ readonly uploads?: UploadStore;
57
+ }
58
+ type Next = (error?: unknown) => void;
59
+ type Handle = (request: IncomingMessage, response: ServerResponse, next?: Next) => Promise<void>;
60
+ interface Cms {
61
+ readonly handle: Handle;
62
+ readonly renderBlock: (id: string) => Promise<string | undefined>;
63
+ }
64
+ export declare function createCms(options: CmsOptions): Promise<Cms>;
65
+ export declare function createFolderStore(folder: string): PageStore;
66
+ export type { Cms, CmsOptions, Handle, ImageFormat, ImageType, Next, PageStore, RecordQuery, RecordSource, RecordSummary, StoredPage, UploadStore, UploadedFile, UploadedFiles, UploadedImage };
package/dist/index.mjs ADDED
@@ -0,0 +1,798 @@
1
+ import { createRequire } from "node:module";
2
+ import { readSchemaSource } from "@loomhelix-cms/cli";
3
+ import { RenderError, createRenderer } from "@loomhelix-cms/render-html";
4
+ import { DocumentError, currentTypeName, loadDocument, loadDocumentSet, loadRecords, parseSchema } from "@loomhelix-cms/schema";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { createReadStream } from "node:fs";
9
+ import { pipeline } from "node:stream/promises";
10
+ // src/block-preview-page.ts
11
+ function defaultBlockPreviewPage(blockHtml) {
12
+ return `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
17
+ <title>Block preview</title>
18
+ </head>
19
+ <body>
20
+ ${blockHtml}
21
+ </body>
22
+ </html>
23
+ `;
24
+ }
25
+ // src/cms-paths.ts
26
+ const CMS_PATH = "/cms";
27
+ const API_PATH = "/cms/api";
28
+ const PREVIEW_PATH = "/cms/preview";
29
+ const SCHEMA_PATH = "/cms/blocks.json";
30
+ const EDITOR_SCRIPT_PATH = "/cms/editor.js";
31
+ const BRIDGE_SCRIPT_PATH = "/cms/bridge.js";
32
+ const STORAGE_SCRIPT_PATH = "/cms/storage.js";
33
+ // src/page-rules.ts
34
+ const PAGE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
35
+ function isPageId(text) {
36
+ return PAGE_ID_PATTERN.test(text);
37
+ }
38
+ function summarizePages(pages) {
39
+ return pages.map(({ id, document }) => ({
40
+ id,
41
+ type: rootTypeOf(document),
42
+ title: rootProp(document, "title"),
43
+ path: pathOf(document)
44
+ })).toSorted((first, second) => String(first.path).localeCompare(String(second.path)));
45
+ }
46
+ function readPageBody(schema, body, id) {
47
+ let json;
48
+ let document;
49
+ try {
50
+ json = JSON.parse(body);
51
+ document = loadDocument(schema, json).document;
52
+ } catch (error) {
53
+ if (error instanceof SyntaxError || error instanceof DocumentError) return { error: error.message };
54
+ throw error;
55
+ }
56
+ if (document.root.id !== id) return { error: `The root block has the id "${document.root.id}", but the address has the page id "${id}".` };
57
+ return {
58
+ json,
59
+ document
60
+ };
61
+ }
62
+ function findSaveError({ conditions, currentTag, paths, document }) {
63
+ if (isOutdated(conditions, currentTag)) return {
64
+ status: 412,
65
+ message: "The page changed after the editor read it, or it exists already."
66
+ };
67
+ const { id } = document.root;
68
+ const path = document.root.props.path;
69
+ if (typeof path !== "string") return;
70
+ const pageWithPath = paths.get(path);
71
+ if (pageWithPath !== void 0 && pageWithPath !== id) return {
72
+ status: 409,
73
+ message: `The page "${pageWithPath}" has the path ${path} already.`
74
+ };
75
+ }
76
+ function indexPaths(pages) {
77
+ const paths = /* @__PURE__ */ new Map();
78
+ for (const page of pages) {
79
+ const path = pathOf(page.document);
80
+ if (typeof path === "string" && !paths.has(path)) paths.set(path, page.id);
81
+ }
82
+ return paths;
83
+ }
84
+ function withPagePath(paths, id, path) {
85
+ const otherPaths = new Map([...paths].filter(([, pageId]) => pageId !== id));
86
+ if (typeof path === "string" && !otherPaths.has(path)) otherPaths.set(path, id);
87
+ return otherPaths;
88
+ }
89
+ function findPageByPath(pages, path) {
90
+ return pages.find((page) => pathOf(page.document) === path);
91
+ }
92
+ function rootTypeOf(document) {
93
+ return propertyOf(propertyOf(document, "root"), "type");
94
+ }
95
+ function pathOf(document) {
96
+ return rootProp(document, "path");
97
+ }
98
+ function rootProp(document, name) {
99
+ return propertyOf(propertyOf(propertyOf(document, "root"), "props"), name);
100
+ }
101
+ function propertyOf(value, name) {
102
+ return typeof value === "object" && value !== null ? Object.getOwnPropertyDescriptor(value, name)?.value : void 0;
103
+ }
104
+ function isOutdated({ ifMatch, ifNoneMatch }, currentTag) {
105
+ if (ifNoneMatch === "*" && currentTag !== void 0) return true;
106
+ return ifMatch !== void 0 && ifMatch !== currentTag;
107
+ }
108
+ // src/page-store.ts
109
+ async function readStoredDocument(store, id) {
110
+ const text = await store.read(id);
111
+ return text === void 0 ? void 0 : {
112
+ id,
113
+ document: parseStoredText(text)
114
+ };
115
+ }
116
+ async function readStoredDocuments(store) {
117
+ return (await store.list()).map(({ id, text }) => ({
118
+ id,
119
+ document: parseStoredText(text)
120
+ }));
121
+ }
122
+ function parseStoredText(text) {
123
+ try {
124
+ return JSON.parse(text);
125
+ } catch {
126
+ return;
127
+ }
128
+ }
129
+ // src/request-checks.ts
130
+ const WEB_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
131
+ function hasSiteOrigin(request) {
132
+ const { origin, host } = request.headers;
133
+ if (origin === void 0 || host === void 0) return false;
134
+ try {
135
+ const url = new URL(origin);
136
+ return WEB_PROTOCOLS.has(url.protocol) && url.host === host;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ function hasBodyType(request, type) {
142
+ return request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() === type;
143
+ }
144
+ async function readBody(request, maxBytes) {
145
+ return (await readBytes(request, maxBytes))?.toString("utf8");
146
+ }
147
+ async function readBytes(request, maxBytes) {
148
+ const chunks = [];
149
+ let size = 0;
150
+ for await (const chunk of request) {
151
+ size += chunk.length;
152
+ if (size <= maxBytes) chunks.push(chunk);
153
+ }
154
+ return size > maxBytes ? void 0 : Buffer.concat(chunks);
155
+ }
156
+ // src/responses.ts
157
+ const EDITOR_HEADERS = {
158
+ "content-security-policy": "default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'",
159
+ "referrer-policy": "same-origin",
160
+ "x-content-type-options": "nosniff"
161
+ };
162
+ const HTML_TYPE = "text/html; charset=utf-8";
163
+ const JSON_TYPE = "application/json; charset=utf-8";
164
+ const TEXT_TYPE = "text/plain; charset=utf-8";
165
+ function send(response, status, headers, body) {
166
+ response.writeHead(status, {
167
+ ...EDITOR_HEADERS,
168
+ ...headers
169
+ });
170
+ response.end(body);
171
+ }
172
+ function sendText(response, status, text, headers = {}) {
173
+ send(response, status, {
174
+ ...headers,
175
+ "content-type": TEXT_TYPE
176
+ }, text);
177
+ }
178
+ function sendNoContent(response, headers) {
179
+ response.writeHead(204, {
180
+ ...EDITOR_HEADERS,
181
+ ...headers
182
+ });
183
+ response.end();
184
+ }
185
+ function sendPage(response, html, headers = {}) {
186
+ response.writeHead(200, {
187
+ ...headers,
188
+ "content-type": HTML_TYPE,
189
+ "x-content-type-options": "nosniff"
190
+ });
191
+ response.end(html);
192
+ }
193
+ // src/content-api.ts
194
+ const PAGES_PATH = "/pages";
195
+ const MAX_PAGE_BYTES = 1048576;
196
+ function createContentApi(schema, store, pagePaths) {
197
+ let pendingWrite = Promise.resolve();
198
+ async function listPages(response) {
199
+ const pages = summarizePages(await readStoredDocuments(store));
200
+ send(response, 200, { "content-type": JSON_TYPE }, JSON.stringify({ pages }));
201
+ }
202
+ async function getPage(response, id) {
203
+ const text = await store.read(id);
204
+ if (text === void 0) {
205
+ sendText(response, 404, "The page does not exist.");
206
+ return;
207
+ }
208
+ send(response, 200, {
209
+ "content-type": JSON_TYPE,
210
+ etag: entityTag(text)
211
+ }, text);
212
+ }
213
+ async function putPage(request, response, id) {
214
+ if (!hasSiteOrigin(request)) {
215
+ sendText(response, 403, "The request must come from a page of this site.");
216
+ return;
217
+ }
218
+ if (!hasBodyType(request, "application/json")) {
219
+ sendText(response, 415, "The body must have the type application/json.");
220
+ return;
221
+ }
222
+ const body = await readBody(request, MAX_PAGE_BYTES);
223
+ if (body === void 0) {
224
+ sendText(response, 413, "The body is larger than 1 MB.");
225
+ return;
226
+ }
227
+ const page = readPageBody(schema, body, id);
228
+ if ("error" in page) {
229
+ sendText(response, 400, page.error);
230
+ return;
231
+ }
232
+ const write = pendingWrite.then(() => savePage(request, response, page.json, page.document));
233
+ pendingWrite = write.catch(() => void 0);
234
+ await write;
235
+ }
236
+ async function savePage(request, response, json, document) {
237
+ const { id } = document.root;
238
+ const currentText = await store.read(id);
239
+ const saveError = findSaveError({
240
+ conditions: {
241
+ ifMatch: request.headers["if-match"],
242
+ ifNoneMatch: request.headers["if-none-match"]
243
+ },
244
+ currentTag: currentText === void 0 ? void 0 : entityTag(currentText),
245
+ paths: await pagePaths.read(),
246
+ document
247
+ });
248
+ if (saveError !== void 0) {
249
+ sendText(response, saveError.status, saveError.message);
250
+ return;
251
+ }
252
+ const text = `${JSON.stringify(json, null, 2)}\n`;
253
+ await store.write(id, text);
254
+ pagePaths.change(id, document.root.props.path);
255
+ sendNoContent(response, { etag: entityTag(text) });
256
+ }
257
+ return async (request, response, path) => {
258
+ if (path === PAGES_PATH) {
259
+ if (request.method !== "GET") {
260
+ sendText(response, 405, "This address accepts only GET.", { allow: "GET" });
261
+ return;
262
+ }
263
+ await listPages(response);
264
+ return;
265
+ }
266
+ const id = path.startsWith(`${PAGES_PATH}/`) ? path.slice(7) : "";
267
+ if (!isPageId(id)) {
268
+ sendText(response, 404, "The page does not exist.");
269
+ return;
270
+ }
271
+ if (request.method === "GET") {
272
+ await getPage(response, id);
273
+ return;
274
+ }
275
+ if (request.method === "PUT") {
276
+ await putPage(request, response, id);
277
+ return;
278
+ }
279
+ sendText(response, 405, "This address accepts only GET and PUT.", { allow: "GET, PUT" });
280
+ };
281
+ }
282
+ function entityTag(text) {
283
+ return `"${createHash("sha256").update(text).digest("base64url")}"`;
284
+ }
285
+ // src/editor-page.ts
286
+ const EDITOR_PAGE = `<!doctype html>
287
+ <html lang="en">
288
+ <head>
289
+ <meta charset="utf-8" />
290
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
291
+ <title>Edit the site</title>
292
+ <style>
293
+ html,
294
+ body {
295
+ height: 100%;
296
+ margin: 0;
297
+ }
298
+ </style>
299
+ <script type="module" src="${EDITOR_SCRIPT_PATH}"><\/script>
300
+ <script type="module" src="${STORAGE_SCRIPT_PATH}"><\/script>
301
+ </head>
302
+ <body>
303
+ <lhcms-editor
304
+ schema-url="${SCHEMA_PATH}"
305
+ preview-url="${PREVIEW_PATH}"
306
+ ></lhcms-editor>
307
+ </body>
308
+ </html>
309
+ `;
310
+ // src/bridge-script.ts
311
+ const BODY_END = "</body>";
312
+ function withBridgeScript(html, scriptPath) {
313
+ const tag = `<script type="module" src="${scriptPath}"><\/script>`;
314
+ const bodyEnd = html.lastIndexOf(BODY_END);
315
+ if (bodyEnd === -1) return `${html}${tag}`;
316
+ return `${html.slice(0, bodyEnd)}${tag}${html.slice(bodyEnd)}`;
317
+ }
318
+ // src/preview.ts
319
+ const FORM_TYPE = "application/x-www-form-urlencoded";
320
+ const DOCUMENT_FIELD = "document";
321
+ const MAX_PREVIEW_BYTES = 2097152;
322
+ const PREVIEW_HEADERS = {
323
+ "cache-control": "no-store",
324
+ "x-frame-options": "SAMEORIGIN"
325
+ };
326
+ async function sendPreview(renderPreview, request, response) {
327
+ if (request.method !== "POST") {
328
+ sendText(response, 405, "This address accepts only POST.", { allow: "POST" });
329
+ return;
330
+ }
331
+ if (!hasSiteOrigin(request)) {
332
+ sendText(response, 403, "The request must come from a page of this site.");
333
+ return;
334
+ }
335
+ if (!hasBodyType(request, FORM_TYPE)) {
336
+ sendText(response, 415, `The body must have the type ${FORM_TYPE}.`);
337
+ return;
338
+ }
339
+ const body = await readBody(request, MAX_PREVIEW_BYTES);
340
+ if (body === void 0) {
341
+ sendText(response, 413, "The body is larger than 2 MB.");
342
+ return;
343
+ }
344
+ const documentText = new URLSearchParams(body).get(DOCUMENT_FIELD);
345
+ if (documentText === null) {
346
+ sendText(response, 400, `The form must have the field "${DOCUMENT_FIELD}".`);
347
+ return;
348
+ }
349
+ let html;
350
+ try {
351
+ html = await renderPreview(JSON.parse(documentText));
352
+ } catch (error) {
353
+ if (error instanceof SyntaxError || error instanceof DocumentError || error instanceof RenderError) {
354
+ sendText(response, 400, error.message);
355
+ return;
356
+ }
357
+ throw error;
358
+ }
359
+ sendPage(response, withBridgeScript(html, BRIDGE_SCRIPT_PATH), PREVIEW_HEADERS);
360
+ }
361
+ // src/records-api.ts
362
+ function findMissingSource(schema, sources) {
363
+ return [...schema.pages.values(), ...schema.blocks.values()].flatMap((type) => [...type.fields.values()]).flatMap((field) => field.type === "record" ? [field.source] : []).find((source) => !hasFunctions(Object.hasOwn(sources, source) ? sources[source] : void 0));
364
+ }
365
+ function hasFunctions(source) {
366
+ return typeof source?.lookup === "function" && typeof source.read === "function";
367
+ }
368
+ function readersOf(sources) {
369
+ return Object.fromEntries(Object.entries(sources).map(([name, { read }]) => [name, read]));
370
+ }
371
+ function readRecordQuery(search) {
372
+ const ids = search.getAll("id");
373
+ return ids.length > 0 ? { ids } : { text: search.get("text") ?? "" };
374
+ }
375
+ function createRecordsApi(sources) {
376
+ return async (request, response, source) => {
377
+ if (request.method !== "GET") {
378
+ sendText(response, 405, "This address accepts only GET.", { allow: "GET" });
379
+ return;
380
+ }
381
+ const recordSource = Object.hasOwn(sources, source) ? sources[source] : void 0;
382
+ if (recordSource === void 0) {
383
+ sendText(response, 404, "The site has no record source with this name.");
384
+ return;
385
+ }
386
+ const query = readRecordQuery(new URL(request.url ?? "", "http://localhost").searchParams);
387
+ const records = (await recordSource.lookup(query)).map(({ id, label }) => ({
388
+ id,
389
+ label
390
+ }));
391
+ send(response, 200, { "content-type": JSON_TYPE }, JSON.stringify({ records }));
392
+ };
393
+ }
394
+ // src/script-files.ts
395
+ const SCRIPT_TYPE = "text/javascript; charset=utf-8";
396
+ const require = createRequire(import.meta.url);
397
+ const SCRIPT_FILES = /* @__PURE__ */ new Map([[EDITOR_SCRIPT_PATH, builtFile("@loomhelix-cms/editor", "editor.js")], [BRIDGE_SCRIPT_PATH, builtFile("@loomhelix-cms/bridge", "bridge.js")]]);
398
+ async function sendScriptFile(response, { packageName, file }) {
399
+ let script;
400
+ try {
401
+ script = await readFile(file);
402
+ } catch (error) {
403
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
404
+ sendText(response, 404, `The script is not built. Run the build of ${packageName}.`);
405
+ return;
406
+ }
407
+ throw error;
408
+ }
409
+ send(response, 200, { "content-type": SCRIPT_TYPE }, script);
410
+ }
411
+ function builtFile(packageName, fileName) {
412
+ const packageFolder = dirname(require.resolve(`${packageName}/package.json`));
413
+ return {
414
+ packageName,
415
+ file: join(packageFolder, "dist", fileName)
416
+ };
417
+ }
418
+ // src/storage-script.ts
419
+ function storageScript(acceptsUploads) {
420
+ return `import { createHttpStorage } from '${EDITOR_SCRIPT_PATH}';
421
+
422
+ const storage = ${acceptsUploads ? `createHttpStorage('${API_PATH}')` : `{ ...createHttpStorage('${API_PATH}'), upload: undefined }`};
423
+ for (const editor of document.querySelectorAll('lhcms-editor')) {
424
+ editor.storage ??= storage;
425
+ }
426
+ `;
427
+ }
428
+ // src/image-files.ts
429
+ const PNG_START = [
430
+ 137,
431
+ 80,
432
+ 78,
433
+ 71,
434
+ 13,
435
+ 10,
436
+ 26,
437
+ 10
438
+ ];
439
+ const JPEG_START = [
440
+ 255,
441
+ 216,
442
+ 255
443
+ ];
444
+ const UPLOAD_NAME_PATTERN = /^[0-9a-f]{32}\.(?:png|jpg|webp|gif|avif)$/;
445
+ const KNOWN_FORMATS = [
446
+ {
447
+ type: "image/png",
448
+ extension: ".png",
449
+ signatures: [[{
450
+ offset: 0,
451
+ bytes: PNG_START
452
+ }]]
453
+ },
454
+ {
455
+ type: "image/jpeg",
456
+ extension: ".jpg",
457
+ signatures: [[{
458
+ offset: 0,
459
+ bytes: JPEG_START
460
+ }]]
461
+ },
462
+ {
463
+ type: "image/gif",
464
+ extension: ".gif",
465
+ signatures: [[{
466
+ offset: 0,
467
+ bytes: ascii("GIF87a")
468
+ }], [{
469
+ offset: 0,
470
+ bytes: ascii("GIF89a")
471
+ }]]
472
+ },
473
+ {
474
+ type: "image/webp",
475
+ extension: ".webp",
476
+ signatures: [[{
477
+ offset: 0,
478
+ bytes: ascii("RIFF")
479
+ }, {
480
+ offset: 8,
481
+ bytes: ascii("WEBP")
482
+ }]]
483
+ },
484
+ {
485
+ type: "image/avif",
486
+ extension: ".avif",
487
+ signatures: [[{
488
+ offset: 4,
489
+ bytes: ascii("ftypavif")
490
+ }], [{
491
+ offset: 4,
492
+ bytes: ascii("ftypavis")
493
+ }]]
494
+ }
495
+ ];
496
+ function findImageFormat(bytes) {
497
+ const format = KNOWN_FORMATS.find(({ signatures }) => signatures.some((signature) => signature.every((part) => hasBytes(bytes, part))));
498
+ return format === void 0 ? void 0 : {
499
+ type: format.type,
500
+ extension: format.extension
501
+ };
502
+ }
503
+ function imageTypeOfName(name) {
504
+ if (!UPLOAD_NAME_PATTERN.test(name)) return;
505
+ return KNOWN_FORMATS.find(({ extension }) => name.endsWith(extension))?.type;
506
+ }
507
+ function ascii(text) {
508
+ return Array.from({ length: text.length }, (_, index) => text.charCodeAt(index));
509
+ }
510
+ function hasBytes(bytes, { offset, bytes: expected }) {
511
+ return expected.every((byte, index) => bytes[offset + index] === byte);
512
+ }
513
+ // src/uploads.ts
514
+ const MAX_UPLOAD_BYTES = 10485760;
515
+ const CLOSED_BY_CLIENT = "ERR_STREAM_PREMATURE_CLOSE";
516
+ const FILE_HEADERS = {
517
+ "cache-control": "public, max-age=31536000, immutable",
518
+ "content-security-policy": "default-src 'none'",
519
+ "x-content-type-options": "nosniff"
520
+ };
521
+ function createFolderUploads(folder, path) {
522
+ const basePath = path.replace(/\/+$/, "");
523
+ if (!basePath.startsWith("/")) throw new TypeError(`The path of the uploaded files must start with / and contain a folder name, for example /uploads, got ${JSON.stringify(path)}.`);
524
+ return {
525
+ save: async ({ bytes, extension }) => {
526
+ const name = `${randomUUID().replaceAll("-", "")}${extension}`;
527
+ await mkdir(folder, { recursive: true });
528
+ await writeFile(join(folder, name), bytes, { flag: "wx" });
529
+ return `${basePath}/${name}`;
530
+ },
531
+ files: {
532
+ path: basePath,
533
+ open: async (name) => imageTypeOfName(name) === void 0 ? void 0 : openExistingFile(join(folder, name))
534
+ }
535
+ };
536
+ }
537
+ async function openExistingFile(file) {
538
+ try {
539
+ const { size } = await stat(file);
540
+ return {
541
+ size,
542
+ stream: () => createReadStream(file)
543
+ };
544
+ } catch (error) {
545
+ if (error.code === "ENOENT") return;
546
+ throw error;
547
+ }
548
+ }
549
+ function createUploadsApi(store) {
550
+ return async (request, response) => {
551
+ if (request.method !== "POST") {
552
+ sendText(response, 405, "This address accepts only POST.", { allow: "POST" });
553
+ return;
554
+ }
555
+ if (store === void 0) {
556
+ sendText(response, 404, "The site does not accept uploads.");
557
+ return;
558
+ }
559
+ if (!hasSiteOrigin(request)) {
560
+ sendText(response, 403, "The request must come from a page of this site.");
561
+ return;
562
+ }
563
+ const bytes = await readBytes(request, MAX_UPLOAD_BYTES);
564
+ if (bytes === void 0) {
565
+ sendText(response, 413, "The file is larger than 10 MB.");
566
+ return;
567
+ }
568
+ const format = findImageFormat(bytes);
569
+ if (format === void 0) {
570
+ sendText(response, 415, "The file is not a PNG, JPEG, WebP, GIF, or AVIF image.");
571
+ return;
572
+ }
573
+ const src = await store.save({
574
+ ...format,
575
+ bytes
576
+ });
577
+ send(response, 201, { "content-type": JSON_TYPE }, JSON.stringify({ src }));
578
+ };
579
+ }
580
+ async function sendUploadedFile(request, response, files, name) {
581
+ const type = imageTypeOfName(name);
582
+ const file = type === void 0 ? void 0 : await files.open(name);
583
+ if (type === void 0 || file === void 0) {
584
+ sendText(response, 404, "The file does not exist.");
585
+ return;
586
+ }
587
+ const entityTag = `"${name}"`;
588
+ const headers = {
589
+ ...FILE_HEADERS,
590
+ etag: entityTag
591
+ };
592
+ if (hasEntityTag(request.headers["if-none-match"], entityTag)) {
593
+ response.writeHead(304, headers);
594
+ response.end();
595
+ return;
596
+ }
597
+ response.writeHead(200, {
598
+ ...headers,
599
+ "content-type": type,
600
+ "content-length": file.size
601
+ });
602
+ if (request.method === "HEAD") {
603
+ response.end();
604
+ return;
605
+ }
606
+ try {
607
+ await pipeline(file.stream(), response);
608
+ } catch (error) {
609
+ if (error.code !== CLOSED_BY_CLIENT) throw error;
610
+ }
611
+ }
612
+ function hasEntityTag(header, entityTag) {
613
+ return (header ?? "").split(",").map((tag) => tag.trim().replace(/^W\//, "")).some((tag) => tag === entityTag || tag === "*");
614
+ }
615
+ // src/create-cms.ts
616
+ const PATH_INDEX_LIFETIME = 5e3;
617
+ const READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
618
+ async function createCms(options) {
619
+ if (typeof options.canEdit !== "function") throw new TypeError("createCms needs the option \"canEdit\": a function that tells if a request can use the editor.");
620
+ const schemaJson = await readSchemaSource(process.cwd(), options.schemaFile);
621
+ const schema = parseSchema(schemaJson);
622
+ const recordSources = options.records ?? {};
623
+ const missingSource = findMissingSource(schema, recordSources);
624
+ if (missingSource !== void 0) throw new TypeError(`The schema has a record field with the source "${missingSource}". Give createCms the option "records" with the functions lookup and read for "${missingSource}".`);
625
+ const schemaText = JSON.stringify(schemaJson);
626
+ const render = createRenderer(schema, options.templates);
627
+ const blockPreviewPage = options.blockPreviewPage ?? defaultBlockPreviewPage;
628
+ const isBlockDocument = (json) => schema.blocks.has(currentTypeName(schema, String(rootTypeOf(json))));
629
+ const readShownDocument = async (id) => (await readStoredDocument(options.store, id))?.document;
630
+ const recordReaders = readersOf(recordSources);
631
+ const renderDocument = async (json, mode) => {
632
+ const documents = await loadDocumentSet(schema, json, readShownDocument);
633
+ return render(await loadRecords(schema, documents, recordReaders), mode);
634
+ };
635
+ const renderPreview = async (json) => {
636
+ const html = await renderDocument(json, "preview");
637
+ return isBlockDocument(json) ? blockPreviewPage(html) : html;
638
+ };
639
+ let pathIndex;
640
+ const contentApi = createContentApi(schema, options.store, {
641
+ read: readPathIndex,
642
+ change: (id, path) => {
643
+ if (pathIndex !== void 0) keepPathIndex(pathIndex.paths.then((paths) => withPagePath(paths, id, path)), pathIndex.readAt);
644
+ }
645
+ });
646
+ const recordsApi = createRecordsApi(recordSources);
647
+ const uploadsApi = createUploadsApi(options.uploads);
648
+ const uploadedFiles = options.uploads?.files;
649
+ const sendEditorPage = (response) => send(response, 200, { "content-type": HTML_TYPE }, EDITOR_PAGE);
650
+ const editorFiles = new Map([
651
+ [CMS_PATH, sendEditorPage],
652
+ [`${CMS_PATH}/`, sendEditorPage],
653
+ [SCHEMA_PATH, (response) => send(response, 200, { "content-type": JSON_TYPE }, schemaText)],
654
+ [STORAGE_SCRIPT_PATH, (response) => send(response, 200, { "content-type": SCRIPT_TYPE }, storageScript(options.uploads !== void 0))],
655
+ ...[...SCRIPT_FILES].map(([path, script]) => [path, (response) => sendScriptFile(response, script)])
656
+ ]);
657
+ async function handleCmsRequest(request, response, pathname) {
658
+ if (!await options.canEdit(request)) {
659
+ sendText(response, 403, "This request cannot use the editor. Sign in as an editor first.");
660
+ return;
661
+ }
662
+ if (pathname === "/cms/api/uploads") {
663
+ await uploadsApi(request, response);
664
+ return;
665
+ }
666
+ if (pathname.startsWith("/cms/api/records/")) {
667
+ await recordsApi(request, response, pathname.slice(17));
668
+ return;
669
+ }
670
+ if (pathname === "/cms/api" || pathname.startsWith(`/cms/api/`)) {
671
+ await contentApi(request, response, pathname.slice(8));
672
+ return;
673
+ }
674
+ if (pathname === "/cms/preview") {
675
+ await sendPreview(renderPreview, request, response);
676
+ return;
677
+ }
678
+ const sendFile = editorFiles.get(pathname);
679
+ if (sendFile === void 0) {
680
+ sendText(response, 404, "The editor has nothing at this address.");
681
+ return;
682
+ }
683
+ if (request.method !== "GET") {
684
+ sendText(response, 405, "This address accepts only GET.", { allow: "GET" });
685
+ return;
686
+ }
687
+ await sendFile(response);
688
+ }
689
+ function readPathIndex() {
690
+ const now = Date.now();
691
+ if (pathIndex === void 0 || now - pathIndex.readAt >= PATH_INDEX_LIFETIME) return keepPathIndex(readStoredDocuments(options.store).then(indexPaths), now);
692
+ return pathIndex.paths;
693
+ }
694
+ function keepPathIndex(paths, readAt) {
695
+ pathIndex = {
696
+ paths,
697
+ readAt
698
+ };
699
+ paths.catch(() => {
700
+ if (pathIndex?.paths === paths) pathIndex = void 0;
701
+ });
702
+ return paths;
703
+ }
704
+ async function findLivePage(pathname) {
705
+ const path = decodePath(pathname);
706
+ const id = path === void 0 ? void 0 : (await readPathIndex()).get(path);
707
+ if (path === void 0 || id === void 0) return;
708
+ const page = await readStoredDocument(options.store, id);
709
+ return page === void 0 ? void 0 : findPageByPath([page], path);
710
+ }
711
+ async function route(request, response, next) {
712
+ const { pathname } = new URL(request.url ?? "/", "http://localhost");
713
+ if (pathname === "/cms" || pathname.startsWith(`/cms/`)) {
714
+ await handleCmsRequest(request, response, pathname);
715
+ return;
716
+ }
717
+ const isRead = READ_METHODS.has(request.method ?? "");
718
+ if (uploadedFiles !== void 0 && isRead && pathname.startsWith(`${uploadedFiles.path}/`)) {
719
+ const name = pathname.slice(uploadedFiles.path.length + 1);
720
+ await sendUploadedFile(request, response, uploadedFiles, name);
721
+ return;
722
+ }
723
+ const page = isRead ? await findLivePage(pathname) : void 0;
724
+ if (page !== void 0) {
725
+ sendPage(response, await renderDocument(page.document, "live"));
726
+ return;
727
+ }
728
+ if (next === void 0) {
729
+ sendText(response, 404, "The page does not exist.");
730
+ return;
731
+ }
732
+ next();
733
+ }
734
+ async function renderBlock(id) {
735
+ const text = await options.store.read(id);
736
+ if (text === void 0) return;
737
+ const json = JSON.parse(text);
738
+ if (!isBlockDocument(json)) throw new TypeError(`The document "${id}" is not a block document. renderBlock renders a document with a block type at the root.`);
739
+ return renderDocument(json, "live");
740
+ }
741
+ return {
742
+ renderBlock,
743
+ handle: async (request, response, next) => {
744
+ try {
745
+ await route(request, response, next);
746
+ } catch (error) {
747
+ if (next !== void 0) {
748
+ next(error);
749
+ return;
750
+ }
751
+ console.error(error);
752
+ if (!response.headersSent) sendText(response, 500, "The server cannot answer this request.");
753
+ }
754
+ }
755
+ };
756
+ }
757
+ function decodePath(pathname) {
758
+ try {
759
+ return decodeURIComponent(pathname);
760
+ } catch {
761
+ return;
762
+ }
763
+ }
764
+ // src/folder-store.ts
765
+ const PAGE_FILE_EXTENSION = ".json";
766
+ function createFolderStore(folder) {
767
+ return {
768
+ list: () => listPages(folder),
769
+ read: (id) => readPage(folder, id),
770
+ write: (id, text) => writePage(folder, id, text)
771
+ };
772
+ }
773
+ async function listPages(folder) {
774
+ const ids = (await readdir(folder)).filter((name) => name.endsWith(PAGE_FILE_EXTENSION)).map((name) => name.slice(0, -5)).filter(isPageId).toSorted();
775
+ return Promise.all(ids.map(async (id) => ({
776
+ id,
777
+ text: await readFile(pageFile(folder, id), "utf8")
778
+ })));
779
+ }
780
+ async function readPage(folder, id) {
781
+ try {
782
+ return await readFile(pageFile(folder, id), "utf8");
783
+ } catch (error) {
784
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
785
+ throw error;
786
+ }
787
+ }
788
+ async function writePage(folder, id, text) {
789
+ const file = pageFile(folder, id);
790
+ const temporaryFile = `${file}.${randomUUID()}.tmp`;
791
+ await writeFile(temporaryFile, text, "utf8");
792
+ await rename(temporaryFile, file);
793
+ }
794
+ function pageFile(folder, id) {
795
+ if (!isPageId(id)) throw new TypeError(`The page id ${JSON.stringify(id)} can contain only letters, digits, hyphens, and underscores.`);
796
+ return join(folder, `${id}${PAGE_FILE_EXTENSION}`);
797
+ }
798
+ export { createCms, createFolderStore, createFolderUploads };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@loomhelix-cms/node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The server part for a Node.js site: the editor page, the content API, the preview, and the stored pages.",
5
+ "homepage": "https://github.com/loomhelix/loomhelix-cms/tree/main/packages/node#readme",
6
+ "bugs": "https://github.com/loomhelix/loomhelix-cms/issues",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/loomhelix/loomhelix-cms.git",
11
+ "directory": "packages/node"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": "./dist/index.mjs",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@loomhelix-cms/bridge": "^0.1.0",
27
+ "@loomhelix-cms/cli": "^0.1.0",
28
+ "@loomhelix-cms/editor": "^0.1.0",
29
+ "@loomhelix-cms/render-html": "^0.1.0",
30
+ "@loomhelix-cms/schema": "^0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.20.5",
34
+ "typescript": "^7.0.2",
35
+ "vite-plus": "1.5.0"
36
+ },
37
+ "engines": {
38
+ "node": "^22.18.0 || ^24.11.0 || >=26.0.0"
39
+ },
40
+ "scripts": {
41
+ "build": "vp pack"
42
+ }
6
43
  }