@mapled/cli 0.9.0 → 0.11.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/README.md +65 -2
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +84 -3
- package/dist/html5.d.ts +66 -0
- package/dist/html5.js +8009 -0
- package/dist/index.js +11 -2
- package/dist/licenses/entities.LICENSE +11 -0
- package/dist/licenses/parse5.LICENSE +19 -0
- package/dist/report.d.ts +127 -0
- package/dist/report.js +425 -0
- package/dist/scan.d.ts +1 -1
- package/dist/scan.js +4 -1
- package/dist/scanHtml.d.ts +38 -0
- package/dist/scanHtml.js +519 -0
- package/package.json +5 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ScanResult } from "./scan.js";
|
|
2
|
+
import type { Schema } from "./schema.js";
|
|
3
|
+
/** `mapled scan` for a plain HTML site (§19.2, "@mapled/vanilla"): the
|
|
4
|
+
pages are the site's .html files, the bindings are what their
|
|
5
|
+
`data-mapled-*` attributes declare — read the way the runtime reads
|
|
6
|
+
them (packages/vanilla/src/runtime.ts, `compile`): a path starts at a
|
|
7
|
+
single from the document and at the record inside a list or a record,
|
|
8
|
+
an each repeats the items of a group, a <template> counts only as the
|
|
9
|
+
first one of a list or an each, a list or a record inside a repeated
|
|
10
|
+
template is never read. The target is the sink the attribute names —
|
|
11
|
+
`markdown` renders rich text, an <img> an image, `bind-href` a link —
|
|
12
|
+
so a field bound where it can't render shows as a mismatch. What the
|
|
13
|
+
runtime can't tell apart — an empty field and a misspelled key — the
|
|
14
|
+
scan can: it has the schema. Nothing on the page runs or leaves the
|
|
15
|
+
machine; only the manifest does, on `bindings push`. */
|
|
16
|
+
export type HtmlElement = {
|
|
17
|
+
name: string;
|
|
18
|
+
attrs: Map<string, string>;
|
|
19
|
+
children: HtmlElement[];
|
|
20
|
+
};
|
|
21
|
+
/** The elements of a page and their attributes — parse5's tree (html5.ts),
|
|
22
|
+
a <template>'s children being its content. Built without recursion:
|
|
23
|
+
a page nests up to MAX_OPEN elements, deeper throws TooDeep. */
|
|
24
|
+
export declare function parseHtml(text: string): HtmlElement;
|
|
25
|
+
/** Where a file is served and the key its bindings start with:
|
|
26
|
+
index.html → "/" (index), blog/index.html → "/blog/" (blog/index),
|
|
27
|
+
post.html → "/post.html" (post). What sits in public/ is served from
|
|
28
|
+
the root, as static hosts and Vite serve it. A name a key can't spell
|
|
29
|
+
as it is — a space, a letter outside ASCII, too long — keeps what it
|
|
30
|
+
can and adds a hash of the file, so two files never share a key. */
|
|
31
|
+
export declare function htmlPage(file: string): {
|
|
32
|
+
route: string;
|
|
33
|
+
key: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function scanHtml(dir: string, opts: {
|
|
36
|
+
schema: Schema;
|
|
37
|
+
maxFiles?: number;
|
|
38
|
+
}): Promise<ScanResult>;
|
package/dist/scanHtml.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { MAX_OPEN, parseDocument, TooDeep } from "./html5.js";
|
|
5
|
+
import { targetFor } from "./manifest.js";
|
|
6
|
+
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
7
|
+
const MAX_DEPTH = MAX_OPEN;
|
|
8
|
+
/** The elements of a page and their attributes — parse5's tree (html5.ts),
|
|
9
|
+
a <template>'s children being its content. Built without recursion:
|
|
10
|
+
a page nests up to MAX_OPEN elements, deeper throws TooDeep. */
|
|
11
|
+
export function parseHtml(text) {
|
|
12
|
+
const root = { name: "#document", attrs: new Map(), children: [] };
|
|
13
|
+
const pending = [[parseDocument(text), root]];
|
|
14
|
+
for (let at = 0; at < pending.length; at++) {
|
|
15
|
+
const [node, into] = pending[at];
|
|
16
|
+
pending[at] = undefined;
|
|
17
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
18
|
+
if (child.tagName === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
const attrs = new Map();
|
|
21
|
+
for (const a of child.attrs ?? [])
|
|
22
|
+
if (!a.namespace)
|
|
23
|
+
attrs.set(a.name, a.value);
|
|
24
|
+
const el = { name: child.tagName, attrs, children: [] };
|
|
25
|
+
into.children.push(el);
|
|
26
|
+
const template = child.tagName === "template" && child.namespaceURI === HTML_NS;
|
|
27
|
+
pending.push([template && child.content ? child.content : child, el]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
/* ---- pages ---- */
|
|
33
|
+
const SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "out", "coverage", "storybook-static", "mapled"]);
|
|
34
|
+
const HTML_FILE = /\.html?$/i;
|
|
35
|
+
async function listHtml(dir, max) {
|
|
36
|
+
const files = [];
|
|
37
|
+
let truncated = false;
|
|
38
|
+
const walk = async (rel, depth) => {
|
|
39
|
+
if (depth > 12)
|
|
40
|
+
return;
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await readdir(path.join(dir, rel), { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
51
|
+
if (e.isDirectory()) {
|
|
52
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith("."))
|
|
53
|
+
continue;
|
|
54
|
+
await walk(next, depth + 1);
|
|
55
|
+
}
|
|
56
|
+
else if (e.isFile() && HTML_FILE.test(e.name)) {
|
|
57
|
+
if (files.length >= max) {
|
|
58
|
+
truncated = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
files.push(next);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
await walk("", 0);
|
|
66
|
+
return { files, truncated };
|
|
67
|
+
}
|
|
68
|
+
const hash8 = (text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
69
|
+
/** Where a file is served and the key its bindings start with:
|
|
70
|
+
index.html → "/" (index), blog/index.html → "/blog/" (blog/index),
|
|
71
|
+
post.html → "/post.html" (post). What sits in public/ is served from
|
|
72
|
+
the root, as static hosts and Vite serve it. A name a key can't spell
|
|
73
|
+
as it is — a space, a letter outside ASCII, too long — keeps what it
|
|
74
|
+
can and adds a hash of the file, so two files never share a key. */
|
|
75
|
+
export function htmlPage(file) {
|
|
76
|
+
const served = file.replace(/^public\//, "");
|
|
77
|
+
const parts = served.split("/");
|
|
78
|
+
const base = parts[parts.length - 1];
|
|
79
|
+
const dirs = parts.slice(0, -1).map(encodeURIComponent);
|
|
80
|
+
const isIndex = /^index\.html?$/i.test(base);
|
|
81
|
+
const route = isIndex ? `/${dirs.map((d) => `${d}/`).join("")}` : `/${[...dirs, encodeURIComponent(base)].join("/")}`;
|
|
82
|
+
const bare = served.replace(/\.html$/, "");
|
|
83
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._/-]{0,59}$/.test(bare))
|
|
84
|
+
return { route, key: bare };
|
|
85
|
+
const kept = bare.replace(/[^A-Za-z0-9._/-]/g, "-").slice(0, 51);
|
|
86
|
+
return { route, key: `${/^[A-Za-z0-9]/.test(kept) ? "" : "p"}${kept}-${hash8(served)}` };
|
|
87
|
+
}
|
|
88
|
+
/** A binding key within the manifest's 120 characters: a longer one
|
|
89
|
+
keeps its start and ends in a hash of the whole (the caller makes
|
|
90
|
+
sure two of them don't meet). */
|
|
91
|
+
function boundKey(key) {
|
|
92
|
+
return key.length <= 120 ? key : `${key.slice(0, 111)}-${hash8(key)}`;
|
|
93
|
+
}
|
|
94
|
+
/** Why Mapled's Preview can't open a route, or null — the API's rules
|
|
95
|
+
(lib/recordRoutes.ts, previewPath) and the manifest's length. */
|
|
96
|
+
const SAFE_PATH = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/[\]-]*(?:\?[A-Za-z0-9._~!$&'()*+,;=:@%/[\]-]*)?$/;
|
|
97
|
+
function unusable(route) {
|
|
98
|
+
if (route.length > 200)
|
|
99
|
+
return "longer than 200 characters";
|
|
100
|
+
if (!SAFE_PATH.test(route) || route.includes("//") || route.includes(".."))
|
|
101
|
+
return `not a path Mapled opens`;
|
|
102
|
+
const query = route.indexOf("?");
|
|
103
|
+
if (query !== -1 && new URLSearchParams(route.slice(query + 1)).has("mapled-preview"))
|
|
104
|
+
return "mapled-preview is the parameter of Mapled's preview token";
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/* ---- what the attributes declare ---- */
|
|
108
|
+
const P = "data-mapled-";
|
|
109
|
+
const KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
110
|
+
const SEGMENT = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
111
|
+
/** The runtime's path grammar (packages/vanilla/src/paths.ts). */
|
|
112
|
+
function segmentsOf(text) {
|
|
113
|
+
const segments = text.trim().split(".");
|
|
114
|
+
if (segments.length > 8)
|
|
115
|
+
return null;
|
|
116
|
+
return segments.every((s) => SEGMENT.test(s)) ? segments : null;
|
|
117
|
+
}
|
|
118
|
+
/** The attributes the runtime fills (sinks.ts ATTRIBUTES). */
|
|
119
|
+
const ATTRIBUTES = new Set(["href", "src", "alt", "title", "aria-label", "placeholder", "datetime"]);
|
|
120
|
+
/** Field types the runtime renders in a sink the API's table doesn't
|
|
121
|
+
list for it (lib/bindings.ts COMPATIBLE): a yes or no becomes "true" or
|
|
122
|
+
"false" (paths.ts asText); a link template takes a string or a number,
|
|
123
|
+
percent-encoded (url.ts fillTemplate). They bind as the field's own
|
|
124
|
+
type, which Mapled grades as it is. Rich text, an image or a file the
|
|
125
|
+
runtime would show too — as raw Markdown or an asset id: those stay a
|
|
126
|
+
mismatch (Anton, 2026-09-23). */
|
|
127
|
+
const ALSO_RENDERS = {
|
|
128
|
+
text: ["boolean"],
|
|
129
|
+
template: ["long_text", "number", "enum", "date", "datetime", "color"],
|
|
130
|
+
};
|
|
131
|
+
const effectiveType = (f) => f.computed?.resultType ?? f.type;
|
|
132
|
+
/** A field by its key or a previous one (§33.5) — Mapled serves both. */
|
|
133
|
+
const fieldOf = (collection, key) => collection?.fields.find((f) => f.key === key) ?? collection?.fields.find((f) => f.aliases?.includes(key));
|
|
134
|
+
export async function scanHtml(dir, opts) {
|
|
135
|
+
const notes = [];
|
|
136
|
+
const max = opts.maxFiles ?? 2000;
|
|
137
|
+
const { files, truncated } = await listHtml(dir, max);
|
|
138
|
+
if (truncated)
|
|
139
|
+
notes.push(`Only the first ${max} HTML files were read.`);
|
|
140
|
+
const collections = new Map(opts.schema.collections.map((c) => [c.key, c]));
|
|
141
|
+
const found = new Map();
|
|
142
|
+
const pages = [];
|
|
143
|
+
const unknown = [];
|
|
144
|
+
const notSingles = [];
|
|
145
|
+
const bare = [];
|
|
146
|
+
const badPaths = [];
|
|
147
|
+
const nested = [];
|
|
148
|
+
const noTemplate = [];
|
|
149
|
+
const unusableRoutes = [];
|
|
150
|
+
const tooDeep = [];
|
|
151
|
+
const notGroups = [];
|
|
152
|
+
const seenRoutes = new Set();
|
|
153
|
+
const pageKeys = new Set();
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
let text;
|
|
156
|
+
try {
|
|
157
|
+
text = await readFile(path.join(dir, file), "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// attribute names are case-insensitive: DATA-MAPLED-BIND is the same attribute
|
|
163
|
+
if (text.length > 2_000_000 || !/data-mapled-/i.test(text))
|
|
164
|
+
continue;
|
|
165
|
+
const { route: served, key: plainKey } = htmlPage(file);
|
|
166
|
+
if (seenRoutes.has(served))
|
|
167
|
+
continue;
|
|
168
|
+
let doc;
|
|
169
|
+
try {
|
|
170
|
+
doc = parseHtml(text);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (!(e instanceof TooDeep))
|
|
174
|
+
throw e;
|
|
175
|
+
tooDeep.push(file);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
seenRoutes.add(served);
|
|
179
|
+
let pageKey = plainKey;
|
|
180
|
+
for (let n = 2; pageKeys.has(pageKey); n++)
|
|
181
|
+
pageKey = `${plainKey.slice(0, 51)}-${hash8(`${file}#${n}`)}`;
|
|
182
|
+
pageKeys.add(pageKey);
|
|
183
|
+
const at = (what) => `${what} (${file})`;
|
|
184
|
+
const routes = new Set();
|
|
185
|
+
/** reads of each collection so far — a record with none inside still reads its collection */
|
|
186
|
+
const reads = new Map();
|
|
187
|
+
const add = (route, b) => {
|
|
188
|
+
routes.add(route);
|
|
189
|
+
reads.set(b.collection, (reads.get(b.collection) ?? 0) + 1);
|
|
190
|
+
const uses = found.get(b.base) ?? { all: [], seen: new Set() };
|
|
191
|
+
const id = `${route}\n${b.target}\n${b.presence ? 1 : 0}`;
|
|
192
|
+
if (!uses.seen.has(id)) {
|
|
193
|
+
uses.seen.add(id);
|
|
194
|
+
uses.all.push({ ...b, route, file });
|
|
195
|
+
}
|
|
196
|
+
found.set(b.base, uses);
|
|
197
|
+
};
|
|
198
|
+
/** A path read in a scope: the binding it makes, or the note it earns. */
|
|
199
|
+
const use = (scope, route, path, sink) => {
|
|
200
|
+
const segments = segmentsOf(path);
|
|
201
|
+
if (!segments) {
|
|
202
|
+
badPaths.push(at(path.trim().slice(0, 40) || '""'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (scope.kind === "group") {
|
|
206
|
+
if (scope.fields && !scope.fields.has(segments[0]))
|
|
207
|
+
unknown.push(at(path.trim()));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
let collection;
|
|
211
|
+
let collectionKey;
|
|
212
|
+
let rest;
|
|
213
|
+
if (scope.kind === "page") {
|
|
214
|
+
if (segments.length < 2) {
|
|
215
|
+
bare.push(at(path.trim()));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
collectionKey = segments[0];
|
|
219
|
+
collection = collections.get(collectionKey);
|
|
220
|
+
if (collection && collection.kind !== "single") {
|
|
221
|
+
notSingles.push(at(path.trim()));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
rest = segments.slice(1);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
collectionKey = scope.collection;
|
|
228
|
+
collection = collections.get(collectionKey);
|
|
229
|
+
rest = segments;
|
|
230
|
+
}
|
|
231
|
+
const fieldKey = rest[0];
|
|
232
|
+
const field = fieldOf(collection, fieldKey);
|
|
233
|
+
// inside a list or a record of an unknown collection the collection is the note
|
|
234
|
+
if (scope.kind === "page" ? !collection || !field : collection !== undefined && !field)
|
|
235
|
+
unknown.push(at(path.trim()));
|
|
236
|
+
// a group's sub-field: the schema knows which there are
|
|
237
|
+
else if (field?.group && rest.length > 1 && !field.group.fields.some((f) => f.key === rest[1]))
|
|
238
|
+
unknown.push(at(path.trim()));
|
|
239
|
+
// an each repeats a group's items: the runtime refuses anything else with "type"
|
|
240
|
+
else if (sink === "each" && field && rest.length === 1 && !field.group && !["json", "location"].includes(effectiveType(field)))
|
|
241
|
+
notGroups.push(at(path.trim()));
|
|
242
|
+
add(route, {
|
|
243
|
+
base: `${pageKey}:${collectionKey}.${fieldKey}`,
|
|
244
|
+
collection: collectionKey,
|
|
245
|
+
field: fieldKey,
|
|
246
|
+
target: targetOf(sink, field, rest.length),
|
|
247
|
+
natural: field ? targetFor(effectiveType(field)) : "other",
|
|
248
|
+
...(sink === "presence" ? { presence: true } : {}),
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
/** The attributes an element declares for itself. */
|
|
252
|
+
const own = (el, scope, route) => {
|
|
253
|
+
const bind = el.attrs.get(`${P}bind`);
|
|
254
|
+
if (bind !== undefined)
|
|
255
|
+
use(scope, route, bind, bindSink(el));
|
|
256
|
+
for (const [name, value] of el.attrs) {
|
|
257
|
+
if (!name.startsWith(`${P}bind-`))
|
|
258
|
+
continue;
|
|
259
|
+
const attr = name.slice(P.length + 5);
|
|
260
|
+
if (ATTRIBUTES.has(attr))
|
|
261
|
+
use(scope, route, value, attributeSink(el.name, attr));
|
|
262
|
+
}
|
|
263
|
+
for (const name of ["show", "hide"]) {
|
|
264
|
+
const value = el.attrs.get(`${P}${name}`);
|
|
265
|
+
if (value !== undefined)
|
|
266
|
+
use(scope, route, value, "presence");
|
|
267
|
+
}
|
|
268
|
+
const link = el.attrs.get(`${P}link`);
|
|
269
|
+
if (link !== undefined)
|
|
270
|
+
for (const m of link.matchAll(/\{([^{}]*)\}/g))
|
|
271
|
+
use(scope, route, m[1], "template");
|
|
272
|
+
};
|
|
273
|
+
/** A list: the collection, and the fields of its first template. The
|
|
274
|
+
runtime reads every list of the page from the document, wherever
|
|
275
|
+
it sits — the page's own route. */
|
|
276
|
+
const readList = (el, collectionKey, depth) => {
|
|
277
|
+
add(served, { base: `${pageKey}:${collectionKey}`, collection: collectionKey, target: "collection", natural: "collection" });
|
|
278
|
+
const template = el.children.find((c) => c.name === "template");
|
|
279
|
+
if (!template)
|
|
280
|
+
noTemplate.push(at(collectionKey));
|
|
281
|
+
else
|
|
282
|
+
for (const c of template.children)
|
|
283
|
+
visit(c, { kind: "record", collection: collectionKey }, served, true, false, depth + 1);
|
|
284
|
+
};
|
|
285
|
+
/** A record inside a record isn't read — but the lists inside it are:
|
|
286
|
+
the runtime lifts them to the page (runtime.ts, compile). */
|
|
287
|
+
const liftLists = (el, depth) => {
|
|
288
|
+
if (depth > MAX_DEPTH)
|
|
289
|
+
return;
|
|
290
|
+
for (const c of el.children) {
|
|
291
|
+
if (c.name === "template" || c.attrs.has(`${P}each`) && !c.attrs.has(`${P}list`) && !c.attrs.has(`${P}record`))
|
|
292
|
+
continue;
|
|
293
|
+
const list = c.attrs.get(`${P}list`);
|
|
294
|
+
const record = c.attrs.get(`${P}record`);
|
|
295
|
+
if (list === undefined && record === undefined) {
|
|
296
|
+
liftLists(c, depth + 1);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const collectionKey = (list ?? record ?? "").trim();
|
|
300
|
+
if (!KEY.test(collectionKey))
|
|
301
|
+
badPaths.push(at(collectionKey.slice(0, 40) || '""'));
|
|
302
|
+
else if (list !== undefined) {
|
|
303
|
+
if (!collections.has(collectionKey))
|
|
304
|
+
unknown.push(at(collectionKey));
|
|
305
|
+
readList(c, collectionKey, depth);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
nested.push(at(collectionKey));
|
|
309
|
+
liftLists(c, depth + 1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const visit = (el, scope, route, inTemplate, inRecord, depth) => {
|
|
314
|
+
if (depth > MAX_DEPTH || el.name === "template")
|
|
315
|
+
return;
|
|
316
|
+
const list = el.attrs.get(`${P}list`);
|
|
317
|
+
const record = el.attrs.get(`${P}record`);
|
|
318
|
+
const each = el.attrs.get(`${P}each`);
|
|
319
|
+
if (list !== undefined || record !== undefined) {
|
|
320
|
+
const collectionKey = (list ?? record ?? "").trim();
|
|
321
|
+
if (inTemplate) {
|
|
322
|
+
nested.push(at(collectionKey || el.name));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!KEY.test(collectionKey)) {
|
|
326
|
+
badPaths.push(at(collectionKey.slice(0, 40) || '""'));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (list === undefined && inRecord) {
|
|
330
|
+
nested.push(at(collectionKey));
|
|
331
|
+
liftLists(el, depth + 1);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!collections.has(collectionKey))
|
|
335
|
+
unknown.push(at(collectionKey));
|
|
336
|
+
if (list !== undefined)
|
|
337
|
+
return readList(el, collectionKey, depth);
|
|
338
|
+
// one record for what is inside: its own attributes and its children,
|
|
339
|
+
// at the address the record is read from
|
|
340
|
+
const param = el.attrs.get(`${P}slug-param`);
|
|
341
|
+
const slugField = slugFieldOf(collections.get(collectionKey));
|
|
342
|
+
const byQuery = param !== undefined && el.attrs.get(`${P}slug`) === undefined;
|
|
343
|
+
// the runtime reads the parameter by its exact name
|
|
344
|
+
const address = byQuery ? `${served}?${encodeURIComponent(param)}=[${slugField ?? "slug"}]` : served;
|
|
345
|
+
const inner = { kind: "record", collection: collectionKey };
|
|
346
|
+
const before = reads.get(collectionKey) ?? 0;
|
|
347
|
+
own(el, inner, address);
|
|
348
|
+
for (const c of el.children)
|
|
349
|
+
visit(c, inner, address, false, true, depth + 1);
|
|
350
|
+
if (byQuery) {
|
|
351
|
+
add(address, { base: `${pageKey}:${collectionKey}[route]`, collection: collectionKey, ...(slugField ? { field: slugField } : {}), target: "route_param", natural: "route_param" });
|
|
352
|
+
}
|
|
353
|
+
else if ((reads.get(collectionKey) ?? 0) === before) {
|
|
354
|
+
// one fixed record, not the collection: Mapled offers a page for any record only when it lists them
|
|
355
|
+
add(address, { base: `${pageKey}:${collectionKey}`, collection: collectionKey, target: "other", natural: "other" });
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (each !== undefined) {
|
|
360
|
+
use(scope, route, each, "each");
|
|
361
|
+
const template = el.children.find((c) => c.name === "template");
|
|
362
|
+
if (!template) {
|
|
363
|
+
noTemplate.push(at(each.trim()));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const items = { kind: "group", fields: groupFields(scope, each) };
|
|
367
|
+
for (const c of template.children)
|
|
368
|
+
visit(c, items, route, true, inRecord, depth + 1);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
own(el, scope, route);
|
|
372
|
+
for (const c of el.children)
|
|
373
|
+
visit(c, scope, route, inTemplate, inRecord, depth + 1);
|
|
374
|
+
};
|
|
375
|
+
/** The sub-fields of the group an each repeats, when the schema says. */
|
|
376
|
+
const groupFields = (scope, each) => {
|
|
377
|
+
const segments = segmentsOf(each);
|
|
378
|
+
if (!segments)
|
|
379
|
+
return null;
|
|
380
|
+
let field;
|
|
381
|
+
if (scope.kind === "page" && segments.length === 2)
|
|
382
|
+
field = fieldOf(collections.get(segments[0]), segments[1]);
|
|
383
|
+
else if (scope.kind === "record" && segments.length === 1)
|
|
384
|
+
field = fieldOf(collections.get(scope.collection), segments[0]);
|
|
385
|
+
return field?.group ? new Set(field.group.fields.map((f) => f.key)) : null;
|
|
386
|
+
};
|
|
387
|
+
// the runtime's root is the document element: its own attributes, then what is inside
|
|
388
|
+
const html = doc.children.find((c) => c.name === "html");
|
|
389
|
+
if (html) {
|
|
390
|
+
own(html, { kind: "page" }, served);
|
|
391
|
+
for (const c of html.children)
|
|
392
|
+
visit(c, { kind: "page" }, served, false, false, 1);
|
|
393
|
+
}
|
|
394
|
+
if (routes.size === 0)
|
|
395
|
+
routes.add(served);
|
|
396
|
+
for (const route of routes) {
|
|
397
|
+
const why = unusable(route);
|
|
398
|
+
if (why)
|
|
399
|
+
unusableRoutes.push(`${route} (${file}: ${why})`);
|
|
400
|
+
pages.push({ route, file });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const list = (items) => {
|
|
404
|
+
const unique = [...new Set(items)];
|
|
405
|
+
return `${unique.slice(0, 5).join(", ")}${unique.length > 5 ? ", …" : ""}`;
|
|
406
|
+
};
|
|
407
|
+
const count = (items) => new Set(items).size;
|
|
408
|
+
if (unknown.length > 0) {
|
|
409
|
+
notes.push(`${count(unknown) === 1 ? "This path names" : "These paths name"} nothing in the schema: ${list(unknown)}. A misspelled field empties its place on the page, a misspelled single leaves the page's own HTML.`);
|
|
410
|
+
}
|
|
411
|
+
if (bare.length > 0)
|
|
412
|
+
notes.push(`Outside a list, an each or a record a path starts with a single's key (home.headline): ${list(bare)}.`);
|
|
413
|
+
if (notSingles.length > 0)
|
|
414
|
+
notes.push(`A collection is read with data-mapled-list or data-mapled-record, not from the document: ${list(notSingles)}.`);
|
|
415
|
+
if (badPaths.length > 0)
|
|
416
|
+
notes.push(`Not a Mapled path or key: ${list(badPaths)}.`);
|
|
417
|
+
if (nested.length > 0)
|
|
418
|
+
notes.push(`A list or a record inside a repeated template, or a record inside a record, isn't read: ${list(nested)}.`);
|
|
419
|
+
if (notGroups.length > 0)
|
|
420
|
+
notes.push(`An each repeats the items of a group — ${count(notGroups) === 1 ? "this field isn't one" : "these fields aren't"}: ${list(notGroups)}.`);
|
|
421
|
+
if (noTemplate.length > 0)
|
|
422
|
+
notes.push(`A list or an each without a <template> isn't filled: ${list(noTemplate)}.`);
|
|
423
|
+
if (tooDeep.length > 0)
|
|
424
|
+
notes.push(`Elements nest deeper than ${MAX_OPEN} levels, so the scan didn't read ${count(tooDeep) === 1 ? "this page" : "these pages"}: ${list(tooDeep)}.`);
|
|
425
|
+
if (unusableRoutes.length > 0)
|
|
426
|
+
notes.push(`Preview can't open ${count(unusableRoutes) === 1 ? "this address" : "these addresses"} — rename the file or the slug parameter: ${list(unusableRoutes)}.`);
|
|
427
|
+
// keys within the manifest's 120 characters, and still one per binding
|
|
428
|
+
const keyed = [...found].flatMap(([base, { all }]) => variants(base, all)).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
429
|
+
const used = new Set(keyed.filter((b) => b.key.length <= 120).map((b) => b.key));
|
|
430
|
+
const bindings = keyed.map((b) => {
|
|
431
|
+
if (b.key.length <= 120)
|
|
432
|
+
return b;
|
|
433
|
+
let key = boundKey(b.key);
|
|
434
|
+
for (let n = 2; used.has(key); n++)
|
|
435
|
+
key = boundKey(`${b.key}#${n}`);
|
|
436
|
+
used.add(key);
|
|
437
|
+
return { ...b, key };
|
|
438
|
+
});
|
|
439
|
+
const sortedPages = pages.sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
|
|
440
|
+
const sortedBindings = bindings.sort((a, b) => a.page < b.page ? -1 : a.page > b.page ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
441
|
+
return { framework: "plain-html", parser: "html", files: files.length, pages: sortedPages, bindings: sortedBindings, notes };
|
|
442
|
+
}
|
|
443
|
+
/** The bindings of one key: every distinct way the page reads the field
|
|
444
|
+
— a date format on a short text is a mismatch even when an <h1> reads
|
|
445
|
+
the same field well — whatever the order of the HTML. The field's
|
|
446
|
+
own target keeps the plain key. Being shown or hidden by a field adds
|
|
447
|
+
nothing where the same address reads it otherwise too — an each does
|
|
448
|
+
(it can fail on its own). Keys are full length here: the caller
|
|
449
|
+
bounds them. */
|
|
450
|
+
function variants(base, all) {
|
|
451
|
+
const read = new Set(all.filter((f) => !f.presence).map((f) => f.route));
|
|
452
|
+
const readAs = new Set(all.filter((f) => !f.presence).map((f) => `${f.route}\n${f.target}`));
|
|
453
|
+
const kept = all.filter((f) => !f.presence || !(f.target === "other" ? read.has(f.route) : readAs.has(`${f.route}\n${f.target}`)));
|
|
454
|
+
const rank = (f) => (f.target === f.natural ? 0 : 1);
|
|
455
|
+
kept.sort((a, b) => rank(a) - rank(b) || (a.route < b.route ? -1 : a.route > b.route ? 1 : 0) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
|
|
456
|
+
const targets = new Map();
|
|
457
|
+
for (const f of kept)
|
|
458
|
+
targets.set(f.target, (targets.get(f.target) ?? 0) + 1);
|
|
459
|
+
return kept.map((f, i) => {
|
|
460
|
+
const key = i === 0 ? base : targets.get(f.target) === 1 ? `${base}[${f.target}]` : `${base}[${i + 1}]`;
|
|
461
|
+
return {
|
|
462
|
+
key,
|
|
463
|
+
page: f.route,
|
|
464
|
+
file: f.file,
|
|
465
|
+
collection: f.collection,
|
|
466
|
+
...(f.field !== undefined ? { field: f.field } : {}),
|
|
467
|
+
target: f.target,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const slugFieldOf = (collection) => collection?.fields.find((f) => f.type === "slug")?.key;
|
|
472
|
+
/** What `data-mapled-bind` renders on this element. */
|
|
473
|
+
function bindSink(el) {
|
|
474
|
+
if (el.name === "img")
|
|
475
|
+
return "image";
|
|
476
|
+
switch (el.attrs.get(`${P}format`)) {
|
|
477
|
+
case "markdown":
|
|
478
|
+
return "rich_text";
|
|
479
|
+
case "date":
|
|
480
|
+
case "datetime":
|
|
481
|
+
return "date";
|
|
482
|
+
case "number":
|
|
483
|
+
return "number";
|
|
484
|
+
default:
|
|
485
|
+
return "text";
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function attributeSink(element, attr) {
|
|
489
|
+
switch (attr) {
|
|
490
|
+
case "href":
|
|
491
|
+
return "link";
|
|
492
|
+
case "src":
|
|
493
|
+
return element === "img" ? "image" : "link";
|
|
494
|
+
case "alt":
|
|
495
|
+
return "image_alt";
|
|
496
|
+
case "datetime":
|
|
497
|
+
return "date";
|
|
498
|
+
default:
|
|
499
|
+
return "text";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** The binding's target: the sink, for a value read at the field itself.
|
|
503
|
+
Deeper paths (a group's sub-field, a linked record's field) and eaches
|
|
504
|
+
bind the field as a whole — Mapled grades those as it can't check. */
|
|
505
|
+
function targetOf(sink, field, depth) {
|
|
506
|
+
if (sink === "each" || depth > 1)
|
|
507
|
+
return "other";
|
|
508
|
+
const type = field ? effectiveType(field) : undefined;
|
|
509
|
+
if (sink === "presence")
|
|
510
|
+
return type === "boolean" ? "boolean" : "other";
|
|
511
|
+
const target = sink === "template" ? "link" : sink;
|
|
512
|
+
if (!type)
|
|
513
|
+
return target;
|
|
514
|
+
// a relation or a group bound directly has no single value to render
|
|
515
|
+
const natural = targetFor(type);
|
|
516
|
+
if (natural === "other")
|
|
517
|
+
return "other";
|
|
518
|
+
return ALSO_RENDERS[sink]?.includes(type) ? natural : target;
|
|
519
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms and check the site's integration.",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://mapled.io",
|
|
7
7
|
"keywords": [
|
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"build": "tsc -p tsconfig.json && node build.mjs",
|
|
27
27
|
"test": "vitest run",
|
|
28
28
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
29
29
|
"prepare": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^24.0.0",
|
|
33
|
+
"esbuild": "^0.28.2",
|
|
34
|
+
"parse5": "7.3.0",
|
|
33
35
|
"typescript": "^5.9.0",
|
|
34
36
|
"vitest": "^4.1.11"
|
|
35
37
|
}
|