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 +21 -0
- package/README.md +10 -0
- package/dist/index.js +476 -0
- package/dist/page-format.js +301 -0
- package/dist/types/fields.d.ts +34 -0
- package/dist/types/generate-page.d.ts +85 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/migrate.d.ts +19 -0
- package/dist/types/page-format.d.ts +43 -0
- package/dist/types/query-engine.d.ts +83 -0
- package/dist/types/schema.d.ts +29 -0
- package/dist/types/types.d.ts +122 -0
- package/dist/types/validate-links.d.ts +25 -0
- package/package.json +46 -0
- package/src/fields.ts +106 -0
- package/src/generate-page.ts +175 -0
- package/src/index.ts +66 -0
- package/src/migrate.ts +44 -0
- package/src/page-format.ts +413 -0
- package/src/query-engine.ts +162 -0
- package/src/schema.ts +137 -0
- package/src/types.ts +126 -0
- package/src/validate-links.ts +56 -0
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
|
+
"<": "<",
|
|
199
|
+
">": ">",
|
|
200
|
+
"\"": """
|
|
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 };
|