@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 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
- ? `Not used: ${state.handlerRoute.file} is mounted, but no page listens — add <MapledLive /> to the root layout.`
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 ${handler} — ${who(state.listener)} can't read the release there.` };
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: `${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.`,
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". A listener without
6
- the route polls a 404 forever and nothing says so — `mapled doctor`
7
- reads both and says which is missing, and `mapled scan` records the
8
- result in the manifest (`capabilities.realtime`, `releaseRoute`) so
9
- MAPLED.md can tell the next agent. The code is read with the scanner's
10
- tokenizer: comments and strings never count as a use, and neither
11
- does an imported name the file binds again. */
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
- /** An App Router route handler file and the path it answers. `handler`:
28
- "GET" when the route's exported GET is seen to answer with
29
- `createReleaseHandler`, "other" when the file calls it but GET isn't
30
- (exported as POST or not at all — the tab's GET gets a 405 — or a GET
31
- whose answer isn't the handler's), null when it doesn't call it.
32
- `params`: the file sets `dynamicParams` — a dynamic route then answers
33
- only the params its code lists. `segment`: the first route segment
34
- setting the file may export as a value that changes how Next serves or
35
- caches the route (SEGMENT_SETTINGS). */
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 any
45
- Pages Router file (`pages/api/…` included). It takes part in the match
46
- as Next makes it — a page more specific than a route takes the path. */
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 Router folder
53
- Next reads, when the site has one. `basePath`: what every path of the
54
- site starts with (next.config). `config`: the next.config file, when it
55
- may set `pageExtensions` or `basePath` where the reading can't follow —
56
- then no route can be checked. `sdk`: the `@mapled/next` version the
57
- site resolves, as Node finds the package from the site's folder up —
58
- absent when none is installed. `output`: next.config sets `output:
59
- "export"` (value "export") or may set it where the reading can't tell
60
- (null) — then there may be no server to run any route. */
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, whether its GET
159
- serves the release. An imported name the file binds again — a
160
- parameter, a declaration, destructuring — may not be the import where
161
- it's used, and the reading can't tell where: none of its uses count.
162
- A namespace's name the same: its members count only where `ns` itself
163
- is the import. */
164
- export declare function liveInFile(file: string, text: string): {
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". A listener without
9
- the route polls a 404 forever and nothing says so — `mapled doctor`
10
- reads both and says which is missing, and `mapled scan` records the
11
- result in the manifest (`capabilities.realtime`, `releaseRoute`) so
12
- MAPLED.md can tell the next agent. The code is read with the scanner's
13
- tokenizer: comments and strings never count as a use, and neither
14
- does an imported name the file binds again. */
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 three names as written, or an escape that may spell one: `import { w\u0061tchRelease as w }`
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 three exports, and of their modules as namespaces; type-only imports don't count. */
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 found = { live: new Set(), watch: new Set(), handler: new Set(), ns: { live: new Set(), watch: new Set(), handler: new Set() } };
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
- /** Whether the route's exported GET answers with the release handler:
729
- GET is a handler — `export const GET = createReleaseHandler(…)`,
730
- `export { release as GET }`, `const GET = make()` — or a function that
731
- always returns a handler's response: `export async function GET(req) {
732
- return release(req); }`. A GET that only mentions the handler, calls it
733
- and drops the result, returns it uncalled (`() => release`), or answers
734
- something else on another branch or by a throw doesn't. A name declared
735
- more than once, or returned more than once, is what all its values agree on,
736
- a name bound or changed any other way is nothing the reading knows,
737
- and neither is `createReleaseHandler` where the file binds its name
738
- again — or a namespace's `ns.createReleaseHandler` where it binds `ns` again. */
739
- function releaseHandlerOf(tokens, names, read) {
740
- const handlers = names.handler;
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 === "createReleaseHandler" && isPunct(tokens[k - 1], ".") && tokens[k - 2]?.kind === "ident" && names.ns.handler.has(tokens[k - 2].value) && !isPunct(tokens[k - 3], ".");
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 names.ns.handler)
839
+ for (const ns of factory.ns)
786
840
  if (!bound.has(ns))
787
- grow(memberName(ns, "createReleaseHandler"), 1 << FACTORY);
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) || names.ns.handler.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("GET") && declarations.some((d) => d.name === "GET" && d.exported);
821
- const clause = (exportClauses(tokens).get("GET") ?? []).some(serves);
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, whether its GET
825
- serves the release. An imported name the file binds again — a
826
- parameter, a declaration, destructuring — may not be the import where
827
- it's used, and the reading can't tell where: none of its uses count.
828
- A namespace's name the same: its members count only where `ns` itself
829
- is the import. */
830
- export function liveInFile(file, text) {
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) => endpoint === undefined ? { file, via, endpoint: RELEASE_ROUTE, byDefault: true } : { file, 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`. Next reads only the module's
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 ignored = [...(rootApp ? ["src/app/"] : []), ...((await atRoot(dir, "pages")) ? ["src/pages/"] : [])];
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
- if (page !== null)
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 { kind: "missing", listener, endpoint: null };
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 { kind: "missing", listener, endpoint: at };
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 { kind: "missing", listener, endpoint: at };
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 dynamicRoute(route) ? { kind: "missing", listener, endpoint: at } : { kind: "no_handler", listener, route };
2317
- if (route.handler === "other")
2318
- return { kind: "not_get", listener, route };
2319
- if (route.params && dynamicRoute(route))
2320
- return { kind: "params", listener, route, endpoint: at };
2321
- if (route.segment)
2322
- return { kind: "segment", listener, route, setting: route.segment };
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 underSrc = [...facts.routes.map((r) => r.file), ...(facts.pages ?? []).map((p) => p.file), ...facts.listeners.map((l) => l.file)].some((f) => f.startsWith("src/"));
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
- const local = base !== "" && (endpoint === base || endpoint.startsWith(`${base}/`)) ? endpoint.slice(base.length) || "/" : endpoint;
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.13.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",