@mapled/cli 0.14.0 → 0.14.2
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 +2 -2
- package/dist/commands.js +6 -1
- package/dist/doctor.d.ts +37 -1
- package/dist/doctor.js +235 -8
- package/dist/html5.d.ts +13 -2
- package/dist/html5.js +9 -0
- package/dist/report.d.ts +4 -0
- package/dist/scanHtml.d.ts +2 -1
- package/dist/scanHtml.js +11 -4
- package/dist/types.js +3 -1
- package/dist/vanillaDigests.d.ts +8 -0
- package/dist/vanillaDigests.js +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -206,7 +206,7 @@ Mapled doctor — Dolphin Landing
|
|
|
206
206
|
|
|
207
207
|
The repository side: the link, the sign-in, the generated types, the schema pin, the site manifest (and whether it matches what was pushed), the integration hash — one line saying whether the pin and the manifest here describe the same integration Mapled holds, and if not, what moved (the schema, the bindings, or edits made in Mapled) and which command catches up — `MAPLED.md` (and whether it is what Mapled would render now), `MAPLED_KEY` and `MAPLED_WEBHOOK_SECRET` in your env files (names only — values are never read out), env files or Mapled secrets tracked by git, the installed `@mapled/next` and CLI versions, the revalidation and preview routes of a Next.js site, and its live updates — ○ «Not used» while no page mounts `<MapledLive />` or calls `watchRelease`, ✓ when the route they ask answers its `GET` with `createReleaseHandler` (`GET` is the handler, or always returns its call: every return is that call, the last statement is one, and nothing throws), ⚠ when it is missing (the tabs would poll a 404) with the file to add, when the route's `GET` isn't seen to answer with the handler (exported as `POST` they would get a 405; a `GET` that only mentions it, calls it and returns something else, or answers something else on another branch, answers without the release), or when no route can be checked against the endpoint — it is relative to the page, a full address (the tab asks it as written, and doctor can't tell it's the site's own), computed, or comes from spread props; a path from the site's root is checked against the route the App Router picks for it, dynamic and catch-all folders included, among route files with the site's page extensions (`pageExtensions` in next.config; `tsx`, `ts`, `jsx`, `js` by default) and under its `basePath`; ⚠ too when a page of either router, more specific than the route, takes that path first, when the dynamic route that answers sets `dynamicParams`, when the route may set `dynamic` to anything but `"auto"` or `"force-dynamic"` (a copy Next builds without the tab's request), sets `"force-dynamic"` while `createReleaseHandler` there isn't seen to keep a `revalidate` above 0 (its default does; with `false` or `0` Next reads the release past the cache tag) or `fetchCache` to anything but `"auto"` (the release read past the cache tag, before the webhook refreshes the pages), when next.config sets `output: "export"` (no server runs the route), or when next.config may set `pageExtensions`, `basePath` or `output` where doctor can't read them — it reads them only as literals, once, in the object the file exports, and only while the file is declarative: type imports, declarations of data, one export of the config object itself, and functions only as Next's hooks (`webpack`, `headers` …) in it. A module the file loads (a plugin such as `withMDX`, a `path` helper, an env check), a config exported as a function, any other code, a spread or a getter leaves the config unread. With a `basePath`, a listener that gives no endpoint asks under it from `@mapled/next` 0.10.0 — doctor reads the version the site has installed, and it's a ⚠ when that is older or missing. A `middleware` file (`proxy` in Next 16) next to the folder Next reads is a ⚠ unless its matcher, read the way Next's build reads `export const config`, surely leaves the route's path alone: it has no matcher, doctor can't read it, a source may take the path, or next.config has locales (`i18n`). `src/app` or `src/pages` counts only when the root has no `app` or `pages`, as Next reads them. On the Pages Router the route is an API route — a file of the pages folder at `/api` or under it — whose default export is `createPagesReleaseHandler` or always returns its call, and the listener is `<MapledLive />` from `@mapled/next/live/pages` in `pages/_app`: a listener without it gets the API route to add (`pages/api/mapled/release.ts` by default; an endpoint outside `/api` no API route can answer). ⚠ there too when the API route may run on the edge runtime — `runtime` or `config.runtime` other than `"nodejs"`, or written where doctor can't read it (Next reads both only as literals) — where the handler has no Node request and response; when a listener the App Router may run asks it — a file of `app/`, or on a site with both folders one either router may render: the API route answers Mapled's live release, which moves before the webhook refreshes what App Router pages read; and when `<MapledLive />` from `@mapled/next/live/pages` sits in the App Router's folder, where it can't mount. doctor reads the files, not how Next serves them at run time: rewrites and what a middleware does on the path aren't checked, and neither is whether a page renders the component that listens; `revalidate` on the route is no ⚠ — the handler's read names its own. The Mapled side, from the same status the AI agent's `check_integration` tool reads: whether the site has read content with the delivery key, the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
|
|
208
208
|
|
|
209
|
-
A site rendered in the visitor's browser — a React single-page app (Vite and the like), plain HTML — is checked for what it has: the package line is `@mapled/react`, Environment wants `VITE_MAPLED_KEY` and no webhook secret, and the routes and the webhook read ○ «Not needed» — such a site shows a publish on its next load and trades Mapled's preview link on the page itself; Live updates reads ○ as well: `live` on `MapledProvider` or `data-mapled-live` asks Mapled directly, with no route of the site's. Plain HTML installs nothing: the line is `@mapled/vanilla`, read from the script tag in the pages (or from a copy of the file next to them) — ⚠ when the address has no version or an older one — and Generated types and Environment read ○: the fields are named in attributes, the delivery key sits in the page. doctor takes the framework from `mapled.json`, or works it out from `package.json` (no `package.json` and an `index.html` is plain HTML). Whatever the framework, a server key or a signing secret under a public prefix (`VITE_…`, `NEXT_PUBLIC_…`) is a ✗: it would ship to every visitor.
|
|
209
|
+
A site rendered in the visitor's browser — a React single-page app (Vite and the like), plain HTML — is checked for what it has: the package line is `@mapled/react`, Environment wants `VITE_MAPLED_KEY` and no webhook secret, and the routes and the webhook read ○ «Not needed» — such a site shows a publish on its next load and trades Mapled's preview link on the page itself; Live updates reads ○ as well: `live` on `MapledProvider` or `data-mapled-live` asks Mapled directly, with no route of the site's. Plain HTML installs nothing: the line is `@mapled/vanilla`, read from the script tag in the pages (or from a copy of the file next to them) — ⚠ when the address has no version or an older one, and before that when a page's tag has an `integrity` a browser won't pass: from another origin without `crossorigin` (a browser checks only a file that came with CORS), `crossorigin="use-credentials"` on jsDelivr or unpkg (their `*` answer fails a request with credentials, integrity or not), or a digest that isn't the file's — for a copy next to the pages, the copy's own; for a jsDelivr or unpkg address, the released file's of the version in it (another host may serve a file of its own; an address with a query isn't checked — `?meta` answers with something else), which doctor knows without the network for the releases up to its own, or from `node_modules/@mapled/vanilla` of that version — and Generated types and Environment read ○: the fields are named in attributes, the delivery key sits in the page. doctor takes the framework from `mapled.json`, or works it out from `package.json` (no `package.json` and an `index.html` is plain HTML). Whatever the framework, a server key or a signing secret under a public prefix (`VITE_…`, `NEXT_PUBLIC_…`) is a ✗: it would ship to every visitor.
|
|
210
210
|
|
|
211
211
|
Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
|
|
212
212
|
|
|
@@ -256,7 +256,7 @@ npx @mapled/cli preview
|
|
|
256
256
|
npx @mapled/cli preview articles hello-world
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
-
Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page and when it expires.
|
|
259
|
+
Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. A plain HTML site whose pages are built with the content in them has no page for a record it wasn't built with: for such a record the command says so instead of printing a link — its page appears once the site is built with it. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page and when it expires.
|
|
260
260
|
|
|
261
261
|
## Options
|
|
262
262
|
|
package/dist/commands.js
CHANGED
|
@@ -507,6 +507,11 @@ export async function preview(ctx, flags, words) {
|
|
|
507
507
|
if (answer.status === "not_set_up") {
|
|
508
508
|
throw new CliError(`${clean(answer.origin)} has no preview route — mount createPreviewHandler from "@mapled/next/server" at /api/mapled/preview and redeploy.`);
|
|
509
509
|
}
|
|
510
|
+
if (answer.status === "no_route") {
|
|
511
|
+
// a site with its content baked in at build time has no page for a record it wasn't built with (§22.7 п. 3)
|
|
512
|
+
const where = answer.path ? `at ${clean(answer.path, 500)}` : "for this record";
|
|
513
|
+
throw new CliError(`${clean(answer.origin)} has no page ${where} — if the record is new, its page appears once the site is built with it. Then try again.`);
|
|
514
|
+
}
|
|
510
515
|
// the link is printed as it came (masking would drop the token): printable ASCII only
|
|
511
516
|
if (typeof answer.url !== "string" || !/^https?:\/\/[!-~]+$/.test(answer.url)) {
|
|
512
517
|
throw new CliError("Mapled answered with a link this CLI can't print. Update @mapled/cli and try again.");
|
|
@@ -708,7 +713,7 @@ export async function doctor(ctx, flags) {
|
|
|
708
713
|
checks.push(dr.checkEnv(await dr.envNames(dir), ctx.env, framework));
|
|
709
714
|
checks.push(dr.checkSecrets(await dr.gitFacts(dir)));
|
|
710
715
|
// plain HTML installs nothing: the runtime comes with a script tag
|
|
711
|
-
checks.push(framework === "plain-html" ? dr.checkScriptTag(await dr.findScriptTag(dir), status?.sdk[sdk]) : dr.checkSdk(await dr.installedVersion(dir, sdk), status?.sdk[sdk], sdk));
|
|
716
|
+
checks.push(framework === "plain-html" ? dr.checkScriptTag(await dr.findScriptTag(dir, status?.site?.url), status?.sdk[sdk]) : dr.checkSdk(await dr.installedVersion(dir, sdk), status?.sdk[sdk], sdk));
|
|
712
717
|
checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
|
|
713
718
|
checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework));
|
|
714
719
|
checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
|
package/dist/doctor.d.ts
CHANGED
|
@@ -110,8 +110,43 @@ export type ScriptTag = {
|
|
|
110
110
|
file: string;
|
|
111
111
|
/** null — a CDN address without a version: it follows every release */
|
|
112
112
|
version: string | null;
|
|
113
|
+
/** the first page whose runtime tag a browser refuses to run */
|
|
114
|
+
refused?: Refusal;
|
|
113
115
|
};
|
|
114
|
-
|
|
116
|
+
/** Why a browser refuses the runtime's file on a page (§19.2): the tag's
|
|
117
|
+
`integrity` names a hash, and the file comes from another origin
|
|
118
|
+
without `crossorigin` — the answer is opaque, and an opaque answer
|
|
119
|
+
passes no check — or no digest of the strongest hash it names is the
|
|
120
|
+
file's — or it asks jsDelivr or unpkg with credentials
|
|
121
|
+
(`crossorigin="use-credentials"`), which they answer with a wildcard
|
|
122
|
+
a browser doesn't take for such a request. `more`: other pages with a
|
|
123
|
+
tag a browser refuses. */
|
|
124
|
+
export type Refusal = {
|
|
125
|
+
file: string;
|
|
126
|
+
more: number;
|
|
127
|
+
} & Why;
|
|
128
|
+
type Why = {
|
|
129
|
+
cors: true;
|
|
130
|
+
} | {
|
|
131
|
+
credentials: true;
|
|
132
|
+
} | {
|
|
133
|
+
digest: string;
|
|
134
|
+
of: string;
|
|
135
|
+
};
|
|
136
|
+
export declare function findScriptTag(dir: string, site?: string | null): Promise<ScriptTag | null>;
|
|
137
|
+
/** Which file's bytes an address serves, where doctor knows them without
|
|
138
|
+
the network — the one place that decides whether a digest is checked:
|
|
139
|
+
a path on the site's own origin (a file of the repository, if there is
|
|
140
|
+
one), or the published runtime of a release on an npm CDN, at its
|
|
141
|
+
canonical address (https, no port of its own). Either only when the
|
|
142
|
+
path alone decides the answer: no query (unpkg's `?meta` answers with
|
|
143
|
+
JSON, a server may answer `?v=2` with anything) and no credentials;
|
|
144
|
+
the fragment never leaves the browser. Null for anything else. */
|
|
145
|
+
export declare function servedFile(src: URL, origin: string): {
|
|
146
|
+
copy: string;
|
|
147
|
+
} | {
|
|
148
|
+
version: string;
|
|
149
|
+
} | null;
|
|
115
150
|
export declare function checkScriptTag(tag: ScriptTag | null, latest: string | undefined): Check;
|
|
116
151
|
export declare function detectFramework(dir: string): Promise<string | undefined>;
|
|
117
152
|
/** -1, 0 or 1 comparing dotted numeric versions; anything else compares by number. */
|
|
@@ -188,3 +223,4 @@ export declare function checkIntegration(local: {
|
|
|
188
223
|
manifest: StoredManifest | null | "unknown";
|
|
189
224
|
} | null): Check;
|
|
190
225
|
export declare const REMOTE_CHECKS: [string, string][];
|
|
226
|
+
export {};
|
package/dist/doctor.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { access, readdir, readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { parseDocument } from "./html5.js";
|
|
5
7
|
import { integrationHash } from "./integrationHash.js";
|
|
6
8
|
import { BASE_PATH_SINCE, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, routeFileFor, runsIn } from "./live.js";
|
|
7
9
|
import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
|
|
8
10
|
import { SCHEMA_FILE } from "./pin.js";
|
|
11
|
+
import { htmlPage } from "./scanHtml.js";
|
|
9
12
|
import { splitGenerated } from "./types.js";
|
|
13
|
+
import { VANILLA_DIGESTS } from "./vanillaDigests.js";
|
|
10
14
|
export function ago(iso) {
|
|
11
15
|
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
|
12
16
|
if (m < 1)
|
|
@@ -111,11 +115,13 @@ export const rendersInBrowser = (framework) => framework === "react-spa" || fram
|
|
|
111
115
|
/** The first-party package a site of this framework reads through. */
|
|
112
116
|
export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : "@mapled/client";
|
|
113
117
|
const SKIPPED_DIRS = new Set(["node_modules", ".git", ".next", ".vercel", ".netlify", "vendor"]);
|
|
114
|
-
export async function findScriptTag(dir) {
|
|
118
|
+
export async function findScriptTag(dir, site) {
|
|
115
119
|
// pages first, breadth first, and not forever: a site's pages sit near its root
|
|
116
120
|
const queue = [{ abs: dir, rel: "", depth: 0 }];
|
|
117
121
|
const copies = [];
|
|
122
|
+
let pinned = null;
|
|
118
123
|
let unpinned = null;
|
|
124
|
+
let refused = null;
|
|
119
125
|
let seen = 0;
|
|
120
126
|
while (queue.length > 0 && seen < 2000) {
|
|
121
127
|
const at = queue.shift();
|
|
@@ -133,20 +139,238 @@ export async function findScriptTag(dir) {
|
|
|
133
139
|
if (!/\.html?$/i.test(entry.name))
|
|
134
140
|
continue;
|
|
135
141
|
const text = await readFile(path.join(at.abs, entry.name), "utf8").catch(() => "");
|
|
136
|
-
const
|
|
137
|
-
if (pinned)
|
|
138
|
-
|
|
142
|
+
const version = /@mapled\/vanilla@(\d+\.\d+\.\d+)/.exec(text);
|
|
143
|
+
if (pinned === null && version)
|
|
144
|
+
pinned = { file: rel, version: version[1] };
|
|
139
145
|
if (unpinned === null && /@mapled\/vanilla(@latest)?\//.test(text))
|
|
140
146
|
unpinned = rel;
|
|
147
|
+
// an attribute's name is in the markup as it is, ASCII case aside — its value may be written in character
|
|
148
|
+
// references: a page with neither name has nothing to refuse
|
|
149
|
+
const why = /integrity|crossorigin/i.test(text) ? await refusalOn(text, rel, dir, site) : null;
|
|
150
|
+
if (why && refused)
|
|
151
|
+
refused.more += 1;
|
|
152
|
+
else if (why)
|
|
153
|
+
refused = { file: rel, more: 0, ...why };
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
|
-
|
|
156
|
+
let found = pinned;
|
|
157
|
+
for (const rel of found ? [] : copies) {
|
|
144
158
|
const head = (await readFile(path.join(dir, rel), "utf8").catch(() => "")).slice(0, 200);
|
|
145
159
|
const banner = /^\/\*! @mapled\/vanilla (\d+\.\d+\.\d+) /.exec(head);
|
|
146
|
-
if (banner)
|
|
147
|
-
|
|
160
|
+
if (banner) {
|
|
161
|
+
found = { file: rel, version: banner[1] };
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
found ??= unpinned === null ? null : { file: unpinned, version: null };
|
|
166
|
+
return found && refused ? { ...found, refused } : found;
|
|
167
|
+
}
|
|
168
|
+
/** A token of `integrity` some browser may read as naming a hash — any
|
|
169
|
+
ASCII case, "sha-384" too (as the preview proxy reads it: SRI_HASH in
|
|
170
|
+
lib/previewProxy.ts of the API) — and one every browser reads alike:
|
|
171
|
+
the name as written, a digest, options after "?". */
|
|
172
|
+
const SRI_HASH = /^sha-?(256|384|512)(?:[-?]|$)/i;
|
|
173
|
+
const SRI_EXACT = /^sha(256|384|512)-([A-Za-z0-9+/=_-]+)(?:\?.*)?$/;
|
|
174
|
+
function sriOf(value) {
|
|
175
|
+
let checked = false;
|
|
176
|
+
let alike = true;
|
|
177
|
+
let bits = 0;
|
|
178
|
+
let digests = [];
|
|
179
|
+
for (const token of value.split(/[\t\n\f\r ]+/)) {
|
|
180
|
+
if (!SRI_HASH.test(token))
|
|
181
|
+
continue;
|
|
182
|
+
checked = true;
|
|
183
|
+
const exact = SRI_EXACT.exec(token);
|
|
184
|
+
if (!exact) {
|
|
185
|
+
alike = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// base64url decodes too, as browsers read it
|
|
189
|
+
const digest = Buffer.from(exact[2], "base64");
|
|
190
|
+
const size = Number(exact[1]);
|
|
191
|
+
if (size > bits)
|
|
192
|
+
[bits, digests] = [size, [digest]];
|
|
193
|
+
else if (size === bits)
|
|
194
|
+
digests.push(digest);
|
|
195
|
+
}
|
|
196
|
+
return { checked, strongest: checked && alike ? { hash: `sha${bits}`, digests } : null };
|
|
197
|
+
}
|
|
198
|
+
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
199
|
+
/** where the pages are taken to be served when doctor doesn't know the site's address */
|
|
200
|
+
const NOWHERE = "https://site.invalid";
|
|
201
|
+
/** the `type` of a classic script (HTML's JavaScript MIME type essences); an empty one is classic too */
|
|
202
|
+
const CLASSIC = /^(?:(?:application|text)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/(?:j|live)script)$/;
|
|
203
|
+
/** The runtime's tags on a page, as a browser builds it:
|
|
204
|
+
a <script> it fetches (not in a <template>, not SVG's, a classic or a
|
|
205
|
+
module script) that names "@mapled/vanilla" or carries a delivery key.
|
|
206
|
+
The source resolves against the first <base href> before it, else the
|
|
207
|
+
page's address; a module is always fetched with CORS. A tag without
|
|
208
|
+
`integrity` reads as an empty one: nothing to check. */
|
|
209
|
+
function runtimeTagsOf(text, page) {
|
|
210
|
+
const tags = [];
|
|
211
|
+
let base = null;
|
|
212
|
+
const stack = [parseDocument(text)];
|
|
213
|
+
while (stack.length > 0) {
|
|
214
|
+
const node = stack.pop();
|
|
215
|
+
// a template's content is no part of the document: parse5 keeps it in `content`, not among the children
|
|
216
|
+
for (let child = node.lastChild; child; child = child.previousSibling)
|
|
217
|
+
stack.push(child);
|
|
218
|
+
if (node.namespaceURI !== HTML_NS)
|
|
219
|
+
continue;
|
|
220
|
+
const attr = (name) => node.attrs?.find((a) => !a.namespace && a.name === name)?.value ?? null;
|
|
221
|
+
if (node.tagName === "base" && base === null && attr("href") !== null) {
|
|
222
|
+
try {
|
|
223
|
+
base = new URL(attr("href"), page);
|
|
224
|
+
if (base.protocol === "data:" || base.protocol === "javascript:")
|
|
225
|
+
base = page;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
base = page;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (node.tagName !== "script")
|
|
232
|
+
continue;
|
|
233
|
+
const src = attr("src");
|
|
234
|
+
const integrity = attr("integrity") ?? "";
|
|
235
|
+
const type = attr("type")?.trim().toLowerCase() ?? "";
|
|
236
|
+
const module = type === "module";
|
|
237
|
+
if (src === null || src.trim() === "")
|
|
238
|
+
continue;
|
|
239
|
+
if (!module && ((type !== "" && !CLASSIC.test(type)) || attr("nomodule") !== null))
|
|
240
|
+
continue;
|
|
241
|
+
if (attr("data-mapled-key") === null && !/@mapled\/vanilla[@/]/.test(src))
|
|
242
|
+
continue;
|
|
243
|
+
let url;
|
|
244
|
+
try {
|
|
245
|
+
url = new URL(src, base ?? page);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// an address a browser can't parse fetches nothing
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// a data: or blob: source is no file of a server's, same origin or not
|
|
252
|
+
// any value but "use-credentials" (ASCII case aside) is the anonymous state, as is an empty one
|
|
253
|
+
const crossorigin = attr("crossorigin");
|
|
254
|
+
const cors = crossorigin === null ? (module ? "anonymous" : "none") : crossorigin.toLowerCase() === "use-credentials" ? "credentials" : "anonymous";
|
|
255
|
+
if (url.protocol === "https:" || url.protocol === "http:")
|
|
256
|
+
tags.push({ src: url, integrity, cors });
|
|
257
|
+
}
|
|
258
|
+
return tags;
|
|
259
|
+
}
|
|
260
|
+
/** Why a browser refuses the runtime on this page, or null: the first of
|
|
261
|
+
its tags that it refuses. The file's digest is known without the
|
|
262
|
+
network only where servedFile says which file an address serves: a
|
|
263
|
+
file next to the pages (hashed here) or a release on an npm CDN
|
|
264
|
+
(VANILLA_DIGESTS, or an installed package's dist/mapled.js.sha384) —
|
|
265
|
+
anything else isn't checked. */
|
|
266
|
+
async function refusalOn(text, rel, dir, site) {
|
|
267
|
+
let origin = NOWHERE;
|
|
268
|
+
try {
|
|
269
|
+
if (site)
|
|
270
|
+
origin = new URL(site).origin;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
origin = NOWHERE;
|
|
274
|
+
}
|
|
275
|
+
let tags;
|
|
276
|
+
try {
|
|
277
|
+
tags = runtimeTagsOf(text, new URL(htmlPage(rel).route, origin));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// deeper than a browser builds, or unreadable: nothing on it is claimed
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
for (const tag of tags) {
|
|
284
|
+
const other = tag.src.origin !== origin;
|
|
285
|
+
// with credentials, a CORS answer passes only when it names the page's origin; these CDNs answer "*" — integrity or not
|
|
286
|
+
if (other && tag.cors === "credentials" && NPM_CDNS.has(tag.src.hostname))
|
|
287
|
+
return { credentials: true };
|
|
288
|
+
const sri = sriOf(tag.integrity);
|
|
289
|
+
if (!sri.checked)
|
|
290
|
+
continue;
|
|
291
|
+
if (other && tag.cors === "none")
|
|
292
|
+
return { cors: true };
|
|
293
|
+
const file = sri.strongest ? servedFile(tag.src, origin) : null;
|
|
294
|
+
if (!sri.strongest || !file)
|
|
295
|
+
continue;
|
|
296
|
+
const known = "copy" in file ? await copyDigest(dir, file.copy, sri.strongest.hash) : await releasedDigest(dir, file.version, sri.strongest.hash);
|
|
297
|
+
if (known && !sri.strongest.digests.some((d) => d.equals(known.digest)))
|
|
298
|
+
return { digest: `${sri.strongest.hash}-${known.digest.toString("base64")}`, of: known.of };
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/** The npm CDNs that serve a package's file as it was published, and the path a package starts at on each: a
|
|
303
|
+
release's digest holds only there — any other host may serve a file of its own under the same path. */
|
|
304
|
+
const NPM_CDNS = new Map([
|
|
305
|
+
["cdn.jsdelivr.net", "/npm/"],
|
|
306
|
+
["unpkg.com", "/"],
|
|
307
|
+
]);
|
|
308
|
+
/** Which file's bytes an address serves, where doctor knows them without
|
|
309
|
+
the network — the one place that decides whether a digest is checked:
|
|
310
|
+
a path on the site's own origin (a file of the repository, if there is
|
|
311
|
+
one), or the published runtime of a release on an npm CDN, at its
|
|
312
|
+
canonical address (https, no port of its own). Either only when the
|
|
313
|
+
path alone decides the answer: no query (unpkg's `?meta` answers with
|
|
314
|
+
JSON, a server may answer `?v=2` with anything) and no credentials;
|
|
315
|
+
the fragment never leaves the browser. Null for anything else. */
|
|
316
|
+
export function servedFile(src, origin) {
|
|
317
|
+
const sent = new URL(src.href);
|
|
318
|
+
sent.hash = "";
|
|
319
|
+
if (sent.href.includes("?") || sent.username !== "" || sent.password !== "")
|
|
320
|
+
return null;
|
|
321
|
+
if (sent.origin === origin) {
|
|
322
|
+
try {
|
|
323
|
+
return { copy: decodeURIComponent(sent.pathname) };
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const root = sent.protocol === "https:" && sent.port === "" ? NPM_CDNS.get(sent.hostname) : undefined;
|
|
330
|
+
if (root === undefined || !sent.pathname.startsWith(root))
|
|
331
|
+
return null;
|
|
332
|
+
const version = /^@mapled\/vanilla@(\d+\.\d+\.\d+)\/dist\/mapled\.js$/.exec(sent.pathname.slice(root.length))?.[1];
|
|
333
|
+
return version ? { version } : null;
|
|
334
|
+
}
|
|
335
|
+
/** The digest of the file a path of the site names, when it sits in the repository (or its public/). */
|
|
336
|
+
async function copyDigest(dir, served, hash) {
|
|
337
|
+
for (const at of [path.resolve(dir), path.resolve(dir, "public")]) {
|
|
338
|
+
const file = path.join(at, served);
|
|
339
|
+
if (!file.startsWith(at + path.sep))
|
|
340
|
+
continue;
|
|
341
|
+
const bytes = await readFile(file).catch(() => null);
|
|
342
|
+
if (bytes && bytes.length <= 8 * 1024 * 1024)
|
|
343
|
+
return { digest: createHash(hash).update(bytes).digest(), of: path.relative(dir, file).split(path.sep).join("/") };
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
/** The digest of a release's runtime file: sha384 is the only one published. */
|
|
348
|
+
async function releasedDigest(dir, version, hash) {
|
|
349
|
+
if (hash !== "sha384")
|
|
350
|
+
return null;
|
|
351
|
+
let value = VANILLA_DIGESTS[version];
|
|
352
|
+
if (value === undefined) {
|
|
353
|
+
// a version newer than this CLI: the package installed next to the pages, if it is that version
|
|
354
|
+
const pkg = path.join(dir, "node_modules", "@mapled", "vanilla");
|
|
355
|
+
const installed = await readFile(path.join(pkg, "package.json"), "utf8")
|
|
356
|
+
.then((raw) => JSON.parse(raw).version)
|
|
357
|
+
.catch(() => null);
|
|
358
|
+
if (installed === version)
|
|
359
|
+
value = (await readFile(path.join(pkg, "dist", "mapled.js.sha384"), "utf8").catch(() => "")).trim();
|
|
360
|
+
}
|
|
361
|
+
if (!value || !/^sha384-[A-Za-z0-9+/]{64}$/.test(value))
|
|
362
|
+
return null;
|
|
363
|
+
return { digest: Buffer.from(value.slice("sha384-".length), "base64"), of: `@mapled/vanilla ${version}` };
|
|
364
|
+
}
|
|
365
|
+
function refusalText(r) {
|
|
366
|
+
const more = r.more === 0 ? "" : ` ${r.more} more page${r.more === 1 ? " has" : "s have"} a tag a browser refuses.`;
|
|
367
|
+
if ("credentials" in r) {
|
|
368
|
+
return `${r.file} loads it with crossorigin="use-credentials" — jsDelivr and unpkg answer every origin with "*", which a browser doesn't take for a request with credentials, so it refuses the runtime and the page shows only its own HTML. Use crossorigin="anonymous".${more}`;
|
|
369
|
+
}
|
|
370
|
+
if ("cors" in r) {
|
|
371
|
+
return `${r.file} loads it with integrity and without crossorigin — a browser checks a file from another origin only when it came with CORS, so it refuses the runtime and the page shows only its own HTML. Add crossorigin="anonymous" to the script tag.${more}`;
|
|
148
372
|
}
|
|
149
|
-
return
|
|
373
|
+
return `The integrity in ${r.file} isn't the digest of ${r.of} — a browser refuses the file, and the page shows only its own HTML. Use integrity="${r.digest}".${more}`;
|
|
150
374
|
}
|
|
151
375
|
export function checkScriptTag(tag, latest) {
|
|
152
376
|
const key = "sdk";
|
|
@@ -160,6 +384,9 @@ export function checkScriptTag(tag, latest) {
|
|
|
160
384
|
detail: `No page loads it yet. Put <script src="https://cdn.jsdelivr.net/npm/@mapled/vanilla@${at}/dist/mapled.js" data-mapled-key="…" defer></script> into the pages.`,
|
|
161
385
|
};
|
|
162
386
|
}
|
|
387
|
+
// before the version: a tag that doesn't run is worse than an old one
|
|
388
|
+
if (tag.refused)
|
|
389
|
+
return { key, label, status: "warning", detail: refusalText(tag.refused) };
|
|
163
390
|
if (tag.version === null) {
|
|
164
391
|
return { key, label, status: "warning", detail: `${tag.file} loads it without a version — that address follows every release. Pin it: @mapled/vanilla@${at}.` };
|
|
165
392
|
}
|
package/dist/html5.d.ts
CHANGED
|
@@ -18,11 +18,21 @@ import { type Token, type TreeAdapter, type TreeAdapterTypeMap } from "parse5";
|
|
|
18
18
|
before a <table>: indexOf), the adoption agency (a misnested </b>:
|
|
19
19
|
splice from the front) and repeated <html>/<body> tags (every
|
|
20
20
|
attribute so far) walk them — 160,000 "a<br>" in a table: 11 s.
|
|
21
|
-
The worst page within the cap is the depth times its start tags.
|
|
21
|
+
The worst page within the cap is the depth times its start tags.
|
|
22
|
+
|
|
23
|
+
parse5 can't read a numeric character reference of 309 or more decimal
|
|
24
|
+
digits, or of 256 or more hex ones, at all: BadReference. */
|
|
22
25
|
/** How deep elements may be open at once — Chrome's own limit of the tree it builds. */
|
|
23
26
|
export declare const MAX_OPEN = 512;
|
|
24
27
|
export declare class TooDeep extends Error {
|
|
25
28
|
}
|
|
29
|
+
/** A numeric character reference parse5 can't read: its decoder (entities)
|
|
30
|
+
multiplies by 10 or 16 to the power of the digits — Infinity past 308
|
|
31
|
+
decimal or 255 hex ones — and 0 × Infinity is NaN, on which parse5
|
|
32
|
+
throws RangeError. Browsers read it as a character. The same guard is
|
|
33
|
+
in the preview proxy's reader (apps/api/src/lib/previewProxyHtml.ts). */
|
|
34
|
+
export declare class BadReference extends Error {
|
|
35
|
+
}
|
|
26
36
|
export type Html5Attr = Token.Attribute;
|
|
27
37
|
/** A node of the tree: an element (tagName), text, a comment, a doctype,
|
|
28
38
|
the document or a template's content — children linked both ways. Its
|
|
@@ -61,6 +71,7 @@ type Map_ = TreeAdapterTypeMap<Html5Node, Html5Node, Html5Node, Html5Node, Html5
|
|
|
61
71
|
/** parse5's default tree adapter, method for method, on linked children. */
|
|
62
72
|
export declare const linked: TreeAdapter<Map_>;
|
|
63
73
|
/** The document, with scripting on — as the runtime sees it (a <noscript>
|
|
64
|
-
is text). Throws TooDeep past MAX_OPEN open elements
|
|
74
|
+
is text). Throws TooDeep past MAX_OPEN open elements, BadReference at a
|
|
75
|
+
numeric character reference parse5 can't read. */
|
|
65
76
|
export declare function parseDocument(text: string): Html5Node;
|
|
66
77
|
export {};
|
package/dist/html5.js
CHANGED
|
@@ -7853,6 +7853,9 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
7853
7853
|
var MAX_OPEN = 512;
|
|
7854
7854
|
var TooDeep = class extends Error {
|
|
7855
7855
|
};
|
|
7856
|
+
var BadReference = class extends Error {
|
|
7857
|
+
};
|
|
7858
|
+
var isCodePoint = (cp) => Number.isInteger(cp) && cp >= 0 && cp <= 1114111;
|
|
7856
7859
|
var Html5Node = class {
|
|
7857
7860
|
constructor(nodeName, extra = {}) {
|
|
7858
7861
|
this.nodeName = nodeName;
|
|
@@ -7991,6 +7994,11 @@ var Guarded = class extends Parser {
|
|
|
7991
7994
|
names.add(this.currentAttr.name);
|
|
7992
7995
|
tag.attrs.push(this.currentAttr);
|
|
7993
7996
|
};
|
|
7997
|
+
const reference = tokenizer._flushCodePointConsumedAsCharacterReference;
|
|
7998
|
+
tokenizer._flushCodePointConsumedAsCharacterReference = function(cp) {
|
|
7999
|
+
if (!isCodePoint(cp)) throw new BadReference();
|
|
8000
|
+
reference.call(this, cp);
|
|
8001
|
+
};
|
|
7994
8002
|
}
|
|
7995
8003
|
onItemPush(...args) {
|
|
7996
8004
|
if (this.openElements.stackTop >= MAX_OPEN) throw new TooDeep();
|
|
@@ -8001,6 +8009,7 @@ function parseDocument(text) {
|
|
|
8001
8009
|
return Guarded.parse(text, { scriptingEnabled: true, treeAdapter: linked });
|
|
8002
8010
|
}
|
|
8003
8011
|
export {
|
|
8012
|
+
BadReference,
|
|
8004
8013
|
Html5Node,
|
|
8005
8014
|
MAX_OPEN,
|
|
8006
8015
|
TooDeep,
|
package/dist/report.d.ts
CHANGED
|
@@ -122,6 +122,10 @@ export type PreviewAnswer = {
|
|
|
122
122
|
status: "not_set_up";
|
|
123
123
|
origin: string;
|
|
124
124
|
path: string | null;
|
|
125
|
+
} | {
|
|
126
|
+
status: "no_route";
|
|
127
|
+
origin: string;
|
|
128
|
+
path: string | null;
|
|
125
129
|
};
|
|
126
130
|
/** "5 minutes" until the link stops working, or null when unknown. */
|
|
127
131
|
export declare function linkLifetime(expiresAt: string | undefined, now?: number): string | null;
|
package/dist/scanHtml.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type HtmlElement = {
|
|
|
20
20
|
};
|
|
21
21
|
/** The elements of a page and their attributes — parse5's tree (html5.ts),
|
|
22
22
|
a <template>'s children being its content. Built without recursion:
|
|
23
|
-
a page nests up to MAX_OPEN elements, deeper throws TooDeep
|
|
23
|
+
a page nests up to MAX_OPEN elements, deeper throws TooDeep; a
|
|
24
|
+
numeric character reference parse5 can't read throws BadReference. */
|
|
24
25
|
export declare function parseHtml(text: string): HtmlElement;
|
|
25
26
|
/** Where a file is served and the key its bindings start with:
|
|
26
27
|
index.html → "/" (index), blog/index.html → "/blog/" (blog/index),
|
package/dist/scanHtml.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { MAX_OPEN, parseDocument, TooDeep } from "./html5.js";
|
|
4
|
+
import { BadReference, MAX_OPEN, parseDocument, TooDeep } from "./html5.js";
|
|
5
5
|
import { targetFor } from "./manifest.js";
|
|
6
6
|
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
7
7
|
const MAX_DEPTH = MAX_OPEN;
|
|
8
8
|
/** The elements of a page and their attributes — parse5's tree (html5.ts),
|
|
9
9
|
a <template>'s children being its content. Built without recursion:
|
|
10
|
-
a page nests up to MAX_OPEN elements, deeper throws TooDeep
|
|
10
|
+
a page nests up to MAX_OPEN elements, deeper throws TooDeep; a
|
|
11
|
+
numeric character reference parse5 can't read throws BadReference. */
|
|
11
12
|
export function parseHtml(text) {
|
|
12
13
|
const root = { name: "#document", attrs: new Map(), children: [] };
|
|
13
14
|
const pending = [[parseDocument(text), root]];
|
|
@@ -148,6 +149,7 @@ export async function scanHtml(dir, opts) {
|
|
|
148
149
|
const noTemplate = [];
|
|
149
150
|
const unusableRoutes = [];
|
|
150
151
|
const tooDeep = [];
|
|
152
|
+
const badReferences = [];
|
|
151
153
|
const notGroups = [];
|
|
152
154
|
const seenRoutes = new Set();
|
|
153
155
|
const pageKeys = new Set();
|
|
@@ -170,9 +172,12 @@ export async function scanHtml(dir, opts) {
|
|
|
170
172
|
doc = parseHtml(text);
|
|
171
173
|
}
|
|
172
174
|
catch (e) {
|
|
173
|
-
if (
|
|
175
|
+
if (e instanceof TooDeep)
|
|
176
|
+
tooDeep.push(file);
|
|
177
|
+
else if (e instanceof BadReference)
|
|
178
|
+
badReferences.push(file);
|
|
179
|
+
else
|
|
174
180
|
throw e;
|
|
175
|
-
tooDeep.push(file);
|
|
176
181
|
continue;
|
|
177
182
|
}
|
|
178
183
|
seenRoutes.add(served);
|
|
@@ -422,6 +427,8 @@ export async function scanHtml(dir, opts) {
|
|
|
422
427
|
notes.push(`A list or an each without a <template> isn't filled: ${list(noTemplate)}.`);
|
|
423
428
|
if (tooDeep.length > 0)
|
|
424
429
|
notes.push(`Elements nest deeper than ${MAX_OPEN} levels, so the scan didn't read ${count(tooDeep) === 1 ? "this page" : "these pages"}: ${list(tooDeep)}.`);
|
|
430
|
+
if (badReferences.length > 0)
|
|
431
|
+
notes.push(`A numeric character reference (&#…;) is too long to read, so the scan didn't read ${count(badReferences) === 1 ? "this page" : "these pages"}: ${list(badReferences)}.`);
|
|
425
432
|
if (unusableRoutes.length > 0)
|
|
426
433
|
notes.push(`Preview can't open ${count(unusableRoutes) === 1 ? "this address" : "these addresses"} — rename the file or the slug parameter: ${list(unusableRoutes)}.`);
|
|
427
434
|
// keys within the manifest's 120 characters, and still one per binding
|
package/dist/types.js
CHANGED
|
@@ -153,7 +153,9 @@ function renderedField(field, owner, names, extras) {
|
|
|
153
153
|
if (field.type === "computed" && field.computed) {
|
|
154
154
|
return { required: false, rendered: computedType(field.computed.resultType) };
|
|
155
155
|
}
|
|
156
|
-
|
|
156
|
+
const scalar = scalarType(field.type, field.options);
|
|
157
|
+
// translated (§28.2): one language per read — `locale` on the read picks it
|
|
158
|
+
return { required: field.required, rendered: field.localized === true ? { ...scalar, note: `${scalar.note}, translated` } : scalar };
|
|
157
159
|
}
|
|
158
160
|
/** Unique type names: the singular PascalCase of the key; the raw
|
|
159
161
|
PascalCase when two keys meet there; a numeric suffix as a last resort. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Subresource Integrity of "@mapled/vanilla"'s single file for each
|
|
2
|
+
released version — `dist/mapled.js.sha384` of the package. `doctor`
|
|
3
|
+
checks a page's `integrity` against it without asking the network.
|
|
4
|
+
The same table as VANILLA_INTEGRITY of the API (lib/siteStack.ts),
|
|
5
|
+
which siteStack.test.ts compares with this one: a release of the
|
|
6
|
+
runtime adds its line in both. A version this CLI doesn't know yet is
|
|
7
|
+
read from an installed package, or its digest isn't checked. */
|
|
8
|
+
export declare const VANILLA_DIGESTS: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Subresource Integrity of "@mapled/vanilla"'s single file for each
|
|
2
|
+
released version — `dist/mapled.js.sha384` of the package. `doctor`
|
|
3
|
+
checks a page's `integrity` against it without asking the network.
|
|
4
|
+
The same table as VANILLA_INTEGRITY of the API (lib/siteStack.ts),
|
|
5
|
+
which siteStack.test.ts compares with this one: a release of the
|
|
6
|
+
runtime adds its line in both. A version this CLI doesn't know yet is
|
|
7
|
+
read from an installed package, or its digest isn't checked. */
|
|
8
|
+
export const VANILLA_DIGESTS = {
|
|
9
|
+
"0.1.0": "sha384-mbuMcQAueviAbkTzgvxc8oDtspuYH6lFLUgE+gocGexnI8ehgSgdjnNMNL7JPk4q",
|
|
10
|
+
"0.1.1": "sha384-2vgQ4g9re/eiYxEa/H3WKpWI5C9L4wCB/62c8Kq+LOfZDzmqc2ozqMGC8mZoOUqk",
|
|
11
|
+
"0.1.2": "sha384-bhIJoFuEGouAZtHbYeNZeN0m6fqFVWpQw+YkSMJhfk/7m5bUwS/qVWZ3xczw/1du",
|
|
12
|
+
"0.2.0": "sha384-HJ5XG9AhAlbKe6jPRVaef3whiVup/5WhprMwqC6RwWhpq6Pb0OsRLzbLmLJ8WH0f",
|
|
13
|
+
"0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
|
|
14
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
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",
|