@mapled/cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/doctor.js +52 -5
- package/dist/live.d.ts +94 -36
- package/dist/live.js +279 -60
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Scanned 2 HTML files — 2 pages, 11 bindings.
|
|
|
128
128
|
⚠ This path names nothing in the schema: home.tagline (index.html). A misspelled field empties its place on the page, a misspelled single leaves the page's own HTML.
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
For a Next.js site the scan also records its live updates: `capabilities.realtime` is `true`, with the route in `releaseRoute`, when `<MapledLive />` or `watchRelease` asks — at the default path or one written in the code — a route whose exported `GET` answers with `createReleaseHandler`, and `false` otherwise — `MAPLED.md` then tells the next agent whether open tabs follow Publish. Capabilities aren't part of the integration hash; `doctor`'s Site manifest line says when the pushed ones differ.
|
|
131
|
+
For a Next.js site the scan also records its live updates: `capabilities.realtime` is `true`, with the route in `releaseRoute`, when `<MapledLive />` or `watchRelease` asks — at the default path or one written in the code — a route whose exported `GET` answers with `createReleaseHandler`, or, on the Pages Router, an API route whose default export answers with `createPagesReleaseHandler` (with `<MapledLive />` from `@mapled/next/live/pages` in `pages/_app`), and `false` otherwise — `MAPLED.md` then tells the next agent whether open tabs follow Publish. Capabilities aren't part of the integration hash; `doctor`'s Site manifest line says when the pushed ones differ.
|
|
132
132
|
|
|
133
133
|
`--write` creates or updates `mapled/manifest.json`. Bindings the scan can't see (written by your AI agent, or by hand) are kept and listed; `--prune` drops them. Commit the file.
|
|
134
134
|
|
|
@@ -204,7 +204,7 @@ Mapled doctor — Dolphin Landing
|
|
|
204
204
|
1 warning.
|
|
205
205
|
```
|
|
206
206
|
|
|
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. 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.
|
|
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
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.
|
|
210
210
|
|
package/dist/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { access, readdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { integrationHash } from "./integrationHash.js";
|
|
6
|
-
import { BASE_PATH_SINCE, fullAddress, liveState, RELEASE_ROUTE, routeFileFor } from "./live.js";
|
|
6
|
+
import { BASE_PATH_SINCE, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, routeFileFor, runsIn } from "./live.js";
|
|
7
7
|
import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
|
|
8
8
|
import { SCHEMA_FILE } from "./pin.js";
|
|
9
9
|
import { splitGenerated } from "./types.js";
|
|
@@ -467,6 +467,10 @@ export function checkLive(facts, framework) {
|
|
|
467
467
|
const state = liveState(facts);
|
|
468
468
|
const who = (l) => `${l.via} in ${l.file}`;
|
|
469
469
|
const handler = 'createReleaseHandler from "@mapled/next/server"';
|
|
470
|
+
// the Pages Router's: the default export of an API route
|
|
471
|
+
const pagesHandler = 'createPagesReleaseHandler from "@mapled/next/server"';
|
|
472
|
+
const handlerOf = (route) => (route.router === "pages" ? pagesHandler : handler);
|
|
473
|
+
const app = `${facts.pagesDir ?? "pages"}/_app`;
|
|
470
474
|
switch (state.kind) {
|
|
471
475
|
case "unused":
|
|
472
476
|
return {
|
|
@@ -474,19 +478,46 @@ export function checkLive(facts, framework) {
|
|
|
474
478
|
label,
|
|
475
479
|
status: "skipped",
|
|
476
480
|
detail: state.handlerRoute
|
|
477
|
-
?
|
|
481
|
+
? state.handlerRoute.router === "pages"
|
|
482
|
+
? `Not used: ${state.handlerRoute.file} is mounted, but no page listens — add <MapledLive /> from "@mapled/next/live/pages" to ${app}.`
|
|
483
|
+
: `Not used: ${state.handlerRoute.file} is mounted, but no page listens — add <MapledLive /> to the root layout.`
|
|
478
484
|
: "Not used: an open tab shows a publish once it is reloaded.",
|
|
479
485
|
};
|
|
480
486
|
case "served":
|
|
481
487
|
return { key, label, status: "passed", detail: `${state.route.file} — open tabs follow Publish (${who(state.listener)})` };
|
|
482
488
|
case "no_handler":
|
|
483
|
-
return { key, label, status: "warning", detail: `${state.route.file} exists but doesn't use ${
|
|
489
|
+
return { key, label, status: "warning", detail: `${state.route.file} exists but doesn't use ${handlerOf(state.route)} — ${who(state.listener)} can't read the release there.` };
|
|
484
490
|
case "not_get":
|
|
485
491
|
return {
|
|
486
492
|
key,
|
|
487
493
|
label,
|
|
488
494
|
status: "warning",
|
|
489
|
-
detail:
|
|
495
|
+
detail: state.route.router === "pages"
|
|
496
|
+
? `${state.route.file} calls ${pagesHandler}, but doctor can't see its default export always answer with the handler, and the default export is what answers ${who(state.listener)}. Export the handler as the default, or end the default function by returning its call, with no other return or throw.`
|
|
497
|
+
: `${state.route.file} calls ${handler}, but doctor can't see its GET always answer with the handler's response — ${who(state.listener)} asks with GET. Export the handler as GET, or end GET by returning its call, with no other return or throw.`,
|
|
498
|
+
};
|
|
499
|
+
case "router":
|
|
500
|
+
return {
|
|
501
|
+
key,
|
|
502
|
+
label,
|
|
503
|
+
status: "warning",
|
|
504
|
+
detail: `${who(state.listener)} comes from "@mapled/next/live/pages", the Pages Router's component, and ${state.dir}/ is the App Router's folder: it can't mount there (it needs next/router), so open tabs don't follow Publish. Import MapledLive from "@mapled/next/live" in the App Router.`,
|
|
505
|
+
};
|
|
506
|
+
case "pointer":
|
|
507
|
+
return {
|
|
508
|
+
key,
|
|
509
|
+
label,
|
|
510
|
+
status: "warning",
|
|
511
|
+
detail: `${state.route.file} ${state.route.handler === "GET" ? "answers with" : "calls"} ${pagesHandler}, which follows Mapled's live release: it moves the moment someone publishes, before the webhook refreshes what the App Router's pages read. ${who(state.listener)} ${runsIn(state.listener, facts) === "app" ? "runs" : "may run"} in the App Router and would refresh into a release those pages don't show yet. Mount ${handler} at ${routeFileFor(state.path, facts)} in its place — or, if only the Pages Router renders the listener, use <MapledLive /> from "@mapled/next/live/pages" in ${app}.`,
|
|
512
|
+
};
|
|
513
|
+
case "runtime":
|
|
514
|
+
return {
|
|
515
|
+
key,
|
|
516
|
+
label,
|
|
517
|
+
status: "warning",
|
|
518
|
+
detail: state.runtime === null
|
|
519
|
+
? `${state.route.file} may set runtime where doctor can't read it — Next reads it from export const runtime or export const config = { runtime }, written with literals. On the edge runtime ${pagesHandler} has no Node request and response to answer ${who(state.listener)} through: write runtime as "nodejs", or leave it out.`
|
|
520
|
+
: `${state.route.file} sets runtime to ${JSON.stringify(state.runtime)} — ${pagesHandler} answers through Node's request and response, which an API route has only on the Node runtime, so ${who(state.listener)} gets an error there. Leave runtime out of the route, or set it to "nodejs".`,
|
|
490
521
|
};
|
|
491
522
|
case "unresolved":
|
|
492
523
|
return {
|
|
@@ -573,7 +604,22 @@ export function checkLive(facts, framework) {
|
|
|
573
604
|
detail: `${why}. doctor can't see what it does there — a redirect, a rewrite or a sign-in wall keeps ${who(state.listener)} from the release. Leave the path out of its matcher, like "/((?!${local.slice(1)}).*)".`,
|
|
574
605
|
};
|
|
575
606
|
}
|
|
576
|
-
case "missing":
|
|
607
|
+
case "missing": {
|
|
608
|
+
if (state.pages) {
|
|
609
|
+
// the Pages Router: an API route, whose default export is the handler
|
|
610
|
+
const base = facts.basePath ?? "";
|
|
611
|
+
const file = state.endpoint === null ? null : pagesRouteFileFor(state.endpoint, facts);
|
|
612
|
+
return {
|
|
613
|
+
key,
|
|
614
|
+
label,
|
|
615
|
+
status: "warning",
|
|
616
|
+
detail: state.endpoint === null
|
|
617
|
+
? `${who(state.listener)} asks a release route the code doesn't have — export ${pagesHandler} as the default of an API route (${pagesRouteFileFor(RELEASE_ROUTE, facts)} by default).`
|
|
618
|
+
: file !== null
|
|
619
|
+
? `${who(state.listener)} asks ${state.endpoint}, which has no route — export ${pagesHandler} as the default of ${file}.`
|
|
620
|
+
: `${who(state.listener)} asks ${state.endpoint}, which has no route — and in the Pages Router only an API route answers it, at ${base}/api or under it. Point it at ${base}${RELEASE_ROUTE}, and export ${pagesHandler} as the default of ${pagesRouteFileFor(`${base}${RELEASE_ROUTE}`, facts)}.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
577
623
|
return state.endpoint === null
|
|
578
624
|
? {
|
|
579
625
|
key,
|
|
@@ -587,6 +633,7 @@ export function checkLive(facts, framework) {
|
|
|
587
633
|
status: "warning",
|
|
588
634
|
detail: `${who(state.listener)} asks ${state.endpoint}, which has no route — mount ${handler} at ${routeFileFor(state.endpoint, facts)}.`,
|
|
589
635
|
};
|
|
636
|
+
}
|
|
590
637
|
}
|
|
591
638
|
}
|
|
592
639
|
export function checkReads(lastReadAt) {
|
package/dist/live.d.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
follows Publish in open tabs with two pieces: `<MapledLive />` from
|
|
3
3
|
"@mapled/next/live" (or `watchRelease` from "@mapled/next") asks a
|
|
4
4
|
route of the site every few seconds, and that route answers with
|
|
5
|
-
`createReleaseHandler` from "@mapled/next/server".
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
`createReleaseHandler` from "@mapled/next/server". The Pages Router
|
|
6
|
+
has its own two: `<MapledLive />` from "@mapled/next/live/pages", and
|
|
7
|
+
an API route whose default export is `createPagesReleaseHandler`. A
|
|
8
|
+
listener without the route polls a 404 forever and nothing says so —
|
|
9
|
+
`mapled doctor` reads both and says which is missing, and `mapled
|
|
10
|
+
scan` records the result in the manifest (`capabilities.realtime`,
|
|
11
|
+
`releaseRoute`) so MAPLED.md can tell the next agent. The code is read
|
|
12
|
+
with the scanner's tokenizer: comments and strings never count as a
|
|
13
|
+
use, and neither does an imported name the file binds again. */
|
|
12
14
|
/** Where `<MapledLive />` and `watchRelease` ask when no endpoint is given. */
|
|
13
15
|
export declare const RELEASE_ROUTE = "/api/mapled/release";
|
|
14
16
|
/** The `@mapled/next` from which that default follows the site's basePath —
|
|
@@ -23,47 +25,63 @@ export type LiveListener = {
|
|
|
23
25
|
/** the code gives no endpoint: the SDK asks its default, `endpoint` —
|
|
24
26
|
under the site's basePath from `@mapled/next` 0.10.0 */
|
|
25
27
|
byDefault?: true;
|
|
28
|
+
/** `<MapledLive />` from "@mapled/next/live/pages": the Pages Router's
|
|
29
|
+
component, which only the Pages Router can mount (`next/router`) */
|
|
30
|
+
router?: "pages";
|
|
26
31
|
};
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/** A route file and the path it answers: an App Router route handler
|
|
33
|
+
file, or a Pages Router API route (`router: "pages"`) — any file of the
|
|
34
|
+
pages folder whose path is /api or under it. `handler`: "GET" when the
|
|
35
|
+
tab's GET is seen to reach the release — the App Router route's
|
|
36
|
+
exported GET answers with `createReleaseHandler`, the API route's
|
|
37
|
+
default export with `createPagesReleaseHandler` — "other" when the file
|
|
38
|
+
calls that factory but its answer isn't seen to be the handler's (the
|
|
39
|
+
App Router handler exported as POST or not at all — the tab's GET gets
|
|
40
|
+
a 405 — or a GET or a default export that answers something else),
|
|
41
|
+
null when it doesn't call it. `params`: the file sets `dynamicParams`
|
|
42
|
+
— a dynamic route then answers only the params its code lists.
|
|
43
|
+
`segment`: the first route segment setting the file may export as a
|
|
44
|
+
value that changes how Next serves or caches the route
|
|
45
|
+
(SEGMENT_SETTINGS). Both are the App Router's; an API route has
|
|
46
|
+
`runtime` instead: present when the file may run it on another runtime
|
|
47
|
+
than Node's — the runtime it writes out, or null where the reading
|
|
48
|
+
can't tell (pagesRuntime). */
|
|
36
49
|
export type RouteFile = {
|
|
37
50
|
file: string;
|
|
38
51
|
path: string;
|
|
39
52
|
handler: RouteHandler;
|
|
40
53
|
params?: true;
|
|
41
54
|
segment?: SegmentSetting;
|
|
55
|
+
router?: "pages";
|
|
56
|
+
runtime?: string | null;
|
|
42
57
|
};
|
|
43
58
|
export type RouteHandler = "GET" | "other" | null;
|
|
44
|
-
/** A page file and the path it answers: an App Router `page.<ext>`, or
|
|
45
|
-
Pages Router
|
|
46
|
-
as Next makes it — a page more specific than a route takes
|
|
59
|
+
/** A page file and the path it answers: an App Router `page.<ext>`, or a
|
|
60
|
+
Pages Router page (its API routes are route files). It takes part in
|
|
61
|
+
the match as Next makes it — a page more specific than a route takes
|
|
62
|
+
the path. */
|
|
47
63
|
export type PageFile = {
|
|
48
64
|
file: string;
|
|
49
65
|
path: string;
|
|
50
66
|
};
|
|
51
67
|
/** `routeExtension`: what a route file the site lacks would end in — the
|
|
52
|
-
site's page extension, `ts` by default. `appDir`: the App
|
|
53
|
-
Next reads, when the site has
|
|
54
|
-
site starts with (next.config).
|
|
55
|
-
may set `pageExtensions` or
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"export"` (value
|
|
60
|
-
|
|
68
|
+
site's page extension, `ts` by default. `appDir`, `pagesDir`: the App
|
|
69
|
+
Router and Pages Router folders Next reads, when the site has them.
|
|
70
|
+
`basePath`: what every path of the site starts with (next.config).
|
|
71
|
+
`config`: the next.config file, when it may set `pageExtensions` or
|
|
72
|
+
`basePath` where the reading can't follow — then no route can be
|
|
73
|
+
checked. `sdk`: the `@mapled/next` version the site resolves, as Node
|
|
74
|
+
finds the package from the site's folder up — absent when none is
|
|
75
|
+
installed. `output`: next.config sets `output: "export"` (value
|
|
76
|
+
"export") or may set it where the reading can't tell (null) — then
|
|
77
|
+
there may be no server to run any route. */
|
|
61
78
|
export type LiveFacts = {
|
|
62
79
|
listeners: LiveListener[];
|
|
63
80
|
routes: RouteFile[];
|
|
64
81
|
pages?: PageFile[];
|
|
65
82
|
routeExtension?: string;
|
|
66
83
|
appDir?: string;
|
|
84
|
+
pagesDir?: string;
|
|
67
85
|
basePath?: string;
|
|
68
86
|
config?: string;
|
|
69
87
|
sdk?: string;
|
|
@@ -82,6 +100,12 @@ export type Middleware = {
|
|
|
82
100
|
file: string;
|
|
83
101
|
matcher: string[] | "none" | "unread";
|
|
84
102
|
};
|
|
103
|
+
/** `missing`: `pages` when the listener runs in the Pages Router — the
|
|
104
|
+
route it lacks is an API route there. `router`: the Pages Router's
|
|
105
|
+
component in a file of the App Router folder `dir`, where it can't
|
|
106
|
+
mount. `pointer`: an API route answers with `createPagesReleaseHandler`
|
|
107
|
+
a listener the App Router may run (runsIn). `runtime`: the API route
|
|
108
|
+
that answers may run on another runtime than Node's. */
|
|
85
109
|
export type LiveState = {
|
|
86
110
|
kind: "unused";
|
|
87
111
|
handlerRoute: RouteFile | null;
|
|
@@ -94,6 +118,21 @@ export type LiveState = {
|
|
|
94
118
|
kind: "missing";
|
|
95
119
|
listener: LiveListener;
|
|
96
120
|
endpoint: string | null;
|
|
121
|
+
pages?: true;
|
|
122
|
+
} | {
|
|
123
|
+
kind: "router";
|
|
124
|
+
listener: LiveListener;
|
|
125
|
+
dir: string;
|
|
126
|
+
} | {
|
|
127
|
+
kind: "pointer";
|
|
128
|
+
listener: LiveListener;
|
|
129
|
+
route: RouteFile;
|
|
130
|
+
path: string;
|
|
131
|
+
} | {
|
|
132
|
+
kind: "runtime";
|
|
133
|
+
listener: LiveListener;
|
|
134
|
+
route: RouteFile;
|
|
135
|
+
runtime: string | null;
|
|
97
136
|
} | {
|
|
98
137
|
kind: "unresolved";
|
|
99
138
|
listener: LiveListener;
|
|
@@ -155,17 +194,19 @@ export type Capabilities = {
|
|
|
155
194
|
};
|
|
156
195
|
/** The extensions a route file may have when next.config doesn't set `pageExtensions`. */
|
|
157
196
|
export declare const PAGE_EXTENSIONS: string[];
|
|
158
|
-
/** The listeners one file mounts and, for a route file
|
|
159
|
-
serves the release
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
197
|
+
/** The listeners one file mounts and, for a route file of `router`,
|
|
198
|
+
whether it serves the release: an App Router route through its GET, a
|
|
199
|
+
Pages API route through its default export. An imported name the file
|
|
200
|
+
binds again — a parameter, a declaration, destructuring — may not be
|
|
201
|
+
the import where it's used, and the reading can't tell where: none of
|
|
202
|
+
its uses count. A namespace's name the same: its members count only
|
|
203
|
+
where `ns` itself is the import. */
|
|
204
|
+
export declare function liveInFile(file: string, text: string, router?: "app" | "pages"): {
|
|
165
205
|
listeners: LiveListener[];
|
|
166
206
|
handler: RouteHandler;
|
|
167
207
|
params: boolean;
|
|
168
208
|
segment?: SegmentSetting;
|
|
209
|
+
runtime?: string | null;
|
|
169
210
|
};
|
|
170
211
|
/** The route segment settings that change how Next serves the release
|
|
171
212
|
route or reads what the handler reads, in the order doctor names them:
|
|
@@ -252,8 +293,19 @@ export declare function findLive(dir: string, maxFiles?: number): Promise<LiveFa
|
|
|
252
293
|
— which the code doesn't show to be the site's: `watchRelease` fetches it
|
|
253
294
|
as written, and no route file of this site is known to answer it. */
|
|
254
295
|
export declare const fullAddress: (endpoint: string) => boolean;
|
|
296
|
+
/** Which router runs a listener's code, as far as its file tells: a file
|
|
297
|
+
of the App Router folder is the App Router's; the Pages Router's
|
|
298
|
+
`<MapledLive />`, a file of the pages folder, or any file of a site
|
|
299
|
+
whose only router folder is the pages one — the Pages Router's. Null
|
|
300
|
+
for anything else: a file either router may render. */
|
|
301
|
+
export declare function runsIn(listener: LiveListener, facts: LiveFacts): "app" | "pages" | null;
|
|
255
302
|
/** Whether every listener has a route that answers it. The first one
|
|
256
|
-
that doesn't is the one to name.
|
|
303
|
+
that doesn't is the one to name. An API route answers with the live
|
|
304
|
+
release (`createPagesReleaseHandler`), which moves the moment someone
|
|
305
|
+
publishes: it serves only listeners the Pages Router runs, whose pages
|
|
306
|
+
read that same release in getServerSideProps (README). A listener the
|
|
307
|
+
App Router may run refreshes on it before the webhook refreshes what
|
|
308
|
+
the App Router's pages read, into a release they don't show yet. */
|
|
257
309
|
export declare function liveState(facts: LiveFacts): LiveState;
|
|
258
310
|
/** What `mapled scan` writes into the manifest: a listener nothing answers
|
|
259
311
|
isn't listening; the route is the path the tab asks, not a dynamic route's pattern. */
|
|
@@ -262,3 +314,9 @@ export declare function capabilitiesOf(state: LiveState): Capabilities;
|
|
|
262
314
|
reads — without one, under `src/app` when the site keeps its code there
|
|
263
315
|
— and without the basePath, which Next adds itself. */
|
|
264
316
|
export declare function routeFileFor(endpoint: string, facts: LiveFacts): string;
|
|
317
|
+
/** Where an API route answering `endpoint` goes, for a listener the Pages
|
|
318
|
+
Router runs: in the pages folder Next reads — without one, under
|
|
319
|
+
`src/pages` when the site keeps its code there — and without the
|
|
320
|
+
basePath. Null for a path no API route answers: Next serves them at
|
|
321
|
+
/api and under it only. */
|
|
322
|
+
export declare function pagesRouteFileFor(endpoint: string, facts: LiveFacts): string | null;
|
package/dist/live.js
CHANGED
|
@@ -5,13 +5,15 @@ import { appRoute, listFiles, tokenize } from "./scan.js";
|
|
|
5
5
|
follows Publish in open tabs with two pieces: `<MapledLive />` from
|
|
6
6
|
"@mapled/next/live" (or `watchRelease` from "@mapled/next") asks a
|
|
7
7
|
route of the site every few seconds, and that route answers with
|
|
8
|
-
`createReleaseHandler` from "@mapled/next/server".
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
`createReleaseHandler` from "@mapled/next/server". The Pages Router
|
|
9
|
+
has its own two: `<MapledLive />` from "@mapled/next/live/pages", and
|
|
10
|
+
an API route whose default export is `createPagesReleaseHandler`. A
|
|
11
|
+
listener without the route polls a 404 forever and nothing says so —
|
|
12
|
+
`mapled doctor` reads both and says which is missing, and `mapled
|
|
13
|
+
scan` records the result in the manifest (`capabilities.realtime`,
|
|
14
|
+
`releaseRoute`) so MAPLED.md can tell the next agent. The code is read
|
|
15
|
+
with the scanner's tokenizer: comments and strings never count as a
|
|
16
|
+
use, and neither does an imported name the file binds again. */
|
|
15
17
|
/** Where `<MapledLive />` and `watchRelease` ask when no endpoint is given. */
|
|
16
18
|
export const RELEASE_ROUTE = "/api/mapled/release";
|
|
17
19
|
/** The `@mapled/next` from which that default follows the site's basePath —
|
|
@@ -21,16 +23,17 @@ export const BASE_PATH_SINCE = "0.10.0";
|
|
|
21
23
|
export const PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
|
|
22
24
|
// the config files Next reads, in its order
|
|
23
25
|
const NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts", "next.config.cjs"];
|
|
24
|
-
// the
|
|
25
|
-
const WORDS = /MapledLive|watchRelease|createReleaseHandler|\\u/;
|
|
26
|
+
// the names as written, or an escape that may spell one: `import { w\u0061tchRelease as w }`
|
|
27
|
+
const WORDS = /MapledLive|watchRelease|createReleaseHandler|createPagesReleaseHandler|\\u/;
|
|
26
28
|
/** A member `object.name` as one name among the file's: no identifier
|
|
27
29
|
reads the same. A namespace's `ns.createReleaseHandler` is one — a
|
|
28
30
|
namespace can't be written to, so its member is the import wherever
|
|
29
31
|
`ns` itself is. */
|
|
30
32
|
const memberName = (object, name) => `${object}.${name}`;
|
|
31
|
-
/** The imports of the
|
|
33
|
+
/** The imports of the exports, and of their modules as namespaces; type-only imports don't count. */
|
|
32
34
|
function importsOf(tokens) {
|
|
33
|
-
const
|
|
35
|
+
const names = () => ({ live: new Set(), livePages: new Set(), watch: new Set(), handler: new Set(), pagesHandler: new Set() });
|
|
36
|
+
const found = { ...names(), ns: names() };
|
|
34
37
|
for (let i = 0; i < tokens.length; i++) {
|
|
35
38
|
const t = tokens[i];
|
|
36
39
|
if (t.kind !== "ident" || t.value !== "import")
|
|
@@ -73,18 +76,26 @@ function importsOf(tokens) {
|
|
|
73
76
|
for (const n of names) {
|
|
74
77
|
if (source === "@mapled/next/live" && n.imported === "MapledLive")
|
|
75
78
|
found.live.add(n.local);
|
|
79
|
+
if (source === "@mapled/next/live/pages" && n.imported === "MapledLive")
|
|
80
|
+
found.livePages.add(n.local);
|
|
76
81
|
if (source === "@mapled/next" && n.imported === "watchRelease")
|
|
77
82
|
found.watch.add(n.local);
|
|
78
83
|
if (source === "@mapled/next/server" && n.imported === "createReleaseHandler")
|
|
79
84
|
found.handler.add(n.local);
|
|
85
|
+
if (source === "@mapled/next/server" && n.imported === "createPagesReleaseHandler")
|
|
86
|
+
found.pagesHandler.add(n.local);
|
|
80
87
|
}
|
|
81
88
|
if (namespace !== null) {
|
|
82
89
|
if (source === "@mapled/next/live")
|
|
83
90
|
found.ns.live.add(namespace);
|
|
91
|
+
if (source === "@mapled/next/live/pages")
|
|
92
|
+
found.ns.livePages.add(namespace);
|
|
84
93
|
if (source === "@mapled/next")
|
|
85
94
|
found.ns.watch.add(namespace);
|
|
86
|
-
if (source === "@mapled/next/server")
|
|
95
|
+
if (source === "@mapled/next/server") {
|
|
87
96
|
found.ns.handler.add(namespace);
|
|
97
|
+
found.ns.pagesHandler.add(namespace);
|
|
98
|
+
}
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
return found;
|
|
@@ -368,9 +379,44 @@ function declarationsOf(tokens, after) {
|
|
|
368
379
|
continue;
|
|
369
380
|
out.push({ name, at: i + 1, exported: exportedAt(i), from: body, to: after[body], fn: true, async: isWord(tokens[i - 1], "async") });
|
|
370
381
|
}
|
|
382
|
+
else if (t.value === "export" && isWord(tokens[i + 1], "default") && !isPunct(tokens[i - 1], ".")) {
|
|
383
|
+
const d = defaultExport(tokens, after, i);
|
|
384
|
+
if (d)
|
|
385
|
+
out.push(d);
|
|
386
|
+
}
|
|
371
387
|
}
|
|
372
388
|
return out;
|
|
373
389
|
}
|
|
390
|
+
/** What `export default` at `i` exports, as a declaration named `default`
|
|
391
|
+
— a reserved word, so no binding of the file reads the same: an
|
|
392
|
+
expression's value; an anonymous function's body; the name of a named
|
|
393
|
+
function, which is the binding the module exports (a function
|
|
394
|
+
declaration the loop above reads too, so writing to that name later
|
|
395
|
+
changes the default as well). A class, a generator or a type is none. */
|
|
396
|
+
function defaultExport(tokens, after, i) {
|
|
397
|
+
const at = i + 1;
|
|
398
|
+
let j = i + 2;
|
|
399
|
+
const async = isWord(tokens[j], "async") && isWord(tokens[j + 1], "function") && !tokens[j + 1].nl;
|
|
400
|
+
if (async)
|
|
401
|
+
j++;
|
|
402
|
+
if (isWord(tokens[j], "function")) {
|
|
403
|
+
if (isPunct(tokens[j + 1], "*"))
|
|
404
|
+
return null;
|
|
405
|
+
if (tokens[j + 1]?.kind === "ident")
|
|
406
|
+
return { name: "default", at, exported: true, from: j + 1, to: j + 2, fn: false, async: false };
|
|
407
|
+
let open = j + 1;
|
|
408
|
+
while (open < tokens.length && open < j + 50 && !isPunct(tokens[open], "("))
|
|
409
|
+
open++;
|
|
410
|
+
if (!isPunct(tokens[open], "("))
|
|
411
|
+
return null;
|
|
412
|
+
const body = bodyAfter(tokens, after[open]);
|
|
413
|
+
return body === null ? null : { name: "default", at, exported: true, from: body, to: after[body], fn: true, async };
|
|
414
|
+
}
|
|
415
|
+
if (["class", "abstract", "interface"].some((w) => isWord(tokens[j], w)))
|
|
416
|
+
return null;
|
|
417
|
+
const to = initializerEnd(tokens, after, i + 2);
|
|
418
|
+
return { name: "default", at, exported: true, from: i + 2, to, fn: false, async: false };
|
|
419
|
+
}
|
|
374
420
|
/** The local names an `export { a as GET, b }` clause exports, by exported
|
|
375
421
|
name; re-exports `from` another module don't count — their code is elsewhere. */
|
|
376
422
|
function exportClauses(tokens) {
|
|
@@ -725,21 +771,29 @@ function readingOf(tokens) {
|
|
|
725
771
|
const bound = new Set([...rebound, ...declarations.map((d) => d.name)]);
|
|
726
772
|
return { b, declarations, rebound, bound };
|
|
727
773
|
}
|
|
728
|
-
/**
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
774
|
+
/** The App Router's factory, whose handler a route exports as GET, and the
|
|
775
|
+
Pages Router's, whose handler an API route exports as its default. */
|
|
776
|
+
const APP_HANDLER = (names) => ({ name: "createReleaseHandler", direct: names.handler, ns: names.ns.handler });
|
|
777
|
+
const PAGES_HANDLER = (names) => ({ name: "createPagesReleaseHandler", direct: names.pagesHandler, ns: names.ns.pagesHandler });
|
|
778
|
+
/** Whether the route's export — `GET` of an App Router route, `default` of
|
|
779
|
+
a Pages API route — answers with the factory's handler: the export is a
|
|
780
|
+
handler — `export const GET = createReleaseHandler(…)`, `export { release
|
|
781
|
+
as GET }`, `const GET = make()`, `export default
|
|
782
|
+
createPagesReleaseHandler(…)`, `export default release` — or a function
|
|
783
|
+
that always returns a handler's response: `export async function
|
|
784
|
+
GET(req) { return release(req); }`, `export default function handler(req,
|
|
785
|
+
res) { return release(req, res); }`. An export that only mentions the
|
|
786
|
+
handler, calls it and drops the result, returns it uncalled (`() =>
|
|
787
|
+
release`), or answers something else on another branch or by a throw
|
|
788
|
+
doesn't. A name declared more than once, or returned more than once, is
|
|
789
|
+
what all its values agree on, a name bound or changed any other way is
|
|
790
|
+
nothing the reading knows, and neither is the factory where the file
|
|
791
|
+
binds its name again — or a namespace's `ns.createReleaseHandler` where
|
|
792
|
+
it binds `ns` again. */
|
|
793
|
+
function releaseHandlerOf(tokens, factory, exported, read) {
|
|
794
|
+
const handlers = factory.direct;
|
|
741
795
|
// `createReleaseHandler(` under a name it's imported as, or `ns.createReleaseHandler(` under a namespace's — not `a.ns.createReleaseHandler(`, a member of something else
|
|
742
|
-
const member = (k) => tokens[k].value ===
|
|
796
|
+
const member = (k) => tokens[k].value === factory.name && isPunct(tokens[k - 1], ".") && tokens[k - 2]?.kind === "ident" && factory.ns.has(tokens[k - 2].value) && !isPunct(tokens[k - 3], ".");
|
|
743
797
|
const calls = (k) => (handlers.has(tokens[k].value) || member(k)) && isPunct(tokens[k + 1], "(");
|
|
744
798
|
if (!tokens.some((u, k) => u.kind === "ident" && calls(k)))
|
|
745
799
|
return null;
|
|
@@ -782,9 +836,9 @@ function releaseHandlerOf(tokens, names, read) {
|
|
|
782
836
|
if (!bound.has(h))
|
|
783
837
|
grow(h, 1 << FACTORY);
|
|
784
838
|
// a namespace's member is the factory wherever the namespace is the import; bound again, neither is anything the reading knows
|
|
785
|
-
for (const ns of
|
|
839
|
+
for (const ns of factory.ns)
|
|
786
840
|
if (!bound.has(ns))
|
|
787
|
-
grow(memberName(ns,
|
|
841
|
+
grow(memberName(ns, factory.name), 1 << FACTORY);
|
|
788
842
|
while (queue.length > 0) {
|
|
789
843
|
const [name, bits] = queue.pop();
|
|
790
844
|
for (const [i, term] of readers.get(name) ?? []) {
|
|
@@ -800,7 +854,7 @@ function releaseHandlerOf(tokens, names, read) {
|
|
|
800
854
|
ofValue[i] = ofValue[i] | fresh;
|
|
801
855
|
const d = values[i];
|
|
802
856
|
// a declaration under an import's name — the factory's or its namespace's — is nothing the reading knows either
|
|
803
|
-
if (handlers.has(d) ||
|
|
857
|
+
if (handlers.has(d) || factory.ns.has(d))
|
|
804
858
|
continue;
|
|
805
859
|
const counts = agree.get(d) ?? [0, 0, 0];
|
|
806
860
|
agree.set(d, counts);
|
|
@@ -817,17 +871,18 @@ function releaseHandlerOf(tokens, names, read) {
|
|
|
817
871
|
}
|
|
818
872
|
}
|
|
819
873
|
const serves = (name) => ((levels.get(name) ?? 0) & (1 << HANDLER)) !== 0;
|
|
820
|
-
const direct = serves(
|
|
821
|
-
const clause = (exportClauses(tokens).get(
|
|
874
|
+
const direct = serves(exported) && declarations.some((d) => d.name === exported && d.exported);
|
|
875
|
+
const clause = (exportClauses(tokens).get(exported) ?? []).some(serves);
|
|
822
876
|
return direct || clause ? "GET" : "other";
|
|
823
877
|
}
|
|
824
|
-
/** The listeners one file mounts and, for a route file
|
|
825
|
-
serves the release
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
878
|
+
/** The listeners one file mounts and, for a route file of `router`,
|
|
879
|
+
whether it serves the release: an App Router route through its GET, a
|
|
880
|
+
Pages API route through its default export. An imported name the file
|
|
881
|
+
binds again — a parameter, a declaration, destructuring — may not be
|
|
882
|
+
the import where it's used, and the reading can't tell where: none of
|
|
883
|
+
its uses count. A namespace's name the same: its members count only
|
|
884
|
+
where `ns` itself is the import. */
|
|
885
|
+
export function liveInFile(file, text, router = "app") {
|
|
831
886
|
const tokens = tokenize(text);
|
|
832
887
|
const names = importsOf(tokens);
|
|
833
888
|
let reading = null;
|
|
@@ -839,7 +894,10 @@ export function liveInFile(file, text) {
|
|
|
839
894
|
return isPunct(tokens[k - 1], ".") && ns?.kind === "ident" && namespaces.has(ns.value) && !isPunct(tokens[k - 3], ".") && !read().bound.has(ns.value);
|
|
840
895
|
};
|
|
841
896
|
const listeners = [];
|
|
842
|
-
const listener = (via, endpoint
|
|
897
|
+
const listener = (via, endpoint, pages = false) => ({
|
|
898
|
+
...(endpoint === undefined ? { file, via, endpoint: RELEASE_ROUTE, byDefault: true } : { file, via, endpoint }),
|
|
899
|
+
...(pages ? { router: "pages" } : {}),
|
|
900
|
+
});
|
|
843
901
|
for (let k = 0; k < tokens.length; k++) {
|
|
844
902
|
const t = tokens[k];
|
|
845
903
|
if (t.kind !== "ident")
|
|
@@ -849,9 +907,17 @@ export function liveInFile(file, text) {
|
|
|
849
907
|
continue;
|
|
850
908
|
listeners.push(listener("<MapledLive />", jsxEndpoint(tokens, k + 1)));
|
|
851
909
|
}
|
|
910
|
+
else if (names.livePages.has(t.value) && isPunct(tokens[k - 1], "<")) {
|
|
911
|
+
if (read().bound.has(t.value))
|
|
912
|
+
continue;
|
|
913
|
+
listeners.push(listener("<MapledLive />", jsxEndpoint(tokens, k + 1), true));
|
|
914
|
+
}
|
|
852
915
|
else if (t.value === "MapledLive" && isPunct(tokens[k - 3], "<") && ofNamespace(k, names.ns.live)) {
|
|
853
916
|
listeners.push(listener("<MapledLive />", jsxEndpoint(tokens, k + 1)));
|
|
854
917
|
}
|
|
918
|
+
else if (t.value === "MapledLive" && isPunct(tokens[k - 3], "<") && ofNamespace(k, names.ns.livePages)) {
|
|
919
|
+
listeners.push(listener("<MapledLive />", jsxEndpoint(tokens, k + 1), true));
|
|
920
|
+
}
|
|
855
921
|
else if (names.watch.has(t.value) && isPunct(tokens[k + 1], "(") && !isPunct(tokens[k - 1], ".")) {
|
|
856
922
|
const prev = tokens[k - 1];
|
|
857
923
|
if (prev?.kind === "ident" && prev.value === "function")
|
|
@@ -864,6 +930,12 @@ export function liveInFile(file, text) {
|
|
|
864
930
|
listeners.push(listener("watchRelease", callEndpoint(tokens, k + 1)));
|
|
865
931
|
}
|
|
866
932
|
}
|
|
933
|
+
if (router === "pages") {
|
|
934
|
+
// an API route: segment config is the App Router's; what may break the handler is the runtime
|
|
935
|
+
const handler = releaseHandlerOf(tokens, PAGES_HANDLER(names), "default", read);
|
|
936
|
+
const runtime = handler === null ? undefined : pagesRuntime(tokens, read().b);
|
|
937
|
+
return { listeners, handler, params: false, ...(runtime !== undefined ? { runtime } : {}) };
|
|
938
|
+
}
|
|
867
939
|
// `dynamicParams` and the segment settings as the route exports them, or an export the reading can't list
|
|
868
940
|
const listing = tokens.some((u, k) => isWord(u, "exports") || (isWord(u, "export") && isPunct(tokens[k + 1], "*")));
|
|
869
941
|
const sets = (name, keeps) => (listing || tokens.some((u) => isName(u, name))) && setsExport(tokens, read().b.after, name, keeps);
|
|
@@ -871,7 +943,7 @@ export function liveInFile(file, text) {
|
|
|
871
943
|
// "force-dynamic" keeps the route only while the handler's read names its own window (keepsWindow)
|
|
872
944
|
const forced = () => sets("dynamic", (t) => literal(t ?? undefined) && t.value === "auto") && !windowsKept(tokens, names, read().b.after);
|
|
873
945
|
const segment = sets("dynamic", SEGMENT_KEEPS.dynamic) ? "dynamic" : forced() ? "force-dynamic" : sets("fetchCache", SEGMENT_KEEPS.fetchCache) ? "fetchCache" : undefined;
|
|
874
|
-
return { listeners, handler: releaseHandlerOf(tokens, names, read), params, ...(segment ? { segment } : {}) };
|
|
946
|
+
return { listeners, handler: releaseHandlerOf(tokens, APP_HANDLER(names), "GET", read), params, ...(segment ? { segment } : {}) };
|
|
875
947
|
}
|
|
876
948
|
/** Whether every release handler the file makes keeps a window of its own
|
|
877
949
|
for the pointer's read (keepsWindow), with the factory only ever called
|
|
@@ -954,7 +1026,8 @@ const SEGMENT_KEEPS = {
|
|
|
954
1026
|
fetchCache: (t) => literal(t ?? undefined) && t.value === "auto",
|
|
955
1027
|
};
|
|
956
1028
|
/** Whether the route may export `name` as anything but a value `keeps`
|
|
957
|
-
accepts, written out on a `const
|
|
1029
|
+
accepts, written out on a `const` (`keeps` gets the one token written,
|
|
1030
|
+
and where the declarator's name is). Next reads only the module's
|
|
958
1031
|
export: a local variable, a property or a string that says the name is
|
|
959
1032
|
nothing to it. An export the reading can't list — `export *`,
|
|
960
1033
|
CommonJS — may be it. */
|
|
@@ -1003,7 +1076,7 @@ function setsExport(tokens, after, name, keeps) {
|
|
|
1003
1076
|
}
|
|
1004
1077
|
else if (tokens[k].kind === "ident") {
|
|
1005
1078
|
// only a `const` keeps its value: a `let` or `var` may change it later
|
|
1006
|
-
if (isName(tokens[k], name) && (declare.value !== "const" || !keeps(writtenValue(tokens, after, k))))
|
|
1079
|
+
if (isName(tokens[k], name) && (declare.value !== "const" || !keeps(writtenValue(tokens, after, k), k)))
|
|
1007
1080
|
return true;
|
|
1008
1081
|
next = k + 1;
|
|
1009
1082
|
}
|
|
@@ -1930,6 +2003,89 @@ export function middlewareMatcher(text) {
|
|
|
1930
2003
|
return "unread";
|
|
1931
2004
|
return config ?? "none";
|
|
1932
2005
|
}
|
|
2006
|
+
/** The literal a declarator at `at` gives its name as Next's build reads
|
|
2007
|
+
it (extractExportedConstValue): `name = "…"`, a type allowed before the
|
|
2008
|
+
`=` and `satisfies T` after the literal — not `as T`, parentheses or
|
|
2009
|
+
anything else, which Next leaves unread. Null for any other value. */
|
|
2010
|
+
function nextLiteral(tokens, after, at) {
|
|
2011
|
+
const eq = isPunct(tokens[at + 1], ":") ? typeEnd(tokens, after, at + 2, tokens.length) : at + 1;
|
|
2012
|
+
if (eq < 0 || !isPunct(tokens[eq], "=") || isPunct(tokens[eq + 1], "="))
|
|
2013
|
+
return null;
|
|
2014
|
+
const v = tokens[eq + 1];
|
|
2015
|
+
if (!literal(v))
|
|
2016
|
+
return null;
|
|
2017
|
+
const end = initializerEnd(tokens, after, eq + 1);
|
|
2018
|
+
let j = eq + 2;
|
|
2019
|
+
if (isWord(tokens[j], "satisfies"))
|
|
2020
|
+
j = typeEnd(tokens, after, j + 1, end);
|
|
2021
|
+
return j === end ? v : null;
|
|
2022
|
+
}
|
|
2023
|
+
/** The runtime a Pages Router API route runs on, as Next's build reads
|
|
2024
|
+
it (getPagesPageStaticInfo): `export const runtime`, and without it
|
|
2025
|
+
`runtime` in `export const config` — each only as a literal. Undefined
|
|
2026
|
+
for Node's: neither is set, or "nodejs" is written out. The literal
|
|
2027
|
+
when the file writes out another — "edge" or "experimental-edge", where
|
|
2028
|
+
the route gets no Node request and response to write the answer to
|
|
2029
|
+
(any other value fails the build). Null when the file may set one where
|
|
2030
|
+
doctor can't read it: an export it can't list, a value that isn't one
|
|
2031
|
+
plain literal, a spread or a key the code computes in the config. Next
|
|
2032
|
+
leaves what it can't read on Node's, but reads some forms doctor
|
|
2033
|
+
doesn't (a regex elsewhere in the config): doctor calls Node's only
|
|
2034
|
+
what it sees to be. */
|
|
2035
|
+
function pagesRuntime(tokens, b) {
|
|
2036
|
+
// `export const runtime = "…"`: it wins over the config's — when Next reads it; one it can't leaves the config's
|
|
2037
|
+
const own = [];
|
|
2038
|
+
const plain = (_, at) => {
|
|
2039
|
+
const t = nextLiteral(tokens, b.after, at);
|
|
2040
|
+
if (t === null)
|
|
2041
|
+
return false;
|
|
2042
|
+
own.push(t.value);
|
|
2043
|
+
return true;
|
|
2044
|
+
};
|
|
2045
|
+
// declared twice, it's the first to Next and the file fails to build: nothing doctor can call Node's
|
|
2046
|
+
if (setsExport(tokens, b.after, "runtime", plain) || own.length > 1)
|
|
2047
|
+
return null;
|
|
2048
|
+
if (own.length === 1)
|
|
2049
|
+
return own[0] === "nodejs" ? undefined : own[0];
|
|
2050
|
+
let runtime = undefined;
|
|
2051
|
+
let declared = -1;
|
|
2052
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
2053
|
+
if (!isWord(tokens[i], "export") || isPunct(tokens[i - 1], ".") || !isWord(tokens[i + 1], "const") || !isWord(tokens[i + 2], "config"))
|
|
2054
|
+
continue;
|
|
2055
|
+
if (declared !== -1)
|
|
2056
|
+
return null;
|
|
2057
|
+
declared = i + 2;
|
|
2058
|
+
let eq = i + 3;
|
|
2059
|
+
if (isPunct(tokens[eq], ":"))
|
|
2060
|
+
eq = typeEnd(tokens, b.after, eq + 1, tokens.length);
|
|
2061
|
+
if (eq < 0 || !isPunct(tokens[eq], "=") || isPunct(tokens[eq + 1], "=") || !isPunct(tokens[eq + 1], "{"))
|
|
2062
|
+
return null;
|
|
2063
|
+
const open = eq + 1;
|
|
2064
|
+
const end = b.after[open];
|
|
2065
|
+
// the statement ends there, or goes on with `satisfies T`
|
|
2066
|
+
const tail = tokens[end];
|
|
2067
|
+
if (tail && !isPunct(tail, ";") && !isPunct(tail, ",") && !isWord(tail, "satisfies") && !(tail.nl === true && tail.kind === "ident"))
|
|
2068
|
+
return null;
|
|
2069
|
+
for (const [a, z] of itemsOf(tokens, b.after, open)) {
|
|
2070
|
+
if (a === z)
|
|
2071
|
+
continue;
|
|
2072
|
+
if (isSpread(tokens, a))
|
|
2073
|
+
return null;
|
|
2074
|
+
const { name, next } = propertyKey(tokens, a);
|
|
2075
|
+
if (name === null)
|
|
2076
|
+
return null;
|
|
2077
|
+
// the last one wins, as in the object; a shorthand, a method or a getter is code
|
|
2078
|
+
if (name === "runtime")
|
|
2079
|
+
runtime = isPunct(tokens[next], ":") && z === next + 2 && literal(tokens[next + 1]) ? tokens[next + 1].value : null;
|
|
2080
|
+
}
|
|
2081
|
+
i = end - 1;
|
|
2082
|
+
}
|
|
2083
|
+
// any other export that may be `config`, as in middlewareMatcher
|
|
2084
|
+
const rest = declared === -1 ? tokens : tokens.map((t, k) => (k === declared ? { ...t, value: "" } : t));
|
|
2085
|
+
if (setsExport(rest, b.after, "config", () => false))
|
|
2086
|
+
return null;
|
|
2087
|
+
return runtime === "nodejs" ? undefined : runtime;
|
|
2088
|
+
}
|
|
1933
2089
|
/** Whether `c` is one of the characters — never for the end of the string. */
|
|
1934
2090
|
const oneOf = (chars, c) => c !== undefined && c.length === 1 && chars.includes(c);
|
|
1935
2091
|
// what a path may hold for doctor to place it among a matcher's sources: nothing a URL would encode
|
|
@@ -2094,8 +2250,10 @@ export async function findLive(dir, maxFiles = 4000) {
|
|
|
2094
2250
|
const routeExtension = extensions === null || extensions.includes("ts") ? "ts" : (extensions[0] ?? "ts");
|
|
2095
2251
|
// a folder under src/ that Next doesn't read: nothing in it runs
|
|
2096
2252
|
const rootApp = await atRoot(dir, "app");
|
|
2097
|
-
const
|
|
2253
|
+
const rootPages = await atRoot(dir, "pages");
|
|
2254
|
+
const ignored = [...(rootApp ? ["src/app/"] : []), ...(rootPages ? ["src/pages/"] : [])];
|
|
2098
2255
|
const appDir = rootApp ? "app" : (await atRoot(dir, "src/app")) ? "src/app" : undefined;
|
|
2256
|
+
const pagesDir = rootPages ? "pages" : (await atRoot(dir, "src/pages")) ? "src/pages" : undefined;
|
|
2099
2257
|
const listeners = [];
|
|
2100
2258
|
const routes = [];
|
|
2101
2259
|
const pages = [];
|
|
@@ -2104,7 +2262,9 @@ export async function findLive(dir, maxFiles = 4000) {
|
|
|
2104
2262
|
continue;
|
|
2105
2263
|
const route = extensions === null ? null : routeFileOf(file, extensions);
|
|
2106
2264
|
const page = extensions === null ? null : pageFileOf(file, extensions);
|
|
2107
|
-
|
|
2265
|
+
// a Pages Router file whose path is /api or under it is an API route, as Next tells them apart
|
|
2266
|
+
const api = page !== null && /^(?:src\/)?pages\//.test(file) && (page === "/api" || page.startsWith("/api/")) ? page : null;
|
|
2267
|
+
if (page !== null && api === null)
|
|
2108
2268
|
pages.push({ file, path: page });
|
|
2109
2269
|
let text;
|
|
2110
2270
|
try {
|
|
@@ -2116,14 +2276,18 @@ export async function findLive(dir, maxFiles = 4000) {
|
|
|
2116
2276
|
if (text.length > 2_000_000)
|
|
2117
2277
|
continue;
|
|
2118
2278
|
const words = WORDS.test(text);
|
|
2119
|
-
const found = words ? liveInFile(file, text) : { listeners: [], handler: null, params: false };
|
|
2279
|
+
const found = words ? liveInFile(file, text, api !== null ? "pages" : "app") : { listeners: [], handler: null, params: false };
|
|
2120
2280
|
listeners.push(...found.listeners);
|
|
2121
2281
|
if (route !== null)
|
|
2122
2282
|
routes.push({ file, path: route, handler: found.handler, ...(found.params ? { params: true } : {}), ...(found.segment ? { segment: found.segment } : {}) });
|
|
2283
|
+
if (api !== null)
|
|
2284
|
+
routes.push({ file, path: api, handler: found.handler, router: "pages", ...(found.runtime !== undefined ? { runtime: found.runtime } : {}) });
|
|
2123
2285
|
}
|
|
2124
2286
|
const facts = { listeners, routes, pages, routeExtension };
|
|
2125
2287
|
if (appDir)
|
|
2126
2288
|
facts.appDir = appDir;
|
|
2289
|
+
if (pagesDir)
|
|
2290
|
+
facts.pagesDir = pagesDir;
|
|
2127
2291
|
if (settings?.basePath)
|
|
2128
2292
|
facts.basePath = settings.basePath;
|
|
2129
2293
|
if (settings === null && config)
|
|
@@ -2264,8 +2428,26 @@ function answerer(routes, pages) {
|
|
|
2264
2428
|
}
|
|
2265
2429
|
/** Whether a route's folders take a param: `[x]`, `[...x]`, `[[...x]]`. */
|
|
2266
2430
|
const dynamicRoute = (route) => route.path.split("/").some((s) => segmentRank(s) > 0);
|
|
2431
|
+
/** Which router runs a listener's code, as far as its file tells: a file
|
|
2432
|
+
of the App Router folder is the App Router's; the Pages Router's
|
|
2433
|
+
`<MapledLive />`, a file of the pages folder, or any file of a site
|
|
2434
|
+
whose only router folder is the pages one — the Pages Router's. Null
|
|
2435
|
+
for anything else: a file either router may render. */
|
|
2436
|
+
export function runsIn(listener, facts) {
|
|
2437
|
+
const under = (dir) => dir !== undefined && listener.file.startsWith(`${dir}/`);
|
|
2438
|
+
if (under(facts.appDir))
|
|
2439
|
+
return "app";
|
|
2440
|
+
if (listener.router === "pages" || under(facts.pagesDir) || (facts.appDir === undefined && facts.pagesDir !== undefined))
|
|
2441
|
+
return "pages";
|
|
2442
|
+
return null;
|
|
2443
|
+
}
|
|
2267
2444
|
/** Whether every listener has a route that answers it. The first one
|
|
2268
|
-
that doesn't is the one to name.
|
|
2445
|
+
that doesn't is the one to name. An API route answers with the live
|
|
2446
|
+
release (`createPagesReleaseHandler`), which moves the moment someone
|
|
2447
|
+
publishes: it serves only listeners the Pages Router runs, whose pages
|
|
2448
|
+
read that same release in getServerSideProps (README). A listener the
|
|
2449
|
+
App Router may run refreshes on it before the webhook refreshes what
|
|
2450
|
+
the App Router's pages read, into a release they don't show yet. */
|
|
2269
2451
|
export function liveState(facts) {
|
|
2270
2452
|
// the conventional path first, then by file: the same answer every run
|
|
2271
2453
|
const rank = (r) => (r.path === RELEASE_ROUTE ? 0 : 1);
|
|
@@ -2276,11 +2458,17 @@ export function liveState(facts) {
|
|
|
2276
2458
|
const base = facts.basePath ?? "";
|
|
2277
2459
|
let served = null;
|
|
2278
2460
|
for (const listener of facts.listeners) {
|
|
2461
|
+
// the Pages Router's component in the App Router can't mount, whatever it asks
|
|
2462
|
+
if (listener.router === "pages" && facts.appDir !== undefined && listener.file.startsWith(`${facts.appDir}/`))
|
|
2463
|
+
return { kind: "router", listener, dir: facts.appDir };
|
|
2464
|
+
const pagesRun = runsIn(listener, facts) === "pages";
|
|
2465
|
+
// the route it lacks: an API route for a listener the Pages Router runs
|
|
2466
|
+
const missing = (endpoint) => ({ kind: "missing", listener, endpoint, ...(pagesRun ? { pages: true } : {}) });
|
|
2279
2467
|
if (listener.endpoint === null) {
|
|
2280
2468
|
// the code decides where it asks: with no release route at all it's
|
|
2281
2469
|
// surely missing; with one — or routes the reading can't know — nothing says it's the route asked
|
|
2282
2470
|
if (handlerRoutes.length === 0 && !facts.config)
|
|
2283
|
-
return
|
|
2471
|
+
return missing(null);
|
|
2284
2472
|
return { kind: "unresolved", listener };
|
|
2285
2473
|
}
|
|
2286
2474
|
const written = sitePath(listener.endpoint);
|
|
@@ -2303,23 +2491,38 @@ export function liveState(facts) {
|
|
|
2303
2491
|
return { kind: "outside", listener, endpoint: at, basePath: base };
|
|
2304
2492
|
const answer = answerOf(base === "" ? at : at.slice(base.length) || "/");
|
|
2305
2493
|
if (!answer)
|
|
2306
|
-
return
|
|
2494
|
+
return missing(at);
|
|
2307
2495
|
if (answer.page) {
|
|
2308
2496
|
// a page takes the path: through a param, a route of its own at the path wins over it; a route can't share a page's path
|
|
2309
2497
|
if (answer.ranks.some((r) => r > 0))
|
|
2310
|
-
return
|
|
2498
|
+
return missing(at);
|
|
2311
2499
|
return { kind: "taken", listener, page: answer.page, endpoint: at };
|
|
2312
2500
|
}
|
|
2313
2501
|
const route = answer.route;
|
|
2314
2502
|
// a dynamic route without the handler: a route of its own at the path wins over the param
|
|
2315
|
-
if (route.handler === null)
|
|
2316
|
-
return
|
|
2317
|
-
if (route.
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2503
|
+
if (route.handler === null && dynamicRoute(route))
|
|
2504
|
+
return missing(at);
|
|
2505
|
+
if (route.router === "pages") {
|
|
2506
|
+
// an API route that isn't the release route is, to a listener the App Router may run, a path taken
|
|
2507
|
+
if (route.handler === null)
|
|
2508
|
+
return pagesRun ? { kind: "no_handler", listener, route } : { kind: "taken", listener, page: { file: route.file, path: route.path }, endpoint: at };
|
|
2509
|
+
if (!pagesRun)
|
|
2510
|
+
return { kind: "pointer", listener, route, path: at };
|
|
2511
|
+
if (route.handler === "other")
|
|
2512
|
+
return { kind: "not_get", listener, route };
|
|
2513
|
+
if (route.runtime !== undefined)
|
|
2514
|
+
return { kind: "runtime", listener, route, runtime: route.runtime };
|
|
2515
|
+
}
|
|
2516
|
+
else {
|
|
2517
|
+
if (route.handler === null)
|
|
2518
|
+
return { kind: "no_handler", listener, route };
|
|
2519
|
+
if (route.handler === "other")
|
|
2520
|
+
return { kind: "not_get", listener, route };
|
|
2521
|
+
if (route.params && dynamicRoute(route))
|
|
2522
|
+
return { kind: "params", listener, route, endpoint: at };
|
|
2523
|
+
if (route.segment)
|
|
2524
|
+
return { kind: "segment", listener, route, setting: route.segment };
|
|
2525
|
+
}
|
|
2323
2526
|
// middleware runs before the route: whatever it does there, the reading can't see
|
|
2324
2527
|
for (const m of facts.middleware ?? []) {
|
|
2325
2528
|
const on = middlewareOn(m, base === "" ? at : at.slice(base.length) || "/", facts.i18n === true);
|
|
@@ -2339,8 +2542,24 @@ export function capabilitiesOf(state) {
|
|
|
2339
2542
|
reads — without one, under `src/app` when the site keeps its code there
|
|
2340
2543
|
— and without the basePath, which Next adds itself. */
|
|
2341
2544
|
export function routeFileFor(endpoint, facts) {
|
|
2342
|
-
const
|
|
2545
|
+
const local = localPath(endpoint, facts);
|
|
2546
|
+
return `${facts.appDir ?? (underSrc(facts) ? "src/app" : "app")}${local === "/" ? "" : local}/route.${facts.routeExtension ?? "ts"}`;
|
|
2547
|
+
}
|
|
2548
|
+
/** Where an API route answering `endpoint` goes, for a listener the Pages
|
|
2549
|
+
Router runs: in the pages folder Next reads — without one, under
|
|
2550
|
+
`src/pages` when the site keeps its code there — and without the
|
|
2551
|
+
basePath. Null for a path no API route answers: Next serves them at
|
|
2552
|
+
/api and under it only. */
|
|
2553
|
+
export function pagesRouteFileFor(endpoint, facts) {
|
|
2554
|
+
const local = localPath(endpoint, facts);
|
|
2555
|
+
if (local !== "/api" && !local.startsWith("/api/"))
|
|
2556
|
+
return null;
|
|
2557
|
+
return `${facts.pagesDir ?? (underSrc(facts) ? "src/pages" : "pages")}${local === "/api" ? "/api/index" : local}.${facts.routeExtension ?? "ts"}`;
|
|
2558
|
+
}
|
|
2559
|
+
/** Whether the site keeps its code under src/: some route, page or listener is there. */
|
|
2560
|
+
const underSrc = (facts) => [...facts.routes.map((r) => r.file), ...(facts.pages ?? []).map((p) => p.file), ...facts.listeners.map((l) => l.file)].some((f) => f.startsWith("src/"));
|
|
2561
|
+
/** An endpoint's path without the basePath, which Next adds itself. */
|
|
2562
|
+
function localPath(endpoint, facts) {
|
|
2343
2563
|
const base = facts.basePath ?? "";
|
|
2344
|
-
|
|
2345
|
-
return `${facts.appDir ?? (underSrc ? "src/app" : "app")}${local === "/" ? "" : local}/route.${facts.routeExtension ?? "ts"}`;
|
|
2564
|
+
return base !== "" && (endpoint === base || endpoint.startsWith(`${base}/`)) ? endpoint.slice(base.length) || "/" : endpoint;
|
|
2346
2565
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|