mechanica-shared 2.0.0-alpha.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 den59k
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 ADDED
@@ -0,0 +1,10 @@
1
+ # mechanica-shared
2
+
3
+ Internal support package for [mechanica](https://www.npmjs.com/package/mechanica) — DOM-free types, the field-type registry, schema/default helpers, the page-generation (SSG) core, and the `.page.md` page-format codec.
4
+
5
+ You normally don't install this directly; it comes with `mechanica`. It is published separately so server-side renderers can consume it without the editor/runtime.
6
+
7
+ Entry points:
8
+
9
+ - `mechanica-shared` — types, field registry, schema helpers, `generatePage` and the `{{ }}` HTML templating engine. DOM-free by contract.
10
+ - `mechanica-shared/page-format` — `parsePage` / `serializePage` for the `.page.md` on-disk format. A separate entry point on purpose: it pulls in a YAML parser that must never reach a client bundle.
package/dist/index.js ADDED
@@ -0,0 +1,476 @@
1
+ import { registerAlias } from "compact-json-schema";
2
+ //#region src/fields.ts
3
+ /** The field types registered by default. */
4
+ var builtinFields = [
5
+ {
6
+ name: "image",
7
+ schema: {
8
+ type: "object",
9
+ format: "image",
10
+ properties: {
11
+ src: "string",
12
+ previewSrc: "string?"
13
+ }
14
+ },
15
+ default: () => ({ src: "" })
16
+ },
17
+ {
18
+ name: "file",
19
+ schema: {
20
+ type: "object",
21
+ format: "file",
22
+ properties: { src: "string" }
23
+ },
24
+ default: () => ({ src: "" })
25
+ },
26
+ {
27
+ name: "text",
28
+ schema: {
29
+ type: "string",
30
+ format: "text"
31
+ }
32
+ },
33
+ {
34
+ name: "color",
35
+ schema: {
36
+ type: "string",
37
+ format: "color"
38
+ }
39
+ },
40
+ {
41
+ name: "smartLink",
42
+ schema: {
43
+ type: "object",
44
+ format: "smartLink",
45
+ properties: {
46
+ url: "string",
47
+ title: "string",
48
+ external: "boolean",
49
+ openNewTab: "boolean"
50
+ }
51
+ }
52
+ },
53
+ {
54
+ name: "multiselect",
55
+ schema: {
56
+ type: "array",
57
+ format: "multiselect",
58
+ items: "string"
59
+ }
60
+ },
61
+ {
62
+ name: "richText",
63
+ schema: {
64
+ type: "array",
65
+ format: "richText",
66
+ items: {
67
+ type: "object",
68
+ properties: {
69
+ text: "string",
70
+ type: "string?",
71
+ styles: "object?"
72
+ }
73
+ }
74
+ },
75
+ default: () => [{ text: "" }]
76
+ }
77
+ ];
78
+ var fieldDefaults = /* @__PURE__ */ new Map();
79
+ var registered = false;
80
+ /**
81
+ * Register field types as compact-json-schema aliases and record their default
82
+ * values. Call once per runtime before unfolding any block/data schema.
83
+ *
84
+ * @param register Override the alias registrar (defaults to compact-json-schema's).
85
+ * @param fields Field set to register (defaults to {@link builtinFields}).
86
+ */
87
+ function registerFieldSchemas(register = registerAlias, fields = builtinFields) {
88
+ for (const field of fields) {
89
+ register(field.name, field.schema);
90
+ if (field.default !== void 0) fieldDefaults.set(field.name, field.default);
91
+ }
92
+ registered = true;
93
+ }
94
+ /** Resolve the default value for a registered field format, or `undefined`. */
95
+ function getFieldDefault(format) {
96
+ const value = fieldDefaults.get(format);
97
+ return typeof value === "function" ? value() : value;
98
+ }
99
+ /** Whether {@link registerFieldSchemas} has run in this runtime. */
100
+ function areFieldSchemasRegistered() {
101
+ return registered;
102
+ }
103
+ //#endregion
104
+ //#region src/schema.ts
105
+ /**
106
+ * Compute the default value for a (compact-unfolded) schema node, consulting the
107
+ * field registry for format-specific defaults (e.g. `image`, `richText`).
108
+ */
109
+ function getDefaultValue(schema) {
110
+ if (schema.default !== void 0) return schema.default;
111
+ if (schema.nullable) return null;
112
+ if (schema.format) {
113
+ const fieldDefault = getFieldDefault(schema.format);
114
+ if (fieldDefault !== void 0) return fieldDefault;
115
+ }
116
+ if (schema.type === "number" || schema.type === "integer") return 0;
117
+ if (schema.type === "object") {
118
+ if (!schema.properties) return {};
119
+ return Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => {
120
+ if (!schema.required?.includes(key)) return [key, void 0];
121
+ return [key, getDefaultValue(value)];
122
+ }));
123
+ }
124
+ if (schema.type === "array") return [];
125
+ if (schema.type === "boolean") return false;
126
+ return "";
127
+ }
128
+ /**
129
+ * Fill missing values in `state` with schema defaults, recursing into objects.
130
+ * Returns `state` when present, otherwise a freshly generated default.
131
+ */
132
+ function passDefaultValue(state, schema) {
133
+ if (!schema) return state;
134
+ if (schema.type === "object" && state) for (const key in schema.properties) {
135
+ if (!(key in state) && !schema.required?.includes(key)) continue;
136
+ state[key] = passDefaultValue(state[key], schema.properties[key]);
137
+ }
138
+ return state ?? getDefaultValue(schema);
139
+ }
140
+ function isPlainObject(value) {
141
+ return typeof value === "object" && value !== null && !Array.isArray(value);
142
+ }
143
+ /** Deep-merge `patch` over `base`: plain objects merge, arrays and scalars replace. */
144
+ function mergePreviewData(base, patch) {
145
+ const out = { ...base };
146
+ for (const [key, value] of Object.entries(patch)) {
147
+ const current = out[key];
148
+ out[key] = isPlainObject(current) && isPlainObject(value) ? mergePreviewData(current, value) : value;
149
+ }
150
+ return out;
151
+ }
152
+ /**
153
+ * Resolve the data a block should render with outside a page: schema defaults,
154
+ * overlaid with the block's authored `previewData`, overlaid with per-call
155
+ * overrides (e.g. the `?data=` payload of the preview route).
156
+ *
157
+ * @param props Unfolded (JSON-schema shaped) props schema, as on `Block.props`.
158
+ */
159
+ function buildPreviewData(props, previewData, overrides) {
160
+ const defaults = props ? getDefaultValue(props) : {};
161
+ return mergePreviewData(mergePreviewData(isPlainObject(defaults) ? defaults : {}, previewData ?? {}), overrides ?? {});
162
+ }
163
+ /** Depth-first walk over a content tree, including array and named-slot children. */
164
+ function walkTree(blocks, callback) {
165
+ for (const block of blocks) {
166
+ callback(block);
167
+ if (!block.children) continue;
168
+ if (Array.isArray(block.children)) walkTree(block.children, callback);
169
+ else for (const list of Object.values(block.children)) walkTree(list, callback);
170
+ }
171
+ }
172
+ /** Walk a value alongside its schema, invoking `callback` for each described node. */
173
+ function walkSchema(obj, schema, callback) {
174
+ if (schema.type === "object" && schema.properties && obj) for (const [key, childSchema] of Object.entries(schema.properties)) {
175
+ const isRequired = schema.required?.includes(key) ?? false;
176
+ callback(obj[key], childSchema, key, obj, isRequired);
177
+ const childType = childSchema.type;
178
+ if (childType === "array" || childType === "object") walkSchema(obj[key], childSchema, callback);
179
+ }
180
+ if (schema.type === "array" && schema.items && obj) for (const value of obj) {
181
+ callback(value, schema.items);
182
+ if (schema.items.type === "array" || schema.items.type === "object") walkSchema(value, schema.items, callback);
183
+ }
184
+ }
185
+ /** Resolve a dotted path within a data object (`'postMeta.date'`). */
186
+ function getValueByPath(data, path) {
187
+ let value = data;
188
+ for (const key of path.split(".")) {
189
+ if (value == null) return value;
190
+ value = value[key];
191
+ }
192
+ return value;
193
+ }
194
+ //#endregion
195
+ //#region src/generate-page.ts
196
+ var HTML_ESCAPES = {
197
+ "&": "&",
198
+ "<": "&lt;",
199
+ ">": "&gt;",
200
+ "\"": "&quot;"
201
+ };
202
+ /** Escape a templated value so it's safe in element text and attribute values. */
203
+ function escapeHtml(value) {
204
+ return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]);
205
+ }
206
+ /**
207
+ * Substitute `{{ a.b }}` placeholders in an HTML string with data values. Used to
208
+ * template the `<head>` (title, meta, Open Graph, …) from `defineData` values and
209
+ * the current page. Resolved values are HTML-escaped.
210
+ */
211
+ function passDataToHTML(html, data) {
212
+ return html.replace(/\{\{(.+?)\}\}/g, (_match, expr) => escapeHtml(String(getValueByPath(data, expr.trim()) ?? "")));
213
+ }
214
+ /** Render a single page into the index template with serialized state. */
215
+ async function generatePage(options) {
216
+ walkTree(options.state.content, (block) => {
217
+ const meta = options.blocksMap.get(block.blockId);
218
+ if (meta) passDefaultValue(block.data, meta.props);
219
+ });
220
+ const merged = {
221
+ ...options.projectData,
222
+ ...options.state.data
223
+ };
224
+ const data = Object.fromEntries(options.dataEntries.map((entry) => [entry.id, passDefaultValue(merged[entry.id] ?? {}, entry.props)]));
225
+ const state = {
226
+ content: options.state.content,
227
+ data,
228
+ baseUrl: options.baseUrl,
229
+ page: {
230
+ path: options.path,
231
+ ...options.state.page
232
+ }
233
+ };
234
+ const result = await options.render(state, options.path ?? "");
235
+ const rendered = typeof result === "string" ? result : result.html;
236
+ const query = (typeof result === "string" ? void 0 : result.query) ?? {};
237
+ if (Object.keys(query).length) state.query = query;
238
+ let index = passDataToHTML(options.index, {
239
+ ...data,
240
+ page: options.state.page
241
+ });
242
+ const appMatch = index.match(/(<div[^>]*\bid="app"[^>]*>)([\s\S]*?)<\/div>/);
243
+ if (!appMatch) throw new Error("index.html has no <div id=\"app\"> container — the rendered page has nowhere to go. Add <div id=\"app\"></div> to the template body.");
244
+ const start = appMatch.index + appMatch[1].length;
245
+ const end = appMatch.index + appMatch[0].length - 6;
246
+ index = index.slice(0, start) + rendered + index.slice(end);
247
+ const links = options.pageLinks?.(options.state.content) ?? [];
248
+ if (links.length) index = index.replace("</head>", `${links.join("\n")}\n</head>`);
249
+ if (options.assetsUrl) index = index.replace(/\/assets\//g, options.assetsUrl);
250
+ const stateScript = `<script>window.state=${serializeState(state)}<\/script>`;
251
+ return {
252
+ html: index.replace("</body>", `${stateScript}\n</body>`),
253
+ query
254
+ };
255
+ }
256
+ var UNSAFE_IN_SCRIPT = new RegExp(`[${[
257
+ 60,
258
+ 8232,
259
+ 8233
260
+ ].map((c) => "\\u" + c.toString(16).padStart(4, "0")).join("")}]`, "g");
261
+ /**
262
+ * Serialize runtime state for embedding in an inline `<script>`. Plain JSON is
263
+ * unsafe (a `<\/script>` in the data would close the tag early), so the few
264
+ * dangerous characters are escaped to their `\uXXXX` form — valid JSON/JS that
265
+ * `window.state` and the router's regex read back unchanged.
266
+ */
267
+ function serializeState(state) {
268
+ return JSON.stringify(state).replace(UNSAFE_IN_SCRIPT, (ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
269
+ }
270
+ /** Render every page of a project, yielding the html, path and resolved queries. */
271
+ async function* generateProject(options) {
272
+ for (const page of options.pages) {
273
+ page.content = page.content ?? [];
274
+ if (options.onFile) collectFiles(page.content, options.blocksMap, options.onFile);
275
+ const { html, query } = await generatePage({
276
+ ...options,
277
+ state: page,
278
+ path: page.path
279
+ });
280
+ yield {
281
+ html,
282
+ path: page.path,
283
+ query
284
+ };
285
+ }
286
+ }
287
+ /** Rewrite asset references (image/file/richText) through `onFile`. */
288
+ function collectFiles(content, blocksMap, onFile) {
289
+ walkTree(content, (block) => {
290
+ const meta = blocksMap.get(block.blockId);
291
+ if (!meta) return;
292
+ walkSchema(block.data, meta.props, (value, schema) => {
293
+ if (!value) return;
294
+ if (schema.format === "image" || schema.format === "file") {
295
+ if (value.src) value.src = onFile(value.src);
296
+ if (value.previewSrc) value.previewSrc = onFile(value.previewSrc);
297
+ }
298
+ if (schema.format === "richText" && schema.type === "array") for (const row of value) {
299
+ if (row.image?.src) row.image.src = onFile(row.image.src);
300
+ if (row.image?.previewSrc) row.image.previewSrc = onFile(row.image.previewSrc);
301
+ }
302
+ });
303
+ });
304
+ }
305
+ //#endregion
306
+ //#region src/validate-links.ts
307
+ /** Normalize an internal URL for page-path comparison (drop query/hash/trailing slash). */
308
+ function normalizeInternalUrl(url) {
309
+ const trimmed = url.split(/[?#]/)[0].replace(/\/+$/, "");
310
+ return trimmed === "" ? "/" : trimmed;
311
+ }
312
+ /**
313
+ * Collect the internal link targets on a page: every `smartLink`-formatted
314
+ * field whose URL is site-relative (starts with `/`) and not marked external.
315
+ */
316
+ function collectInternalLinks(content, blocksMap) {
317
+ const found = [];
318
+ walkTree(content, (block) => {
319
+ const meta = blocksMap.get(block.blockId);
320
+ if (!meta) return;
321
+ walkSchema(block.data, meta.props, (value, schema) => {
322
+ if (schema?.format !== "smartLink" || value == null) return;
323
+ const url = typeof value === "string" ? value : value.url;
324
+ const external = typeof value === "object" && value.external === true;
325
+ if (typeof url === "string" && url.startsWith("/") && !external) found.push(url);
326
+ });
327
+ });
328
+ return found;
329
+ }
330
+ /**
331
+ * Cross-check every page's internal `smartLink` targets against the set of
332
+ * page paths that actually exist. Returns the dead links (empty = all good).
333
+ * Meant for export/deploy time — a warning, not a hard failure, since a target
334
+ * may be intentionally served by something else (redirects, external hosting).
335
+ */
336
+ function validateLinks(pages, blocksMap) {
337
+ const known = new Set(pages.map((page) => normalizeInternalUrl(page.path)));
338
+ const issues = [];
339
+ for (const page of pages) for (const url of collectInternalLinks(page.content ?? [], blocksMap)) if (!known.has(normalizeInternalUrl(url))) issues.push({
340
+ page: page.path,
341
+ url
342
+ });
343
+ return issues;
344
+ }
345
+ //#endregion
346
+ //#region src/migrate.ts
347
+ /**
348
+ * Upgrade placed blocks whose data was written with an older schema version.
349
+ *
350
+ * Each placed block records the schema version it was saved with (`v`, absent
351
+ * = 1). When a block type declares a newer `version`, its `migrate` hook runs
352
+ * with the stored data and the version it came from, then the block is
353
+ * stamped with the current version. Runs on load (dev state, export) so pages
354
+ * never render stale-shaped data; the upgrade persists with the next save.
355
+ *
356
+ * Returns whether anything changed.
357
+ */
358
+ function migrateContent(content, blocksMap) {
359
+ let changed = false;
360
+ walkTree(content, (block) => {
361
+ const meta = blocksMap.get(block.blockId);
362
+ const version = meta?.version;
363
+ if (!version) return;
364
+ const from = block.v ?? 1;
365
+ if (from >= version) return;
366
+ if (meta.migrate) {
367
+ const result = meta.migrate(block.data ?? {}, from);
368
+ if (result) block.data = result;
369
+ }
370
+ block.v = version;
371
+ changed = true;
372
+ });
373
+ return changed;
374
+ }
375
+ /**
376
+ * Block ids referenced by the content tree that the block registry doesn't
377
+ * know (deleted or renamed block types). These render as nothing — callers
378
+ * should surface them (export warning, editor badge).
379
+ */
380
+ function findUnknownBlocks(content, blocksMap) {
381
+ const unknown = /* @__PURE__ */ new Set();
382
+ walkTree(content, (block) => {
383
+ if (!blocksMap.has(block.blockId)) unknown.add(block.blockId);
384
+ });
385
+ return [...unknown];
386
+ }
387
+ //#endregion
388
+ //#region src/query-engine.ts
389
+ /** Split a query key (`"<type>.<json-args>"`) into its type and parsed args. */
390
+ function parseQueryKey(key) {
391
+ const dot = key.indexOf(".");
392
+ if (dot === -1) return {
393
+ type: key,
394
+ args: {}
395
+ };
396
+ const type = key.slice(0, dot);
397
+ const raw = key.slice(dot + 1);
398
+ try {
399
+ const parsed = JSON.parse(raw || "{}");
400
+ return {
401
+ type,
402
+ args: parsed && typeof parsed === "object" ? parsed : {}
403
+ };
404
+ } catch {
405
+ return {
406
+ type,
407
+ args: {}
408
+ };
409
+ }
410
+ }
411
+ /** Whether a key is a paginated `getPages` query (drives export page-splitting). */
412
+ function isPaginatedQuery(key) {
413
+ const { type, args } = parseQueryKey(key);
414
+ return type === "getPages" && typeof args.pageSize === "number" && args.pageSize > 0;
415
+ }
416
+ /** Compare possibly-missing values: numbers numerically, everything else as strings. */
417
+ function compareValues(a, b) {
418
+ if (a == null && b == null) return 0;
419
+ if (a == null) return 1;
420
+ if (b == null) return -1;
421
+ if (typeof a === "number" && typeof b === "number") return a - b;
422
+ return String(a).localeCompare(String(b));
423
+ }
424
+ /**
425
+ * Resolve a `getPages` query: filter by folder, sort, and either cap (`limit`)
426
+ * or paginate (`pageSize`). Internal ordering fields are stripped from results.
427
+ */
428
+ function resolvePagesQuery(source, args, context = {}) {
429
+ let pages = source.listPages({ data: args.data });
430
+ if (args.folderName) {
431
+ const indexPath = "/" + args.folderName.replace(/^\/+|\/+$/g, "");
432
+ pages = pages.filter((page) => page.folderPath === args.folderName && page.path !== indexPath);
433
+ }
434
+ if (args.sort?.by) {
435
+ const { by, dir } = args.sort;
436
+ const sign = dir === "desc" ? -1 : 1;
437
+ pages = pages.map((page) => ({
438
+ page,
439
+ field: getValueByPath(page, by)
440
+ })).sort((a, b) => {
441
+ if (a.field == null || b.field == null) return compareValues(a.field, b.field);
442
+ return compareValues(a.field, b.field) * sign;
443
+ }).map((entry) => entry.page);
444
+ }
445
+ const items = pages.map(({ order, orderAfter, folderPath, ...rest }) => rest);
446
+ if (args.pageSize && args.pageSize > 0) {
447
+ const total = items.length;
448
+ const pageCount = Math.max(1, Math.ceil(total / args.pageSize));
449
+ const page = Math.min(Math.max(1, context.page ?? 1), pageCount);
450
+ return {
451
+ items: items.slice((page - 1) * args.pageSize, page * args.pageSize),
452
+ page,
453
+ pageCount,
454
+ pageSize: args.pageSize,
455
+ total
456
+ };
457
+ }
458
+ return typeof args.limit === "number" && args.limit >= 0 ? items.slice(0, args.limit) : items;
459
+ }
460
+ /**
461
+ * Resolve any query key against a source. Unknown types resolve to `{}` (the
462
+ * runtime containers keep their initial shape). `fetch` keys require the
463
+ * source to provide `fetchJson`.
464
+ */
465
+ async function resolveQueryKey(source, key, context = {}) {
466
+ const { type, args } = parseQueryKey(key);
467
+ if (type === "getPages") return resolvePagesQuery(source, args, context);
468
+ if (type === "fetch") {
469
+ if (!source.fetchJson) return {};
470
+ if (typeof args.url !== "string" || !args.url) return {};
471
+ return source.fetchJson(args);
472
+ }
473
+ return {};
474
+ }
475
+ //#endregion
476
+ export { areFieldSchemasRegistered, buildPreviewData, builtinFields, collectInternalLinks, findUnknownBlocks, generatePage, generateProject, getDefaultValue, getFieldDefault, getValueByPath, isPaginatedQuery, mergePreviewData, migrateContent, normalizeInternalUrl, parseQueryKey, passDataToHTML, passDefaultValue, registerFieldSchemas, resolvePagesQuery, resolveQueryKey, serializeState, validateLinks, walkSchema, walkTree };