@mapled/cli 0.9.0 → 0.10.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 +16 -1
- package/dist/commands.js +4 -3
- package/dist/html5.d.ts +66 -0
- package/dist/html5.js +8009 -0
- package/dist/licenses/entities.LICENSE +11 -0
- package/dist/licenses/parse5.LICENSE +19 -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 +4 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Copyright (c) Felix Böhm
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
7
|
+
|
|
8
|
+
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
9
|
+
|
|
10
|
+
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
|
|
11
|
+
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2013-2019 Ivan Nikulin (ifaaan@gmail.com, https://github.com/inikulin)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
19
|
+
THE SOFTWARE.
|
package/dist/scan.d.ts
CHANGED
package/dist/scan.js
CHANGED
|
@@ -1200,7 +1200,10 @@ export async function scanRepository(dir, opts) {
|
|
|
1200
1200
|
...(read.component ? { component: read.component } : {}),
|
|
1201
1201
|
file: relFile(read.file),
|
|
1202
1202
|
collection: read.collection,
|
|
1203
|
-
|
|
1203
|
+
// a list shows any record of the collection, a single is its one record; one read
|
|
1204
|
+
// record of a collection is that record — Mapled doesn't offer the page as the
|
|
1205
|
+
// Preview of another (recordRoutes.ts)
|
|
1206
|
+
target: isList || read.method === "getSingle" ? "collection" : "other",
|
|
1204
1207
|
});
|
|
1205
1208
|
}
|
|
1206
1209
|
if (read.bySlug && /\[[^\]]+\]/.test(page)) {
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms and check the site's integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://mapled.io",
|
|
@@ -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
|
}
|