@mapled/cli 0.14.3 → 0.14.5
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 +5 -5
- package/dist/doctor.d.ts +32 -2
- package/dist/doctor.js +140 -11
- package/dist/pin.d.ts +9 -3
- package/dist/pin.js +71 -5
- package/dist/schema.d.ts +16 -0
- package/dist/schema.js +25 -1
- package/dist/types.d.ts +19 -3
- package/dist/types.js +79 -14
- package/dist/vanillaDigests.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ Only public values live here — the project id, where the generated types go, t
|
|
|
50
50
|
npx @mapled/cli types generate
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out.
|
|
53
|
+
Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out. A group field is typed by an interface of its items; the project's component types become one interface each (`HeroComponent`, with `_type: "hero"` and the item's `_key`), a `components` field a list of the union of the types it allows, and `MapledComponent` the union of them all — switch on `_type` to render an item.
|
|
54
54
|
|
|
55
55
|
```ts
|
|
56
56
|
import type { MapledSchema } from "./mapled-types";
|
|
@@ -206,7 +206,7 @@ Mapled doctor — Dolphin Landing
|
|
|
206
206
|
1 warning.
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
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.
|
|
209
|
+
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 (for a project with a staging channel, through which key: production's or staging's — the key decides which releases a deployment shows), the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
|
|
210
210
|
|
|
211
211
|
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.
|
|
212
212
|
|
package/dist/commands.js
CHANGED
|
@@ -179,7 +179,7 @@ export async function schemaPull(ctx, flags) {
|
|
|
179
179
|
ctx.out(`${SCHEMA_FILE} is up to date (schema ${pin.hash}).`);
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
|
-
const changes = diffSchema(existing.pin.collections, pin.collections);
|
|
182
|
+
const changes = diffSchema(existing.pin.collections, pin.collections, { before: existing.pin.componentTypes, after: pin.componentTypes });
|
|
183
183
|
await writePin(found.dir, pin);
|
|
184
184
|
ctx.out(`Updated ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${pin.hash}): ${summarizeChanges(changes).toLowerCase()}.`);
|
|
185
185
|
ctx.out("");
|
|
@@ -199,7 +199,7 @@ export async function schemaDiff(ctx, flags) {
|
|
|
199
199
|
throw new CliError(`No ${SCHEMA_FILE} yet. Run \`mapled schema pull\` to record the schema this site is built against.`);
|
|
200
200
|
}
|
|
201
201
|
const live = pinSchema(await session.get("/v1/agent/schema"), found.config.project);
|
|
202
|
-
const changes = diffSchema(existing.pin.collections, live.collections);
|
|
202
|
+
const changes = diffSchema(existing.pin.collections, live.collections, { before: existing.pin.componentTypes, after: live.componentTypes });
|
|
203
203
|
if (flags.json) {
|
|
204
204
|
ctx.out(JSON.stringify({ from: existing.pin.hash, to: live.hash, changes }, null, 2));
|
|
205
205
|
}
|
|
@@ -732,12 +732,12 @@ export async function doctor(ctx, flags) {
|
|
|
732
732
|
checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
|
|
733
733
|
// the live facts first: the revalidate route's answer matters once a page listens (#435)
|
|
734
734
|
const live = !framework || framework === "nextjs" ? await findLive(dir) : null;
|
|
735
|
-
checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework, (live?.listeners.length ?? 0) > 0));
|
|
736
|
-
checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
|
|
735
|
+
checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate", framework), framework, (live?.listeners.length ?? 0) > 0));
|
|
736
|
+
checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview", framework), framework));
|
|
737
737
|
// the code is read only where the check applies: a Next.js site (or one doctor can't place)
|
|
738
738
|
checks.push(dr.checkLive(live, framework));
|
|
739
739
|
if (status) {
|
|
740
|
-
checks.push(dr.checkReads(status.delivery.lastReadAt));
|
|
740
|
+
checks.push(dr.checkReads(status.delivery.lastReadAt, status.delivery.staging));
|
|
741
741
|
checks.push(dr.checkWebhook(status.webhook, { local: browser, remote: status.site }));
|
|
742
742
|
let origin = null;
|
|
743
743
|
try {
|
package/dist/doctor.d.ts
CHANGED
|
@@ -29,8 +29,12 @@ export type IntegrationStatus = {
|
|
|
29
29
|
name: string;
|
|
30
30
|
createdAt: string | null;
|
|
31
31
|
};
|
|
32
|
+
/** `staging` only for a project with a staging delivery key (release channels). */
|
|
32
33
|
delivery: {
|
|
33
34
|
lastReadAt: string | null;
|
|
35
|
+
staging?: {
|
|
36
|
+
lastReadAt: string | null;
|
|
37
|
+
};
|
|
34
38
|
};
|
|
35
39
|
webhook: {
|
|
36
40
|
url: string | null;
|
|
@@ -101,6 +105,12 @@ export declare function installedVersion(dir: string, pkg: string): Promise<Pack
|
|
|
101
105
|
export declare const rendersInBrowser: (framework: string | null | undefined) => boolean;
|
|
102
106
|
/** The first-party package a site of this framework reads through. */
|
|
103
107
|
export declare const sdkOf: (framework: string | null | undefined) => string;
|
|
108
|
+
/** The frameworks whose revalidate and preview routes doctor reads: Next.js
|
|
109
|
+
(`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
|
|
110
|
+
(`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
|
|
111
|
+
for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
|
|
112
|
+
or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
|
|
113
|
+
export declare const checksRoutesOf: (framework: string | null | undefined) => boolean;
|
|
104
114
|
/** How a plain HTML site loads "@mapled/vanilla": a script tag in its
|
|
105
115
|
pages — from a CDN, where the version is in the address, or a copy of
|
|
106
116
|
the single file next to the pages, where it is in the file's first
|
|
@@ -152,6 +162,20 @@ export declare function detectFramework(dir: string): Promise<string | undefined
|
|
|
152
162
|
/** -1, 0 or 1 comparing dotted numeric versions; anything else compares by number. */
|
|
153
163
|
export declare function compareVersions(a: string, b: string): number;
|
|
154
164
|
export type RouteKind = "revalidate" | "preview";
|
|
165
|
+
/** The Nuxt module mounts every route itself: a site whose `nuxt.config`
|
|
166
|
+
lists "@mapled/nuxt/module" has them without a file. What `findRoute`
|
|
167
|
+
answers for such a site, when no route file of its own is found. */
|
|
168
|
+
export declare const NUXT_CONFIG_FILES: string[];
|
|
169
|
+
export declare const NUXT_MODULE = "@mapled/nuxt/module";
|
|
170
|
+
export declare function moduleRoute(dir: string): Promise<RouteFile | null>;
|
|
171
|
+
/** Whether a nuxt.config's text lists the module, routes on, in its
|
|
172
|
+
`modules` array: as the string "@mapled/nuxt/module" or as the
|
|
173
|
+
identifier a default import of it binds, bare or as the first element
|
|
174
|
+
of a tuple whose options don't say `routes: false`. Comments don't
|
|
175
|
+
count; neither does an import that never reaches `modules`.
|
|
176
|
+
Conservative: a config that can't be read this way claims nothing,
|
|
177
|
+
and doctor asks for the route files. */
|
|
178
|
+
export declare function mountsModuleRoutes(text: string): boolean;
|
|
155
179
|
export declare const ROUTE_HANDLER: Record<RouteKind, string>;
|
|
156
180
|
/** `cacheMark` and `fetchCache` — the revalidate route only (revalidateRouteFacts). */
|
|
157
181
|
export type RouteFile = {
|
|
@@ -160,7 +184,7 @@ export type RouteFile = {
|
|
|
160
184
|
cacheMark?: boolean;
|
|
161
185
|
fetchCache?: boolean;
|
|
162
186
|
};
|
|
163
|
-
export declare function findRoute(dir: string, kind: RouteKind): Promise<RouteFile | null>;
|
|
187
|
+
export declare function findRoute(dir: string, kind: RouteKind, framework?: string | null): Promise<RouteFile | null>;
|
|
164
188
|
/** GET the site's preview route without a token — the adapter answers 400. */
|
|
165
189
|
export declare function probePreview(origin: string, previewPath: string, f: Fetch): Promise<{
|
|
166
190
|
status: number | null;
|
|
@@ -190,7 +214,13 @@ export declare function checkRoute(kind: RouteKind, found: RouteFile | null, fra
|
|
|
190
214
|
code listens, whether a route answers it. `facts` is null when the
|
|
191
215
|
code wasn't read (a stack this check doesn't cover). */
|
|
192
216
|
export declare function checkLive(facts: LiveFacts | null, framework: string | undefined): Check;
|
|
193
|
-
|
|
217
|
+
/** The delivery key decides which release channel a site reads: a
|
|
218
|
+
project with a staging key has two, and the line says which of them
|
|
219
|
+
sites read through — a production site on the staging key shows
|
|
220
|
+
releases nobody promoted. */
|
|
221
|
+
export declare function checkReads(lastReadAt: string | null, staging?: {
|
|
222
|
+
lastReadAt: string | null;
|
|
223
|
+
}): Check;
|
|
194
224
|
export declare function checkWebhook(webhook: IntegrationStatus["webhook"], site?: {
|
|
195
225
|
local: boolean;
|
|
196
226
|
remote: IntegrationStatus["site"];
|
package/dist/doctor.js
CHANGED
|
@@ -113,7 +113,19 @@ export async function installedVersion(dir, pkg) {
|
|
|
113
113
|
keys are the ones Mapled knows by (lib/siteStack.ts of the API). */
|
|
114
114
|
export const rendersInBrowser = (framework) => framework === "react-spa" || framework === "plain-html";
|
|
115
115
|
/** The first-party package a site of this framework reads through. */
|
|
116
|
-
export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : "@mapled/client";
|
|
116
|
+
export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : framework === "astro" ? "@mapled/astro" : framework === "nuxt" ? "@mapled/nuxt" : "@mapled/client";
|
|
117
|
+
/** The frameworks whose revalidate and preview routes doctor reads: Next.js
|
|
118
|
+
(`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
|
|
119
|
+
(`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
|
|
120
|
+
for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
|
|
121
|
+
or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
|
|
122
|
+
export const checksRoutesOf = (framework) => !framework || framework === "nextjs" || framework === "astro" || framework === "nuxt";
|
|
123
|
+
/** where the adapter's handlers come from, and where the route file goes, by framework */
|
|
124
|
+
const adapterOf = (framework, kind) => framework === "astro"
|
|
125
|
+
? { server: '"@mapled/astro/server"', file: `src/pages/api/mapled/${kind}.ts` }
|
|
126
|
+
: framework === "nuxt"
|
|
127
|
+
? { server: '"@mapled/nuxt/server"', file: `server/api/mapled/${kind}.${kind === "revalidate" ? "post" : "get"}.ts` }
|
|
128
|
+
: { server: '"@mapled/next/server"', file: `app/api/mapled/${kind}/route.ts` };
|
|
117
129
|
const SKIPPED_DIRS = new Set(["node_modules", ".git", ".next", ".vercel", ".netlify", "vendor"]);
|
|
118
130
|
export async function findScriptTag(dir, site) {
|
|
119
131
|
// pages first, breadth first, and not forever: a site's pages sit near its root
|
|
@@ -444,20 +456,116 @@ const ROUTE_FILES = {
|
|
|
444
456
|
"src/app/api/mapled/revalidate/route",
|
|
445
457
|
"pages/api/mapled/revalidate",
|
|
446
458
|
"src/pages/api/mapled/revalidate",
|
|
459
|
+
// Nuxt: a Nitro route, with or without the method in its name
|
|
460
|
+
"server/api/mapled/revalidate.post",
|
|
461
|
+
"server/api/mapled/revalidate",
|
|
447
462
|
],
|
|
448
463
|
preview: [
|
|
449
464
|
"app/api/mapled/preview/route",
|
|
450
465
|
"src/app/api/mapled/preview/route",
|
|
451
466
|
"pages/api/mapled/preview",
|
|
452
467
|
"src/pages/api/mapled/preview",
|
|
468
|
+
"server/api/mapled/preview.get",
|
|
469
|
+
"server/api/mapled/preview",
|
|
453
470
|
],
|
|
454
471
|
};
|
|
472
|
+
/** The Nuxt module mounts every route itself: a site whose `nuxt.config`
|
|
473
|
+
lists "@mapled/nuxt/module" has them without a file. What `findRoute`
|
|
474
|
+
answers for such a site, when no route file of its own is found. */
|
|
475
|
+
export const NUXT_CONFIG_FILES = ["nuxt.config.ts", "nuxt.config.mts", "nuxt.config.js", "nuxt.config.mjs"];
|
|
476
|
+
export const NUXT_MODULE = "@mapled/nuxt/module";
|
|
477
|
+
export async function moduleRoute(dir) {
|
|
478
|
+
for (const file of NUXT_CONFIG_FILES) {
|
|
479
|
+
let text;
|
|
480
|
+
try {
|
|
481
|
+
text = await readFile(path.join(dir, file), "utf8");
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
return mountsModuleRoutes(text) ? { file: `${file} (${NUXT_MODULE} mounts it)`, usesAdapter: true, cacheMark: true } : null;
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
/** Whether a nuxt.config's text lists the module, routes on, in its
|
|
491
|
+
`modules` array: as the string "@mapled/nuxt/module" or as the
|
|
492
|
+
identifier a default import of it binds, bare or as the first element
|
|
493
|
+
of a tuple whose options don't say `routes: false`. Comments don't
|
|
494
|
+
count; neither does an import that never reaches `modules`.
|
|
495
|
+
Conservative: a config that can't be read this way claims nothing,
|
|
496
|
+
and doctor asks for the route files. */
|
|
497
|
+
export function mountsModuleRoutes(text) {
|
|
498
|
+
// comments go: block comments, and line comments that aren't the `//` of a URL
|
|
499
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
500
|
+
// the names a default import of the module binds
|
|
501
|
+
const names = [...code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s*from\s*["'`]@mapled\/nuxt\/module["'`]/g)].map((m) => m[1]);
|
|
502
|
+
const open = code.search(/\bmodules\s*:\s*\[/);
|
|
503
|
+
if (open < 0)
|
|
504
|
+
return false;
|
|
505
|
+
const array = balanced(code, code.indexOf("[", open));
|
|
506
|
+
if (array === null)
|
|
507
|
+
return false;
|
|
508
|
+
// each element of the array, at depth one
|
|
509
|
+
for (const element of elementsOf(array)) {
|
|
510
|
+
const asString = /^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(element);
|
|
511
|
+
const asName = names.some((n) => new RegExp(`^\\s*${n.replace(/\$/g, "\\$")}\\s*$`).test(element));
|
|
512
|
+
if (asString || asName)
|
|
513
|
+
return true;
|
|
514
|
+
const tuple = element.trim().startsWith("[") ? balanced(element, element.indexOf("[")) : null;
|
|
515
|
+
if (tuple === null)
|
|
516
|
+
continue;
|
|
517
|
+
const [first, ...options] = elementsOf(tuple);
|
|
518
|
+
const firstIs = first !== undefined && (/^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(first) || names.some((n) => first.trim() === n));
|
|
519
|
+
if (!firstIs)
|
|
520
|
+
continue;
|
|
521
|
+
return !/\broutes\s*:\s*false\b/.test(options.join(","));
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
/** the text inside the bracket at `at` and its match, or null when unbalanced */
|
|
526
|
+
function balanced(code, at) {
|
|
527
|
+
const pairs = { "[": "]", "{": "}", "(": ")" };
|
|
528
|
+
const stack = [];
|
|
529
|
+
for (let i = at; i < code.length; i++) {
|
|
530
|
+
const c = code[i];
|
|
531
|
+
if (c in pairs)
|
|
532
|
+
stack.push(pairs[c]);
|
|
533
|
+
else if (c === "]" || c === "}" || c === ")") {
|
|
534
|
+
if (stack.pop() !== c)
|
|
535
|
+
return null;
|
|
536
|
+
if (stack.length === 0)
|
|
537
|
+
return code.slice(at + 1, i);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
/** the comma-separated elements of a bracket's inside, at its own depth */
|
|
543
|
+
function elementsOf(inside) {
|
|
544
|
+
const out = [];
|
|
545
|
+
let depth = 0;
|
|
546
|
+
let from = 0;
|
|
547
|
+
for (let i = 0; i < inside.length; i++) {
|
|
548
|
+
const c = inside[i];
|
|
549
|
+
if (c === "[" || c === "{" || c === "(")
|
|
550
|
+
depth += 1;
|
|
551
|
+
else if (c === "]" || c === "}" || c === ")")
|
|
552
|
+
depth -= 1;
|
|
553
|
+
else if (c === "," && depth === 0) {
|
|
554
|
+
out.push(inside.slice(from, i));
|
|
555
|
+
from = i + 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const last = inside.slice(from);
|
|
559
|
+
if (last.trim())
|
|
560
|
+
out.push(last);
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
455
563
|
const ROUTE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".jsx"];
|
|
456
564
|
export const ROUTE_HANDLER = {
|
|
457
565
|
revalidate: "createRevalidateHandler",
|
|
458
566
|
preview: "createPreviewHandler",
|
|
459
567
|
};
|
|
460
|
-
export async function findRoute(dir, kind) {
|
|
568
|
+
export async function findRoute(dir, kind, framework) {
|
|
461
569
|
for (const base of ROUTE_FILES[kind]) {
|
|
462
570
|
for (const ext of ROUTE_EXTENSIONS) {
|
|
463
571
|
const rel = base + ext;
|
|
@@ -472,7 +580,7 @@ export async function findRoute(dir, kind) {
|
|
|
472
580
|
return kind === "revalidate" ? { file: rel, usesAdapter, ...revalidateRouteFacts(text) } : { file: rel, usesAdapter };
|
|
473
581
|
}
|
|
474
582
|
}
|
|
475
|
-
return null;
|
|
583
|
+
return framework === "nuxt" ? moduleRoute(dir) : null;
|
|
476
584
|
}
|
|
477
585
|
/** GET the site's preview route without a token — the adapter answers 400. */
|
|
478
586
|
export async function probePreview(origin, previewPath, f) {
|
|
@@ -656,22 +764,23 @@ export function checkRoute(kind, found, framework, listens = false) {
|
|
|
656
764
|
: "Not needed: the client trades Mapled's preview link on the page itself.",
|
|
657
765
|
};
|
|
658
766
|
}
|
|
659
|
-
if (framework
|
|
767
|
+
if (!checksRoutesOf(framework)) {
|
|
660
768
|
return { key, label, status: "skipped", detail: `No ${kind} route check for ${framework} sites yet.` };
|
|
661
769
|
}
|
|
770
|
+
const adapter = adapterOf(framework, kind);
|
|
662
771
|
if (!found) {
|
|
663
772
|
return kind === "revalidate"
|
|
664
773
|
? {
|
|
665
774
|
key,
|
|
666
775
|
label,
|
|
667
776
|
status: "failed",
|
|
668
|
-
detail:
|
|
777
|
+
detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server}.`,
|
|
669
778
|
}
|
|
670
779
|
: {
|
|
671
780
|
key,
|
|
672
781
|
label,
|
|
673
782
|
status: "warning",
|
|
674
|
-
detail:
|
|
783
|
+
detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server} so Preview from Mapled works.`,
|
|
675
784
|
};
|
|
676
785
|
}
|
|
677
786
|
if (kind === "revalidate" && listens && !found.usesAdapter && !found.cacheMark) {
|
|
@@ -679,7 +788,7 @@ export function checkRoute(kind, found, framework, listens = false) {
|
|
|
679
788
|
key,
|
|
680
789
|
label,
|
|
681
790
|
status: "warning",
|
|
682
|
-
detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from
|
|
791
|
+
detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from ${adapter.server}, or answer with the header x-mapled-cache: await cacheMark().`,
|
|
683
792
|
};
|
|
684
793
|
}
|
|
685
794
|
if (kind === "revalidate" && listens && found.fetchCache) {
|
|
@@ -691,7 +800,7 @@ export function checkRoute(kind, found, framework, listens = false) {
|
|
|
691
800
|
};
|
|
692
801
|
}
|
|
693
802
|
if (!found.usesAdapter) {
|
|
694
|
-
return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from
|
|
803
|
+
return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from ${adapter.server}.` };
|
|
695
804
|
}
|
|
696
805
|
return { key, label, status: "passed", detail: found.file };
|
|
697
806
|
}
|
|
@@ -882,9 +991,14 @@ export function checkLive(facts, framework) {
|
|
|
882
991
|
}
|
|
883
992
|
}
|
|
884
993
|
}
|
|
885
|
-
|
|
994
|
+
/** The delivery key decides which release channel a site reads: a
|
|
995
|
+
project with a staging key has two, and the line says which of them
|
|
996
|
+
sites read through — a production site on the staging key shows
|
|
997
|
+
releases nobody promoted. */
|
|
998
|
+
export function checkReads(lastReadAt, staging) {
|
|
886
999
|
const label = "Site reads content";
|
|
887
|
-
|
|
1000
|
+
const viaStaging = staging?.lastReadAt ?? null;
|
|
1001
|
+
if (!lastReadAt && !viaStaging) {
|
|
888
1002
|
return {
|
|
889
1003
|
key: "reads",
|
|
890
1004
|
label,
|
|
@@ -892,7 +1006,22 @@ export function checkReads(lastReadAt) {
|
|
|
892
1006
|
detail: "No reads yet. Deploy the site and open a page that shows Mapled content.",
|
|
893
1007
|
};
|
|
894
1008
|
}
|
|
895
|
-
|
|
1009
|
+
if (!staging)
|
|
1010
|
+
return { key: "reads", label, status: "passed", detail: `Last read ${ago(lastReadAt)}` };
|
|
1011
|
+
if (!lastReadAt) {
|
|
1012
|
+
return {
|
|
1013
|
+
key: "reads",
|
|
1014
|
+
label,
|
|
1015
|
+
status: "passed",
|
|
1016
|
+
detail: `Staging key: last read ${ago(viaStaging)}. No reads with the production key yet — a site on the staging key shows staging releases.`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
key: "reads",
|
|
1021
|
+
label,
|
|
1022
|
+
status: "passed",
|
|
1023
|
+
detail: `Production key: last read ${ago(lastReadAt)}. Staging key: ${viaStaging ? `last read ${ago(viaStaging)}` : "no reads yet"}.`,
|
|
1024
|
+
};
|
|
896
1025
|
}
|
|
897
1026
|
export function checkWebhook(webhook, site) {
|
|
898
1027
|
const label = "Publish webhook";
|
package/dist/pin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
1
|
+
import { type Schema, type SchemaCollection, type SchemaComponentType } from "./schema.js";
|
|
2
2
|
/** mapled/schema.json — the repository's pin of the schema it was last
|
|
3
3
|
synced with (§21.5). `mapled schema diff` compares it with the live
|
|
4
4
|
schema and says which changes a site that READS the content must
|
|
@@ -9,6 +9,8 @@ export type SchemaPin = {
|
|
|
9
9
|
project: string;
|
|
10
10
|
hash: string;
|
|
11
11
|
collections: SchemaCollection[];
|
|
12
|
+
/** the component types (§45.4) — only when the project has some, so older pins read as before */
|
|
13
|
+
componentTypes?: SchemaComponentType[];
|
|
12
14
|
};
|
|
13
15
|
/** The schema reduced to what the pin keeps — the live answer of
|
|
14
16
|
GET /v1/agent/schema without ids, positions and counters. */
|
|
@@ -22,7 +24,8 @@ export declare function writePin(dir: string, pin: SchemaPin): Promise<string>;
|
|
|
22
24
|
export type Severity = "breaking" | "safe";
|
|
23
25
|
export type ChangeOp = "added" | "removed" | "changed" | "renamed";
|
|
24
26
|
export type SchemaChange = {
|
|
25
|
-
|
|
27
|
+
/** `component`: a component type (§45.4) — `collection` holds its key */
|
|
28
|
+
scope: "collection" | "field" | "subfield" | "component";
|
|
26
29
|
collection: string;
|
|
27
30
|
collectionName: string;
|
|
28
31
|
/** The field key; "group.sub" for a sub-field. */
|
|
@@ -37,7 +40,10 @@ export type SchemaChange = {
|
|
|
37
40
|
(§33.5) is safe while the old one is still an alias of the field —
|
|
38
41
|
the site keeps reading it — and the rest of the field is compared
|
|
39
42
|
under its new key; once the alias is removed it is a removed field. */
|
|
40
|
-
export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[]
|
|
43
|
+
export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[], componentTypes?: {
|
|
44
|
+
before?: SchemaComponentType[];
|
|
45
|
+
after?: SchemaComponentType[];
|
|
46
|
+
}): SchemaChange[];
|
|
41
47
|
/** The diff as terminal lines: one heading per collection, one line per
|
|
42
48
|
change, the severity in the last column. */
|
|
43
49
|
export declare function formatChanges(changes: SchemaChange[], paint?: (severity: Severity, text: string) => string): string[];
|
package/dist/pin.js
CHANGED
|
@@ -50,6 +50,10 @@ function pinField(f) {
|
|
|
50
50
|
// translation is part of the schema hash too (§28.2)
|
|
51
51
|
if (f.localized === true)
|
|
52
52
|
out.localized = true;
|
|
53
|
+
// and so are the component types a field allows (§45.4)
|
|
54
|
+
if (f.type === "components" && f.components) {
|
|
55
|
+
out.components = { allowed: [...f.components.allowed], min: f.components.min ?? null, max: f.components.max ?? null };
|
|
56
|
+
}
|
|
53
57
|
if (f.aliases && f.aliases.length > 0)
|
|
54
58
|
out.aliases = [...f.aliases];
|
|
55
59
|
return out;
|
|
@@ -65,7 +69,10 @@ export function pinSchema(schema, project) {
|
|
|
65
69
|
mode: c.mode,
|
|
66
70
|
fields: c.fields.map(pinField),
|
|
67
71
|
}));
|
|
68
|
-
|
|
72
|
+
const componentTypes = (schema.componentTypes ?? []).map((t) => ({ key: t.key, displayName: t.displayName, fields: t.fields.map(pinSubField) }));
|
|
73
|
+
if (componentTypes.length === 0)
|
|
74
|
+
return { project, hash: schemaHash({ collections }), collections };
|
|
75
|
+
return { project, hash: schemaHash({ collections, componentTypes }), collections, componentTypes };
|
|
69
76
|
}
|
|
70
77
|
export function parsePin(raw, file) {
|
|
71
78
|
let data;
|
|
@@ -84,7 +91,8 @@ export function parsePin(raw, file) {
|
|
|
84
91
|
collections.some((c) => !c || typeof c !== "object" || typeof c.key !== "string" || !Array.isArray(c.fields))) {
|
|
85
92
|
throw new CliError(`${file} isn't a schema pin written by \`mapled schema pull\` — delete it and pull again.`);
|
|
86
93
|
}
|
|
87
|
-
|
|
94
|
+
const componentTypes = Array.isArray(obj.componentTypes) ? obj.componentTypes : undefined;
|
|
95
|
+
return pinSchema({ collections: collections, ...(componentTypes ? { componentTypes } : {}) }, obj.project);
|
|
88
96
|
}
|
|
89
97
|
export async function readPin(dir) {
|
|
90
98
|
const file = path.join(dir, SCHEMA_FILE);
|
|
@@ -109,6 +117,9 @@ function describeType(f, names) {
|
|
|
109
117
|
const target = names.get(f.relation.target) ?? f.relation.target;
|
|
110
118
|
return `relation to ${target}${f.relation.cardinality === "many" ? " (many)" : ""}`;
|
|
111
119
|
}
|
|
120
|
+
if (f.type === "components" && "components" in f && f.components) {
|
|
121
|
+
return `components of ${f.components.allowed.join(", ")}`;
|
|
122
|
+
}
|
|
112
123
|
if (f.type === "group" && "group" in f && f.group) {
|
|
113
124
|
return `group of ${f.group.fields.length} field${f.group.fields.length === 1 ? "" : "s"}${f.group.repeatable ? ", repeatable" : ""}`;
|
|
114
125
|
}
|
|
@@ -155,7 +166,7 @@ function sameJson(a, b) {
|
|
|
155
166
|
(§33.5) is safe while the old one is still an alias of the field —
|
|
156
167
|
the site keeps reading it — and the rest of the field is compared
|
|
157
168
|
under its new key; once the alias is removed it is a removed field. */
|
|
158
|
-
export function diffSchema(before, after) {
|
|
169
|
+
export function diffSchema(before, after, componentTypes = {}) {
|
|
159
170
|
const changes = [];
|
|
160
171
|
const names = new Map();
|
|
161
172
|
for (const c of before)
|
|
@@ -252,6 +263,19 @@ export function diffSchema(before, after) {
|
|
|
252
263
|
});
|
|
253
264
|
}
|
|
254
265
|
}
|
|
266
|
+
// §45.4: a type the list may no longer hold leaves the site's union; a new one is an item the site doesn't render yet
|
|
267
|
+
if (bf.type === "components" && af.type === "components" && bf.components && af.components) {
|
|
268
|
+
for (const k of bf.components.allowed)
|
|
269
|
+
if (!af.components.allowed.includes(k))
|
|
270
|
+
push("changed", `no longer holds ${q(k)} components`, "breaking");
|
|
271
|
+
for (const k of af.components.allowed)
|
|
272
|
+
if (!bf.components.allowed.includes(k))
|
|
273
|
+
push("changed", `may hold ${q(k)} components now`, "safe");
|
|
274
|
+
const limit = (lo, hi) => [lo ? `at least ${lo}` : null, hi ? `up to ${hi}` : null].filter((x) => x !== null).join(", ") || "no item limit";
|
|
275
|
+
if ((bf.components.min ?? null) !== (af.components.min ?? null) || (bf.components.max ?? null) !== (af.components.max ?? null)) {
|
|
276
|
+
push("changed", `${limit(bf.components.min, bf.components.max)} → ${limit(af.components.min, af.components.max)} items`, "safe");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
255
279
|
if (!sameJson(bf.validation, af.validation))
|
|
256
280
|
push("changed", "validation changed", "safe");
|
|
257
281
|
if (!sameJson(bf.defaultValue, af.defaultValue))
|
|
@@ -282,6 +306,48 @@ export function diffSchema(before, after) {
|
|
|
282
306
|
severity: "safe",
|
|
283
307
|
});
|
|
284
308
|
}
|
|
309
|
+
// the component types (§45.4): an item of a type is typed by the type's sub-fields
|
|
310
|
+
const typesBefore = componentTypes.before ?? [];
|
|
311
|
+
const typesAfter = componentTypes.after ?? [];
|
|
312
|
+
const afterTypes = new Map(typesAfter.map((t) => [t.key, t]));
|
|
313
|
+
const beforeTypes = new Map(typesBefore.map((t) => [t.key, t]));
|
|
314
|
+
for (const bt of typesBefore) {
|
|
315
|
+
const at = afterTypes.get(bt.key);
|
|
316
|
+
const base = { collection: bt.key, collectionName: `${at?.displayName ?? bt.displayName} (component)` };
|
|
317
|
+
if (!at) {
|
|
318
|
+
changes.push({ ...base, scope: "component", op: "removed", detail: "component type removed", severity: "breaking" });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (bt.displayName !== at.displayName)
|
|
322
|
+
changes.push({ ...base, scope: "component", op: "changed", detail: `name ${q(bt.displayName)} → ${q(at.displayName)}`, severity: "safe" });
|
|
323
|
+
const afterSubs = new Map(at.fields.map((s) => [s.key, s]));
|
|
324
|
+
const beforeSubs = new Map(bt.fields.map((s) => [s.key, s]));
|
|
325
|
+
for (const bs of bt.fields) {
|
|
326
|
+
const as = afterSubs.get(bs.key);
|
|
327
|
+
if (!as) {
|
|
328
|
+
changes.push({ ...base, scope: "component", field: bs.key, op: "removed", detail: `removed — ${describeType(bs, names)}`, severity: "breaking" });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
diffCommon(bs, as, names, (op, detail, severity) => changes.push({ ...base, scope: "component", field: bs.key, op, detail, severity }));
|
|
332
|
+
}
|
|
333
|
+
for (const as of at.fields) {
|
|
334
|
+
if (beforeSubs.has(as.key))
|
|
335
|
+
continue;
|
|
336
|
+
changes.push({ ...base, scope: "component", field: as.key, op: "added", detail: `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`, severity: "safe" });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const at of typesAfter) {
|
|
340
|
+
if (beforeTypes.has(at.key))
|
|
341
|
+
continue;
|
|
342
|
+
changes.push({
|
|
343
|
+
collection: at.key,
|
|
344
|
+
collectionName: `${at.displayName} (component)`,
|
|
345
|
+
scope: "component",
|
|
346
|
+
op: "added",
|
|
347
|
+
detail: `new component type — ${at.fields.length} field${at.fields.length === 1 ? "" : "s"}`,
|
|
348
|
+
severity: "safe",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
285
351
|
return changes;
|
|
286
352
|
}
|
|
287
353
|
const GLYPH = { added: "+", removed: "-", changed: "~", renamed: ">" };
|
|
@@ -303,14 +369,14 @@ export function formatChanges(changes, paint = (_, t) => t) {
|
|
|
303
369
|
for (const key of order) {
|
|
304
370
|
const list = byCollection.get(key);
|
|
305
371
|
const name = list[0].collectionName;
|
|
306
|
-
const whole = list.find((c) => c.scope === "collection" && c.op !== "changed");
|
|
372
|
+
const whole = list.find((c) => (c.scope === "collection" || (c.scope === "component" && !c.field)) && c.op !== "changed");
|
|
307
373
|
if (whole) {
|
|
308
374
|
lines.push(`${GLYPH[whole.op]} ${name} (${key}) ${whole.detail.padEnd(detailWidth)} ${paint(whole.severity, whole.severity)}`);
|
|
309
375
|
continue;
|
|
310
376
|
}
|
|
311
377
|
lines.push(`${name} (${key})`);
|
|
312
378
|
for (const c of list) {
|
|
313
|
-
const label = c.scope === "collection" ? "(collection)" : c.field;
|
|
379
|
+
const label = c.scope === "collection" ? "(collection)" : c.scope === "component" && !c.field ? "(component)" : c.field;
|
|
314
380
|
lines.push(` ${GLYPH[c.op]} ${label.padEnd(width)} ${c.detail.padEnd(detailWidth)} ${paint(c.severity, c.severity)}`);
|
|
315
381
|
}
|
|
316
382
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -44,6 +44,20 @@ export type SchemaField = {
|
|
|
44
44
|
/** Translated (§28.2): the field holds a value per language; the site
|
|
45
45
|
reads the default language's. Absent on older servers. */
|
|
46
46
|
localized?: boolean;
|
|
47
|
+
/** Components (§45.4): the component types the field allows (keys of
|
|
48
|
+
`componentTypes`) and how many items it takes. Only on a components field. */
|
|
49
|
+
components?: {
|
|
50
|
+
allowed: string[];
|
|
51
|
+
min?: number | null;
|
|
52
|
+
max?: number | null;
|
|
53
|
+
} | null;
|
|
54
|
+
};
|
|
55
|
+
/** A component type (§45.4): the key an item names in `_type`, the name,
|
|
56
|
+
the sub-fields in a group's shape. */
|
|
57
|
+
export type SchemaComponentType = {
|
|
58
|
+
key: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
fields: SchemaSubField[];
|
|
47
61
|
};
|
|
48
62
|
export type SchemaCollection = {
|
|
49
63
|
key: string;
|
|
@@ -55,6 +69,8 @@ export type SchemaCollection = {
|
|
|
55
69
|
};
|
|
56
70
|
export type Schema = {
|
|
57
71
|
collections: SchemaCollection[];
|
|
72
|
+
/** the project's component types, in their order — absent on older servers */
|
|
73
|
+
componentTypes?: SchemaComponentType[];
|
|
58
74
|
};
|
|
59
75
|
/** Twelve hex characters over everything the generated types depend on. */
|
|
60
76
|
export declare function schemaHash(schema: Schema): string;
|
package/dist/schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
/** Twelve hex characters over everything the generated types depend on. */
|
|
3
3
|
export function schemaHash(schema) {
|
|
4
|
-
const
|
|
4
|
+
const collections = schema.collections.map((c) => ({
|
|
5
5
|
key: c.key,
|
|
6
6
|
displayName: c.displayName,
|
|
7
7
|
kind: c.kind,
|
|
@@ -33,7 +33,31 @@ export function schemaHash(schema) {
|
|
|
33
33
|
...(f.type === "computed" && f.computed ? { computed: { expression: f.computed.expression, resultType: f.computed.resultType } } : {}),
|
|
34
34
|
// present only for translated fields, so schemas without translations hash as before
|
|
35
35
|
...(f.localized === true ? { localized: true } : {}),
|
|
36
|
+
// present only for components fields (§45.4)
|
|
37
|
+
...(f.type === "components" && f.components
|
|
38
|
+
? { components: { allowed: [...f.components.allowed], min: f.components.min ?? null, max: f.components.max ?? null } }
|
|
39
|
+
: {}),
|
|
36
40
|
})),
|
|
37
41
|
}));
|
|
42
|
+
// the component types (§45.4) join the projection only when the project
|
|
43
|
+
// has some: the generated types name their sub-fields; a schema without
|
|
44
|
+
// them hashes as before
|
|
45
|
+
const projection = schema.componentTypes && schema.componentTypes.length > 0
|
|
46
|
+
? {
|
|
47
|
+
collections,
|
|
48
|
+
componentTypes: schema.componentTypes.map((t) => ({
|
|
49
|
+
key: t.key,
|
|
50
|
+
displayName: t.displayName,
|
|
51
|
+
fields: t.fields.map((s) => ({
|
|
52
|
+
key: s.key,
|
|
53
|
+
displayName: s.displayName,
|
|
54
|
+
type: s.type,
|
|
55
|
+
required: s.required,
|
|
56
|
+
options: s.options ?? null,
|
|
57
|
+
sensitive: s.sensitive === true,
|
|
58
|
+
})),
|
|
59
|
+
})),
|
|
60
|
+
}
|
|
61
|
+
: collections;
|
|
38
62
|
return createHash("sha256").update(JSON.stringify(projection)).digest("hex").slice(0, 12);
|
|
39
63
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
1
|
+
import { type Schema, type SchemaCollection, type SchemaComponentType } from "./schema.js";
|
|
2
2
|
/** TypeScript types for the content a site reads through @mapled/next:
|
|
3
3
|
one interface per collection and single, plus keyed maps. Sensitive
|
|
4
4
|
fields never reach the site, so they are left out. */
|
|
@@ -16,9 +16,25 @@ export declare function singular(word: string): string;
|
|
|
16
16
|
export declare function pascal(words: string[]): string;
|
|
17
17
|
/** `blog-posts` → `BlogPost`; singles keep their name (`homepage` → `Homepage`). */
|
|
18
18
|
export declare function typeName(key: string, kind: "collection" | "single"): string;
|
|
19
|
+
/** The names the file declares itself — no collection or component type
|
|
20
|
+
may take one: `mapled-schemas` is a collection like any other, and a
|
|
21
|
+
component keyed `mapled` isn't the union of them all. */
|
|
22
|
+
export declare const RESERVED_NAMES: readonly ["MapledAssetId", "MapledRecordId", "MapledJson", "MapledLocation", "MapledComponent", "MapledCollections", "MapledSingles", "MapledCollectionKey", "MapledSingleKey", "MapledSchema"];
|
|
23
|
+
/** Every name the file declares, claimed once: the reserved ones first,
|
|
24
|
+
then the collections', the component types' and the group items' —
|
|
25
|
+
a name already claimed gets a numeric suffix, whoever asks second. */
|
|
26
|
+
export declare class DeclaredNames {
|
|
27
|
+
private readonly taken;
|
|
28
|
+
claim(name: string): string;
|
|
29
|
+
}
|
|
19
30
|
/** Unique type names: the singular PascalCase of the key; the raw
|
|
20
|
-
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
21
|
-
|
|
31
|
+
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
32
|
+
— and when the name is one the file declares itself (`RESERVED_NAMES`). */
|
|
33
|
+
export declare function typeNames(collections: SchemaCollection[], declared?: DeclaredNames): Map<string, string>;
|
|
34
|
+
/** Unique names of the component types (§45.4): the PascalCase of the
|
|
35
|
+
key with `Component` after it, a numeric suffix when that is claimed
|
|
36
|
+
already — by a collection, another type or the file itself. */
|
|
37
|
+
export declare function componentNames(types: SchemaComponentType[], declared: DeclaredNames): Map<string, string>;
|
|
22
38
|
export declare function generateTypes(schema: Schema, opts?: {
|
|
23
39
|
projectName?: string;
|
|
24
40
|
sdk?: string;
|
package/dist/types.js
CHANGED
|
@@ -118,8 +118,8 @@ function renderSubField(sub) {
|
|
|
118
118
|
/** The field's line, followed by one per previous key (§33.5): a
|
|
119
119
|
renamed field is still delivered under them, typed as the field, and
|
|
120
120
|
the editor strikes them through until the site stops reading them. */
|
|
121
|
-
function renderField(field, owner, names, extras) {
|
|
122
|
-
const { required, rendered } = renderedField(field, owner, names, extras);
|
|
121
|
+
function renderField(field, owner, names, extras, components, declared) {
|
|
122
|
+
const { required, rendered } = renderedField(field, owner, names, extras, components, declared);
|
|
123
123
|
const lines = [fieldLine(field.key, field.displayName, required, rendered)];
|
|
124
124
|
for (const alias of field.aliases ?? []) {
|
|
125
125
|
lines.push(` /** @deprecated Renamed to "${comment(field.key)}" — still delivered under this key until the alias is removed in Mapled. */\n` +
|
|
@@ -127,7 +127,7 @@ function renderField(field, owner, names, extras) {
|
|
|
127
127
|
}
|
|
128
128
|
return lines.join("\n");
|
|
129
129
|
}
|
|
130
|
-
function renderedField(field, owner, names, extras) {
|
|
130
|
+
function renderedField(field, owner, names, extras, components = new Map(), declared = new DeclaredNames()) {
|
|
131
131
|
if (field.type === "relation" && field.relation) {
|
|
132
132
|
const target = names.get(field.relation.target) ?? field.relation.target;
|
|
133
133
|
const many = field.relation.cardinality === "many";
|
|
@@ -137,7 +137,8 @@ function renderedField(field, owner, names, extras) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
if (field.type === "group" && field.group) {
|
|
140
|
-
|
|
140
|
+
// the group's interface is a declared name like any other: a component or a collection may have taken it
|
|
141
|
+
const itemName = declared.claim(`${owner}${pascal(wordsOf(field.key))}${field.group.repeatable ? "Item" : ""}`);
|
|
141
142
|
const kept = field.group.fields.filter((s) => s.sensitive !== true);
|
|
142
143
|
const hidden = field.group.fields.filter((s) => s.sensitive === true).map((s) => s.key);
|
|
143
144
|
const lines = kept.map(renderSubField);
|
|
@@ -153,38 +154,95 @@ function renderedField(field, owner, names, extras) {
|
|
|
153
154
|
if (field.type === "computed" && field.computed) {
|
|
154
155
|
return { required: false, rendered: computedType(field.computed.resultType) };
|
|
155
156
|
}
|
|
157
|
+
// components (§45.4): a list of the types the field allows, told apart by `_type`
|
|
158
|
+
if (field.type === "components" && field.components) {
|
|
159
|
+
const allowed = field.components.allowed.map((k) => components.get(k)).filter((n) => n !== undefined);
|
|
160
|
+
const union = allowed.length === 0 ? "never" : allowed.length === 1 ? allowed[0] : `(${allowed.join(" | ")})`;
|
|
161
|
+
const limits = [
|
|
162
|
+
...(field.components.min ? [`at least ${field.components.min}`] : []),
|
|
163
|
+
...(field.components.max ? [`up to ${field.components.max}`] : []),
|
|
164
|
+
];
|
|
165
|
+
const note = `components${limits.length > 0 ? `, ${limits.join(", ")} items` : ""}${field.localized === true ? ", translated" : ""}`;
|
|
166
|
+
return { required: field.required, rendered: { type: `${union}[]`, note } };
|
|
167
|
+
}
|
|
156
168
|
const scalar = scalarType(field.type, field.options);
|
|
157
169
|
// translated (§28.2): one language per read — `locale` on the read picks it
|
|
158
170
|
return { required: field.required, rendered: field.localized === true ? { ...scalar, note: `${scalar.note}, translated` } : scalar };
|
|
159
171
|
}
|
|
172
|
+
/** The names the file declares itself — no collection or component type
|
|
173
|
+
may take one: `mapled-schemas` is a collection like any other, and a
|
|
174
|
+
component keyed `mapled` isn't the union of them all. */
|
|
175
|
+
export const RESERVED_NAMES = [
|
|
176
|
+
"MapledAssetId",
|
|
177
|
+
"MapledRecordId",
|
|
178
|
+
"MapledJson",
|
|
179
|
+
"MapledLocation",
|
|
180
|
+
"MapledComponent",
|
|
181
|
+
"MapledCollections",
|
|
182
|
+
"MapledSingles",
|
|
183
|
+
"MapledCollectionKey",
|
|
184
|
+
"MapledSingleKey",
|
|
185
|
+
"MapledSchema",
|
|
186
|
+
];
|
|
187
|
+
/** Every name the file declares, claimed once: the reserved ones first,
|
|
188
|
+
then the collections', the component types' and the group items' —
|
|
189
|
+
a name already claimed gets a numeric suffix, whoever asks second. */
|
|
190
|
+
export class DeclaredNames {
|
|
191
|
+
taken = new Set(RESERVED_NAMES);
|
|
192
|
+
claim(name) {
|
|
193
|
+
let unique = name;
|
|
194
|
+
for (let n = 2; this.taken.has(unique); n++)
|
|
195
|
+
unique = `${name}${n}`;
|
|
196
|
+
this.taken.add(unique);
|
|
197
|
+
return unique;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
160
200
|
/** Unique type names: the singular PascalCase of the key; the raw
|
|
161
|
-
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
162
|
-
|
|
201
|
+
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
202
|
+
— and when the name is one the file declares itself (`RESERVED_NAMES`). */
|
|
203
|
+
export function typeNames(collections, declared = new DeclaredNames()) {
|
|
163
204
|
const preferred = collections.map((c) => typeName(c.key, c.kind));
|
|
164
205
|
const counts = new Map();
|
|
165
206
|
for (const name of preferred)
|
|
166
207
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
167
|
-
const taken = new Set();
|
|
168
208
|
const out = new Map();
|
|
169
209
|
collections.forEach((c, i) => {
|
|
170
210
|
let name = preferred[i];
|
|
171
211
|
if ((counts.get(name) ?? 0) > 1)
|
|
172
212
|
name = pascal(wordsOf(c.key));
|
|
173
|
-
|
|
174
|
-
for (let n = 2; taken.has(unique); n++)
|
|
175
|
-
unique = `${name}${n}`;
|
|
176
|
-
taken.add(unique);
|
|
177
|
-
out.set(c.key, unique);
|
|
213
|
+
out.set(c.key, declared.claim(name));
|
|
178
214
|
});
|
|
179
215
|
return out;
|
|
180
216
|
}
|
|
217
|
+
/** Unique names of the component types (§45.4): the PascalCase of the
|
|
218
|
+
key with `Component` after it, a numeric suffix when that is claimed
|
|
219
|
+
already — by a collection, another type or the file itself. */
|
|
220
|
+
export function componentNames(types, declared) {
|
|
221
|
+
const out = new Map();
|
|
222
|
+
for (const t of types)
|
|
223
|
+
out.set(t.key, declared.claim(`${pascal(wordsOf(t.key))}Component`));
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** One interface per component type: `_type` as its key, `_key` the
|
|
227
|
+
item's own id, then its sub-fields. */
|
|
228
|
+
function renderComponent(type, name) {
|
|
229
|
+
const lines = [
|
|
230
|
+
` /** The component's type */\n _type: ${JSON.stringify(type.key)};`,
|
|
231
|
+
" /** The item's own id — stays with it across saves */\n _key: string;",
|
|
232
|
+
...type.fields.map(renderSubField),
|
|
233
|
+
];
|
|
234
|
+
return `/** ${comment(type.displayName)} — a component (key "${comment(type.key)}") */\nexport interface ${name} {\n${lines.join("\n")}\n}`;
|
|
235
|
+
}
|
|
181
236
|
export function generateTypes(schema, opts = {}) {
|
|
182
237
|
// the package the site reads through: "@mapled/next" unless the framework says otherwise
|
|
183
238
|
const sdk = opts.sdk ?? "@mapled/next";
|
|
184
239
|
// a React app's assetUrl comes from the client its hooks are built on; the HTML runtime has none to call
|
|
185
240
|
const assets = sdk === "@mapled/react" || sdk === "@mapled/vanilla" ? "@mapled/client" : sdk;
|
|
186
241
|
const hash = schemaHash(schema);
|
|
187
|
-
const
|
|
242
|
+
const declared = new DeclaredNames();
|
|
243
|
+
const names = typeNames(schema.collections, declared);
|
|
244
|
+
const componentTypes = schema.componentTypes ?? [];
|
|
245
|
+
const components = componentNames(componentTypes, declared);
|
|
188
246
|
const blocks = [];
|
|
189
247
|
blocks.push([
|
|
190
248
|
`// Types of the published content this site reads through ${sdk}.`,
|
|
@@ -198,12 +256,19 @@ export function generateTypes(schema, opts = {}) {
|
|
|
198
256
|
"export type MapledJson = Record<string, unknown> | unknown[];",
|
|
199
257
|
"export type MapledLocation = { lat: number; lng: number };",
|
|
200
258
|
].join("\n"));
|
|
259
|
+
// the component types come first: a components field names them
|
|
260
|
+
for (const t of componentTypes)
|
|
261
|
+
blocks.push(renderComponent(t, components.get(t.key)));
|
|
262
|
+
if (componentTypes.length > 0) {
|
|
263
|
+
blocks.push("/** Any component of the project — switch on `_type` to render an item of a components field. */\n" +
|
|
264
|
+
`export type MapledComponent = ${componentTypes.map((t) => components.get(t.key)).join(" | ")};`);
|
|
265
|
+
}
|
|
201
266
|
for (const c of schema.collections) {
|
|
202
267
|
const name = names.get(c.key);
|
|
203
268
|
const extras = [];
|
|
204
269
|
const kept = c.fields.filter((f) => !f.sensitive);
|
|
205
270
|
const hidden = c.fields.filter((f) => f.sensitive).map((f) => f.key);
|
|
206
|
-
const lines = kept.map((f) => renderField(f, name, names, extras));
|
|
271
|
+
const lines = kept.map((f) => renderField(f, name, names, extras, components, declared));
|
|
207
272
|
if (hidden.length > 0)
|
|
208
273
|
lines.push(` // Sensitive, never delivered: ${comment(hidden.join(", "))}`);
|
|
209
274
|
const traits = [
|
package/dist/vanillaDigests.js
CHANGED
|
@@ -12,4 +12,5 @@ export const VANILLA_DIGESTS = {
|
|
|
12
12
|
"0.2.0": "sha384-HJ5XG9AhAlbKe6jPRVaef3whiVup/5WhprMwqC6RwWhpq6Pb0OsRLzbLmLJ8WH0f",
|
|
13
13
|
"0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
|
|
14
14
|
"0.2.2": "sha384-lfhwQoPUtrbLcD2HXSLr2o1z+aUAjotB62i3LFMGxzBlK5lfCQPQPLOoWL+1DBMc",
|
|
15
|
+
"0.2.3": "sha384-SuKH/yk2hpNI+Ec2QGKpZ7jVxe+0Ep0x/fchL560g+XuzG5hnVCIhG9OKX6BKdpY",
|
|
15
16
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
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",
|