@mapled/cli 0.14.2 → 0.14.4

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
@@ -50,7 +50,7 @@ Only public values live here — the project id, where the generated types go, t
50
50
  npx @mapled/cli types generate
51
51
  ```
52
52
 
53
- Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out.
53
+ Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out. A group field is typed by an interface of its items; the project's component types become one interface each (`HeroComponent`, with `_type: "hero"` and the item's `_key`), a `components` field a list of the union of the types it allows, and `MapledComponent` the union of them all — switch on `_type` to render an item.
54
54
 
55
55
  ```ts
56
56
  import type { MapledSchema } from "./mapled-types";
@@ -130,6 +130,8 @@ Scanned 2 HTML files — 2 pages, 11 bindings.
130
130
 
131
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
+ On a site with more than one language the scan records how the routes carry it: `locales` is `{ "routing": "prefix", "param": "locale" }` when the routes start with a segment named for the language (`/[locale]/blog/[slug]`) — Mapled then builds each language's address of a record from it (the editor's routes, Preview), a translated slug being the address in that language. Write `{ "routing": "prefix-except-default" }` by hand when the default language lives at the root and the others under `/ru/…`, `/de/…` (without `param` Mapled puts the language before the page); leave `locales` out when every language shares one address. It is part of the integration hash.
134
+
133
135
  `--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
136
 
135
137
  ## Validate and push the manifest
@@ -140,7 +142,7 @@ npx @mapled/cli bindings push
140
142
  npx @mapled/cli bindings pull
141
143
  ```
142
144
 
143
- `manifest validate` checks `mapled/manifest.json` the way Mapled will: the shape and limits, duplicate keys, and — against the schema pin or the live schema — collections and fields that don't exist and targets that don't fit the field type, plus files the repository doesn't have. Problems exit 1; warnings only tell you what Mapled would grade down.
145
+ `manifest validate` checks `mapled/manifest.json` the way Mapled will: the shape and limits, duplicate keys, `capabilities` and `locales` (`routing` and `param` only, as Mapled's schema takes them — and whether a page route has the `param` segment), and — against the schema pin or the live schema — collections and fields that don't exist and targets that don't fit the field type, plus files the repository doesn't have. Problems exit 1; warnings only tell you what Mapled would grade down.
144
146
 
145
147
  `bindings push` sends the manifest to Mapled (the same call your AI agent's `push_site_manifest` makes). Mapled grades every binding — healthy, type mismatch, outdated, missing on site — and the result shows up in **Structure → Bindings**. The push also records the integration hash: one marker over the schema and these bindings, what the site is synced with from now on. `--dry-run` validates and stops. Pushing bindings changes structure, so it needs the builder plan.
146
148
 
@@ -254,9 +256,10 @@ Still hardcoded
254
256
  ```bash
255
257
  npx @mapled/cli preview
256
258
  npx @mapled/cli preview articles hello-world
259
+ npx @mapled/cli preview articles hello-world --locale ru
257
260
  ```
258
261
 
259
- Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. A plain HTML site whose pages are built with the content in them has no page for a record it wasn't built with: for such a record the command says so instead of printing a link — its page appears once the site is built with it. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page and when it expires.
262
+ Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. A plain HTML site whose pages are built with the content in them has no page for a record it wasn't built with: for such a record the command says so instead of printing a link — its page appears once the site is built with it. `--locale <code>` opens the page in that language — the address Mapled builds from the manifest's `locales` (a translated slug is the address in its language); a language the project doesn't have is refused. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page, the language when one was asked, and when it expires.
260
263
 
261
264
  ## Options
262
265
 
package/dist/args.js CHANGED
@@ -2,7 +2,7 @@ import { CliError } from "./errors.js";
2
2
  /** Minimal argument parsing: the leading words name the command;
3
3
  `--flag`, `--flag=value` and `--flag value` (for the flags that take
4
4
  one) follow. Nothing here is user content beyond the command line. */
5
- const VALUE_FLAGS = new Set(["api", "project", "out", "key"]);
5
+ const VALUE_FLAGS = new Set(["api", "project", "out", "key", "locale"]);
6
6
  export function parseArgs(argv) {
7
7
  const words = [];
8
8
  const flags = {};
@@ -38,10 +38,12 @@ export declare function formsList(ctx: Ctx, flags: Flags): Promise<void>;
38
38
  applied setup run (S-42) and what the agent reported; `--verify` runs
39
39
  the server checks again first, as "Run checks again" does. */
40
40
  export declare function integrationReport(ctx: Ctx, flags: Flags): Promise<number>;
41
- /** `mapled preview [<collection> [<slug>]]` — a Preview link from Mapled's
42
- launcher, the one the Preview button opens: the token is Mapled's, the
43
- CLI only passes it on. `--open` hands it to the browser instead of
44
- printing it (the link works once). */
41
+ /** `mapled preview [<collection> [<slug>]] [--locale <code>]` — a Preview
42
+ link from Mapled's launcher, the one the Preview button opens: the token
43
+ is Mapled's, the CLI only passes it on. `--locale` opens the page in that
44
+ language (§28.4: Mapled builds the address from the manifest's `locales`;
45
+ a language the project doesn't have is refused by Mapled). `--open` hands
46
+ it to the browser instead of printing it (the link works once). */
45
47
  export declare function preview(ctx: Ctx, flags: Flags, words: string[]): Promise<void>;
46
48
  export declare function scan(ctx: Ctx, flags: Flags): Promise<void>;
47
49
  export declare function doctor(ctx: Ctx, flags: Flags): Promise<number>;
package/dist/commands.js CHANGED
@@ -179,7 +179,7 @@ export async function schemaPull(ctx, flags) {
179
179
  ctx.out(`${SCHEMA_FILE} is up to date (schema ${pin.hash}).`);
180
180
  return;
181
181
  }
182
- const changes = diffSchema(existing.pin.collections, pin.collections);
182
+ const changes = diffSchema(existing.pin.collections, pin.collections, { before: existing.pin.componentTypes, after: pin.componentTypes });
183
183
  await writePin(found.dir, pin);
184
184
  ctx.out(`Updated ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${pin.hash}): ${summarizeChanges(changes).toLowerCase()}.`);
185
185
  ctx.out("");
@@ -199,7 +199,7 @@ export async function schemaDiff(ctx, flags) {
199
199
  throw new CliError(`No ${SCHEMA_FILE} yet. Run \`mapled schema pull\` to record the schema this site is built against.`);
200
200
  }
201
201
  const live = pinSchema(await session.get("/v1/agent/schema"), found.config.project);
202
- const changes = diffSchema(existing.pin.collections, live.collections);
202
+ const changes = diffSchema(existing.pin.collections, live.collections, { before: existing.pin.componentTypes, after: live.componentTypes });
203
203
  if (flags.json) {
204
204
  ctx.out(JSON.stringify({ from: existing.pin.hash, to: live.hash, changes }, null, 2));
205
205
  }
@@ -360,7 +360,7 @@ export async function bindingsPull(ctx, flags) {
360
360
  if (local && !flags.force) {
361
361
  const diff = compareManifests(local.manifest, stored.manifest);
362
362
  // capabilities a scan just recorded are as much the repository's as its bindings
363
- if (!diff.same || diff.capabilitiesDiffer) {
363
+ if (!diff.same || diff.capabilitiesDiffer || diff.localesDiffer) {
364
364
  const parts = [];
365
365
  if (diff.onlyLocal.length > 0)
366
366
  parts.push(`${diff.onlyLocal.length} not pushed`);
@@ -370,6 +370,8 @@ export async function bindingsPull(ctx, flags) {
370
370
  parts.push(`${diff.onlyRemote.length} only in Mapled`);
371
371
  if (diff.capabilitiesDiffer)
372
372
  parts.push("capabilities changed");
373
+ if (diff.localesDiffer)
374
+ parts.push("routes by language changed");
373
375
  throw new CliError(`${MANIFEST_FILE} differs from the pushed manifest (v${stored.version}): ${parts.join(", ")}. Pass --force to overwrite it, or push yours with \`mapled bindings push\`.`);
374
376
  }
375
377
  }
@@ -478,21 +480,28 @@ export async function integrationReport(ctx, flags) {
478
480
  ctx.out(line);
479
481
  return report.checks.some((c) => c.status === "failed") ? 1 : 0;
480
482
  }
481
- /** `mapled preview [<collection> [<slug>]]` — a Preview link from Mapled's
482
- launcher, the one the Preview button opens: the token is Mapled's, the
483
- CLI only passes it on. `--open` hands it to the browser instead of
484
- printing it (the link works once). */
483
+ /** `mapled preview [<collection> [<slug>]] [--locale <code>]` — a Preview
484
+ link from Mapled's launcher, the one the Preview button opens: the token
485
+ is Mapled's, the CLI only passes it on. `--locale` opens the page in that
486
+ language (§28.4: Mapled builds the address from the manifest's `locales`;
487
+ a language the project doesn't have is refused by Mapled). `--open` hands
488
+ it to the browser instead of printing it (the link works once). */
485
489
  export async function preview(ctx, flags, words) {
486
490
  const [collection, record, ...rest] = words;
487
491
  if (rest.length > 0) {
488
492
  throw new CliError("Name a collection and, optionally, one record's slug: `mapled preview articles hello-world`.", 2);
489
493
  }
494
+ const locale = stringFlag(flags, "locale");
495
+ if (locale !== undefined && !/^[A-Za-z0-9_-]{1,40}$/.test(locale)) {
496
+ throw new CliError("--locale takes a language code such as ru or pt-BR.", 2);
497
+ }
490
498
  const { session } = await openSession(ctx, flags);
491
499
  let answer;
492
500
  try {
493
501
  answer = await session.send("POST", "/v1/agent/preview", {
494
502
  ...(collection ? { collection } : {}),
495
503
  ...(record ? { record } : {}),
504
+ ...(locale ? { locale } : {}),
496
505
  });
497
506
  }
498
507
  catch (err) {
@@ -516,11 +525,13 @@ export async function preview(ctx, flags, words) {
516
525
  if (typeof answer.url !== "string" || !/^https?:\/\/[!-~]+$/.test(answer.url)) {
517
526
  throw new CliError("Mapled answered with a link this CLI can't print. Update @mapled/cli and try again.");
518
527
  }
528
+ // the language Mapled opened the page in (the answer names it only when one was asked)
529
+ const inLanguage = typeof answer.locale === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(answer.locale) ? answer.locale : null;
519
530
  if (flags.json) {
520
- ctx.out(JSON.stringify({ url: answer.url, path: answer.path, expiresAt: answer.expiresAt ?? null }, null, 2));
531
+ ctx.out(JSON.stringify({ url: answer.url, path: answer.path, expiresAt: answer.expiresAt ?? null, ...(inLanguage ? { locale: inLanguage } : {}) }, null, 2));
521
532
  return;
522
533
  }
523
- const where = answer.path ? clean(answer.path, 500) : "the site's home page";
534
+ const where = (answer.path ? clean(answer.path, 500) : "the site's home page") + (inLanguage ? ` in ${inLanguage}` : "");
524
535
  const lifetime = linkLifetime(answer.expiresAt);
525
536
  const project = clean(session.conn.projectName, 120);
526
537
  if (flags.open && (await ctx.openBrowser(answer.url))) {
@@ -626,6 +637,8 @@ export async function scan(ctx, flags) {
626
637
  parts.push(`${merged.unchanged.length} unchanged`);
627
638
  if (merged.capabilitiesChanged)
628
639
  parts.push("capabilities updated");
640
+ if (merged.localesChanged)
641
+ parts.push("routes by language updated");
629
642
  ctx.out(`Wrote ${MANIFEST_FILE} — ${manifestSummary(merged.manifest)}${parts.length > 0 && existing ? ` (${parts.join(", ")})` : ""}.${keptNote}${droppedNote}`);
630
643
  ctx.out("Next: `mapled bindings push`.");
631
644
  }
@@ -641,10 +654,12 @@ export async function scan(ctx, flags) {
641
654
  parts.push(`${merged.unchanged.length} unchanged`);
642
655
  if (merged.capabilitiesChanged)
643
656
  parts.push("capabilities changed");
657
+ if (merged.localesChanged)
658
+ parts.push("routes by language changed");
644
659
  if (merged.kept.length > 0)
645
660
  parts.push(`${merged.kept.length} not found in the code (${merged.kept.slice(0, 5).join(", ")}${merged.kept.length > 5 ? ", …" : ""})`);
646
661
  ctx.out(`Compared with ${MANIFEST_FILE}: ${parts.join(", ")}.`);
647
- if (merged.added.length > 0 || merged.changed.length > 0 || merged.capabilitiesChanged) {
662
+ if (merged.added.length > 0 || merged.changed.length > 0 || merged.capabilitiesChanged || merged.localesChanged) {
648
663
  ctx.out(`Run \`mapled scan --write\` to update ${MANIFEST_FILE}, then \`mapled bindings push\`.`);
649
664
  }
650
665
  else {
@@ -715,10 +730,12 @@ export async function doctor(ctx, flags) {
715
730
  // plain HTML installs nothing: the runtime comes with a script tag
716
731
  checks.push(framework === "plain-html" ? dr.checkScriptTag(await dr.findScriptTag(dir, status?.site?.url), status?.sdk[sdk]) : dr.checkSdk(await dr.installedVersion(dir, sdk), status?.sdk[sdk], sdk));
717
732
  checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
718
- checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework));
719
- checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
733
+ // the live facts first: the revalidate route's answer matters once a page listens (#435)
734
+ const live = !framework || framework === "nextjs" ? await findLive(dir) : null;
735
+ checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate", framework), framework, (live?.listeners.length ?? 0) > 0));
736
+ checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview", framework), framework));
720
737
  // the code is read only where the check applies: a Next.js site (or one doctor can't place)
721
- checks.push(dr.checkLive(!framework || framework === "nextjs" ? await findLive(dir) : null, framework));
738
+ checks.push(dr.checkLive(live, framework));
722
739
  if (status) {
723
740
  checks.push(dr.checkReads(status.delivery.lastReadAt));
724
741
  checks.push(dr.checkWebhook(status.webhook, { local: browser, remote: status.site }));
package/dist/doctor.d.ts CHANGED
@@ -101,6 +101,12 @@ export declare function installedVersion(dir: string, pkg: string): Promise<Pack
101
101
  export declare const rendersInBrowser: (framework: string | null | undefined) => boolean;
102
102
  /** The first-party package a site of this framework reads through. */
103
103
  export declare const sdkOf: (framework: string | null | undefined) => string;
104
+ /** The frameworks whose revalidate and preview routes doctor reads: Next.js
105
+ (`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
106
+ (`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
107
+ for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
108
+ or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
109
+ export declare const checksRoutesOf: (framework: string | null | undefined) => boolean;
104
110
  /** How a plain HTML site loads "@mapled/vanilla": a script tag in its
105
111
  pages — from a CDN, where the version is in the address, or a copy of
106
112
  the single file next to the pages, where it is in the file's first
@@ -152,12 +158,29 @@ export declare function detectFramework(dir: string): Promise<string | undefined
152
158
  /** -1, 0 or 1 comparing dotted numeric versions; anything else compares by number. */
153
159
  export declare function compareVersions(a: string, b: string): number;
154
160
  export type RouteKind = "revalidate" | "preview";
161
+ /** The Nuxt module mounts every route itself: a site whose `nuxt.config`
162
+ lists "@mapled/nuxt/module" has them without a file. What `findRoute`
163
+ answers for such a site, when no route file of its own is found. */
164
+ export declare const NUXT_CONFIG_FILES: string[];
165
+ export declare const NUXT_MODULE = "@mapled/nuxt/module";
166
+ export declare function moduleRoute(dir: string): Promise<RouteFile | null>;
167
+ /** Whether a nuxt.config's text lists the module, routes on, in its
168
+ `modules` array: as the string "@mapled/nuxt/module" or as the
169
+ identifier a default import of it binds, bare or as the first element
170
+ of a tuple whose options don't say `routes: false`. Comments don't
171
+ count; neither does an import that never reaches `modules`.
172
+ Conservative: a config that can't be read this way claims nothing,
173
+ and doctor asks for the route files. */
174
+ export declare function mountsModuleRoutes(text: string): boolean;
155
175
  export declare const ROUTE_HANDLER: Record<RouteKind, string>;
176
+ /** `cacheMark` and `fetchCache` — the revalidate route only (revalidateRouteFacts). */
156
177
  export type RouteFile = {
157
178
  file: string;
158
179
  usesAdapter: boolean;
180
+ cacheMark?: boolean;
181
+ fetchCache?: boolean;
159
182
  };
160
- export declare function findRoute(dir: string, kind: RouteKind): Promise<RouteFile | null>;
183
+ export declare function findRoute(dir: string, kind: RouteKind, framework?: string | null): Promise<RouteFile | null>;
161
184
  /** GET the site's preview route without a token — the adapter answers 400. */
162
185
  export declare function probePreview(origin: string, previewPath: string, f: Fetch): Promise<{
163
186
  status: number | null;
@@ -180,7 +203,9 @@ export declare function checkEnv(names: Map<string, string>, env: NodeJS.Process
180
203
  export declare function checkSecrets(facts: GitFacts): Check;
181
204
  export declare function checkSdk(pkg: PackageVersion, latest: string | undefined, name?: string): Check;
182
205
  export declare function checkCli(current: string, latest: string | undefined): Check;
183
- export declare function checkRoute(kind: RouteKind, found: RouteFile | null, framework: string | undefined): Check;
206
+ /** `listens`: a page follows Publish (<MapledLive /> or watchRelease) — then
207
+ the revalidate route's answer is what the release route trusts (#435). */
208
+ export declare function checkRoute(kind: RouteKind, found: RouteFile | null, framework: string | undefined, listens?: boolean): Check;
184
209
  /** Live updates (§18.8): whether open tabs follow Publish, and when the
185
210
  code listens, whether a route answers it. `facts` is null when the
186
211
  code wasn't read (a stack this check doesn't cover). */
package/dist/doctor.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { parseDocument } from "./html5.js";
7
7
  import { integrationHash } from "./integrationHash.js";
8
- import { BASE_PATH_SINCE, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, routeFileFor, runsIn } from "./live.js";
8
+ import { BASE_PATH_SINCE, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, revalidateRouteFacts, routeFileFor, runsIn } from "./live.js";
9
9
  import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
10
10
  import { SCHEMA_FILE } from "./pin.js";
11
11
  import { htmlPage } from "./scanHtml.js";
@@ -113,7 +113,19 @@ export async function installedVersion(dir, pkg) {
113
113
  keys are the ones Mapled knows by (lib/siteStack.ts of the API). */
114
114
  export const rendersInBrowser = (framework) => framework === "react-spa" || framework === "plain-html";
115
115
  /** The first-party package a site of this framework reads through. */
116
- export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : "@mapled/client";
116
+ export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : framework === "astro" ? "@mapled/astro" : framework === "nuxt" ? "@mapled/nuxt" : "@mapled/client";
117
+ /** The frameworks whose revalidate and preview routes doctor reads: Next.js
118
+ (`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
119
+ (`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
120
+ for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
121
+ or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
122
+ export const checksRoutesOf = (framework) => !framework || framework === "nextjs" || framework === "astro" || framework === "nuxt";
123
+ /** where the adapter's handlers come from, and where the route file goes, by framework */
124
+ const adapterOf = (framework, kind) => framework === "astro"
125
+ ? { server: '"@mapled/astro/server"', file: `src/pages/api/mapled/${kind}.ts` }
126
+ : framework === "nuxt"
127
+ ? { server: '"@mapled/nuxt/server"', file: `server/api/mapled/${kind}.${kind === "revalidate" ? "post" : "get"}.ts` }
128
+ : { server: '"@mapled/next/server"', file: `app/api/mapled/${kind}/route.ts` };
117
129
  const SKIPPED_DIRS = new Set(["node_modules", ".git", ".next", ".vercel", ".netlify", "vendor"]);
118
130
  export async function findScriptTag(dir, site) {
119
131
  // pages first, breadth first, and not forever: a site's pages sit near its root
@@ -444,20 +456,116 @@ const ROUTE_FILES = {
444
456
  "src/app/api/mapled/revalidate/route",
445
457
  "pages/api/mapled/revalidate",
446
458
  "src/pages/api/mapled/revalidate",
459
+ // Nuxt: a Nitro route, with or without the method in its name
460
+ "server/api/mapled/revalidate.post",
461
+ "server/api/mapled/revalidate",
447
462
  ],
448
463
  preview: [
449
464
  "app/api/mapled/preview/route",
450
465
  "src/app/api/mapled/preview/route",
451
466
  "pages/api/mapled/preview",
452
467
  "src/pages/api/mapled/preview",
468
+ "server/api/mapled/preview.get",
469
+ "server/api/mapled/preview",
453
470
  ],
454
471
  };
472
+ /** The Nuxt module mounts every route itself: a site whose `nuxt.config`
473
+ lists "@mapled/nuxt/module" has them without a file. What `findRoute`
474
+ answers for such a site, when no route file of its own is found. */
475
+ export const NUXT_CONFIG_FILES = ["nuxt.config.ts", "nuxt.config.mts", "nuxt.config.js", "nuxt.config.mjs"];
476
+ export const NUXT_MODULE = "@mapled/nuxt/module";
477
+ export async function moduleRoute(dir) {
478
+ for (const file of NUXT_CONFIG_FILES) {
479
+ let text;
480
+ try {
481
+ text = await readFile(path.join(dir, file), "utf8");
482
+ }
483
+ catch {
484
+ continue;
485
+ }
486
+ return mountsModuleRoutes(text) ? { file: `${file} (${NUXT_MODULE} mounts it)`, usesAdapter: true, cacheMark: true } : null;
487
+ }
488
+ return null;
489
+ }
490
+ /** Whether a nuxt.config's text lists the module, routes on, in its
491
+ `modules` array: as the string "@mapled/nuxt/module" or as the
492
+ identifier a default import of it binds, bare or as the first element
493
+ of a tuple whose options don't say `routes: false`. Comments don't
494
+ count; neither does an import that never reaches `modules`.
495
+ Conservative: a config that can't be read this way claims nothing,
496
+ and doctor asks for the route files. */
497
+ export function mountsModuleRoutes(text) {
498
+ // comments go: block comments, and line comments that aren't the `//` of a URL
499
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
500
+ // the names a default import of the module binds
501
+ const names = [...code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s*from\s*["'`]@mapled\/nuxt\/module["'`]/g)].map((m) => m[1]);
502
+ const open = code.search(/\bmodules\s*:\s*\[/);
503
+ if (open < 0)
504
+ return false;
505
+ const array = balanced(code, code.indexOf("[", open));
506
+ if (array === null)
507
+ return false;
508
+ // each element of the array, at depth one
509
+ for (const element of elementsOf(array)) {
510
+ const asString = /^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(element);
511
+ const asName = names.some((n) => new RegExp(`^\\s*${n.replace(/\$/g, "\\$")}\\s*$`).test(element));
512
+ if (asString || asName)
513
+ return true;
514
+ const tuple = element.trim().startsWith("[") ? balanced(element, element.indexOf("[")) : null;
515
+ if (tuple === null)
516
+ continue;
517
+ const [first, ...options] = elementsOf(tuple);
518
+ const firstIs = first !== undefined && (/^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(first) || names.some((n) => first.trim() === n));
519
+ if (!firstIs)
520
+ continue;
521
+ return !/\broutes\s*:\s*false\b/.test(options.join(","));
522
+ }
523
+ return false;
524
+ }
525
+ /** the text inside the bracket at `at` and its match, or null when unbalanced */
526
+ function balanced(code, at) {
527
+ const pairs = { "[": "]", "{": "}", "(": ")" };
528
+ const stack = [];
529
+ for (let i = at; i < code.length; i++) {
530
+ const c = code[i];
531
+ if (c in pairs)
532
+ stack.push(pairs[c]);
533
+ else if (c === "]" || c === "}" || c === ")") {
534
+ if (stack.pop() !== c)
535
+ return null;
536
+ if (stack.length === 0)
537
+ return code.slice(at + 1, i);
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ /** the comma-separated elements of a bracket's inside, at its own depth */
543
+ function elementsOf(inside) {
544
+ const out = [];
545
+ let depth = 0;
546
+ let from = 0;
547
+ for (let i = 0; i < inside.length; i++) {
548
+ const c = inside[i];
549
+ if (c === "[" || c === "{" || c === "(")
550
+ depth += 1;
551
+ else if (c === "]" || c === "}" || c === ")")
552
+ depth -= 1;
553
+ else if (c === "," && depth === 0) {
554
+ out.push(inside.slice(from, i));
555
+ from = i + 1;
556
+ }
557
+ }
558
+ const last = inside.slice(from);
559
+ if (last.trim())
560
+ out.push(last);
561
+ return out;
562
+ }
455
563
  const ROUTE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".jsx"];
456
564
  export const ROUTE_HANDLER = {
457
565
  revalidate: "createRevalidateHandler",
458
566
  preview: "createPreviewHandler",
459
567
  };
460
- export async function findRoute(dir, kind) {
568
+ export async function findRoute(dir, kind, framework) {
461
569
  for (const base of ROUTE_FILES[kind]) {
462
570
  for (const ext of ROUTE_EXTENSIONS) {
463
571
  const rel = base + ext;
@@ -468,10 +576,11 @@ export async function findRoute(dir, kind) {
468
576
  catch {
469
577
  continue;
470
578
  }
471
- return { file: rel, usesAdapter: text.includes(ROUTE_HANDLER[kind]) };
579
+ const usesAdapter = text.includes(ROUTE_HANDLER[kind]);
580
+ return kind === "revalidate" ? { file: rel, usesAdapter, ...revalidateRouteFacts(text) } : { file: rel, usesAdapter };
472
581
  }
473
582
  }
474
- return null;
583
+ return framework === "nuxt" ? moduleRoute(dir) : null;
475
584
  }
476
585
  /** GET the site's preview route without a token — the adapter answers 400. */
477
586
  export async function probePreview(origin, previewPath, f) {
@@ -639,7 +748,9 @@ export function checkCli(current, latest) {
639
748
  }
640
749
  return { key: "cli", label, status: "passed", detail: latest ? `${current} (current)` : current };
641
750
  }
642
- export function checkRoute(kind, found, framework) {
751
+ /** `listens`: a page follows Publish (<MapledLive /> or watchRelease) — then
752
+ the revalidate route's answer is what the release route trusts (#435). */
753
+ export function checkRoute(kind, found, framework, listens = false) {
643
754
  const key = `route_${kind}`;
644
755
  const label = kind === "revalidate" ? "Revalidation route" : "Preview route";
645
756
  const handler = ROUTE_HANDLER[kind];
@@ -653,26 +764,43 @@ export function checkRoute(kind, found, framework) {
653
764
  : "Not needed: the client trades Mapled's preview link on the page itself.",
654
765
  };
655
766
  }
656
- if (framework && framework !== "nextjs") {
767
+ if (!checksRoutesOf(framework)) {
657
768
  return { key, label, status: "skipped", detail: `No ${kind} route check for ${framework} sites yet.` };
658
769
  }
770
+ const adapter = adapterOf(framework, kind);
659
771
  if (!found) {
660
772
  return kind === "revalidate"
661
773
  ? {
662
774
  key,
663
775
  label,
664
776
  status: "failed",
665
- detail: `app/api/mapled/revalidate/route.ts is missing — mount ${handler} from "@mapled/next/server".`,
777
+ detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server}.`,
666
778
  }
667
779
  : {
668
780
  key,
669
781
  label,
670
782
  status: "warning",
671
- detail: `app/api/mapled/preview/route.ts is missing — mount ${handler} from "@mapled/next/server" so Preview from Mapled works.`,
783
+ detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server} so Preview from Mapled works.`,
672
784
  };
673
785
  }
786
+ if (kind === "revalidate" && listens && !found.usesAdapter && !found.cacheMark) {
787
+ return {
788
+ key,
789
+ label,
790
+ status: "warning",
791
+ detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from ${adapter.server}, or answer with the header x-mapled-cache: await cacheMark().`,
792
+ };
793
+ }
794
+ if (kind === "revalidate" && listens && found.fetchCache) {
795
+ return {
796
+ key,
797
+ label,
798
+ status: "warning",
799
+ detail: `${found.file} may set fetchCache to something other than "auto" — with "force-no-store" every answer names a new cache mark, so the release route may name no release and open tabs don't follow Publish. Leave fetchCache out of the route.`,
800
+ };
801
+ }
674
802
  if (!found.usesAdapter) {
675
- return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from "@mapled/next/server".` };
803
+ return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from ${adapter.server}.` };
676
804
  }
677
805
  return { key, label, status: "passed", detail: found.file };
678
806
  }
@@ -1021,7 +1149,7 @@ export function checkManifestFile(local, remote) {
1021
1149
  if (remote === null)
1022
1150
  return { key: "manifest", label, status: "warning", detail: `${MANIFEST_FILE} isn't pushed yet — run \`mapled bindings push\`.` };
1023
1151
  const diff = compareManifests(local.manifest, remote.manifest);
1024
- if (diff.same && !diff.capabilitiesDiffer) {
1152
+ if (diff.same && !diff.capabilitiesDiffer && !diff.localesDiffer) {
1025
1153
  return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE} is pushed as manifest v${remote.version} (${manifestSummary(local.manifest)})` };
1026
1154
  }
1027
1155
  const parts = [];
@@ -1034,6 +1162,8 @@ export function checkManifestFile(local, remote) {
1034
1162
  // capabilities aren't in the integration hash, so this is the line that notices them
1035
1163
  if (diff.capabilitiesDiffer)
1036
1164
  parts.push("capabilities changed");
1165
+ if (diff.localesDiffer)
1166
+ parts.push("routes by language changed");
1037
1167
  return {
1038
1168
  key: "manifest",
1039
1169
  label,
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ Usage
28
28
  mapled forms list [--json] List the project's forms and where the site posts to
29
29
  mapled doctor [--json] Check the integration end to end
30
30
  mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
31
- mapled preview [<collection> [<slug>]] [--open] Print a one-time Preview link that shows the drafts on the site
31
+ mapled preview [<collection> [<slug>]] [--locale <code>] [--open] Print a one-time Preview link that shows the drafts on the site
32
32
 
33
33
  Options
34
34
  --version, -v Print the version
@@ -1,7 +1,7 @@
1
1
  /** The integration hash (§21.4): one marker over everything the site's
2
2
  integration was written against — the schema (its hash, the twelve hex
3
3
  characters in mapled-types.ts and mapled/schema.json) and the site
4
- manifest (bindings, pages, framework, notes). Mapled records it on
4
+ manifest (bindings, pages, framework, notes, routes by language). Mapled records it on
5
5
  every manifest push and computes it again on read; the repository
6
6
  computes the same value from mapled/schema.json and
7
7
  mapled/manifest.json, and `mapled doctor` compares the two in one
@@ -31,5 +31,10 @@ export type HashedManifest = {
31
31
  }[] | null;
32
32
  bindings: HashedBinding[];
33
33
  notes?: string[] | null;
34
+ /** routes by language (§28.4); a manifest without it hashes as before */
35
+ locales?: {
36
+ routing: string;
37
+ param?: string | null;
38
+ } | null;
34
39
  };
35
40
  export declare function integrationHash(schemaHash: string, manifest: HashedManifest): string;
@@ -21,6 +21,8 @@ export function integrationHash(schemaHash, manifest) {
21
21
  }))
22
22
  .sort((a, b) => byText(a.key, b.key)),
23
23
  notes: manifest.notes ?? [],
24
+ // only when declared: the hashes of every manifest without it stay what they were
25
+ ...(manifest.locales ? { locales: { routing: manifest.locales.routing, param: manifest.locales.param || null } } : {}),
24
26
  };
25
27
  return createHash("sha256").update(JSON.stringify(projection)).digest("hex").slice(0, 12);
26
28
  }
package/dist/live.d.ts CHANGED
@@ -208,6 +208,16 @@ export declare function liveInFile(file: string, text: string, router?: "app" |
208
208
  segment?: SegmentSetting;
209
209
  runtime?: string | null;
210
210
  };
211
+ /** What the revalidate (webhook) route does that live updates depend on
212
+ (§18.8, #435): whether it answers with the cache's mark (`cacheMark()`,
213
+ which createRevalidateHandler does for you), and whether it may export
214
+ `fetchCache` as anything but "auto" — "force-no-store" makes every
215
+ answer name a new mark, so the release route may name no release.
216
+ Read the way the release route's segment config is read. */
217
+ export declare function revalidateRouteFacts(text: string): {
218
+ cacheMark: boolean;
219
+ fetchCache: boolean;
220
+ };
211
221
  /** The route segment settings that change how Next serves the release
212
222
  route or reads what the handler reads, in the order doctor names them:
213
223
  `dynamic` can make it a static copy built without the tab's request,
package/dist/live.js CHANGED
@@ -945,6 +945,19 @@ export function liveInFile(file, text, router = "app") {
945
945
  const segment = sets("dynamic", SEGMENT_KEEPS.dynamic) ? "dynamic" : forced() ? "force-dynamic" : sets("fetchCache", SEGMENT_KEEPS.fetchCache) ? "fetchCache" : undefined;
946
946
  return { listeners, handler: releaseHandlerOf(tokens, APP_HANDLER(names), "GET", read), params, ...(segment ? { segment } : {}) };
947
947
  }
948
+ /** What the revalidate (webhook) route does that live updates depend on
949
+ (§18.8, #435): whether it answers with the cache's mark (`cacheMark()`,
950
+ which createRevalidateHandler does for you), and whether it may export
951
+ `fetchCache` as anything but "auto" — "force-no-store" makes every
952
+ answer name a new mark, so the release route may name no release.
953
+ Read the way the release route's segment config is read. */
954
+ export function revalidateRouteFacts(text) {
955
+ const tokens = tokenize(text);
956
+ const cacheMark = tokens.some((t, k) => t.kind === "ident" && t.value === "cacheMark" && isPunct(tokens[k + 1], "("));
957
+ const listing = tokens.some((u, k) => isWord(u, "exports") || (isWord(u, "export") && isPunct(tokens[k + 1], "*")));
958
+ const fetchCache = (listing || tokens.some((u) => isName(u, "fetchCache"))) && setsExport(tokens, readingOf(tokens).b.after, "fetchCache", SEGMENT_KEEPS.fetchCache);
959
+ return { cacheMark, fetchCache };
960
+ }
948
961
  /** Whether every release handler the file makes keeps a window of its own
949
962
  for the pointer's read (keepsWindow), with the factory only ever called
950
963
  where it's named: `const make = createReleaseHandler`, a namespace
@@ -30,6 +30,18 @@ export type ManifestCapabilities = {
30
30
  realtime?: boolean;
31
31
  releaseRoute?: string;
32
32
  };
33
+ /** How the site's routes carry the language (§28.4): `prefix` — every
34
+ language under its own (`/en/…`, `/ru/…`), `prefix-except-default` —
35
+ the default language at the root, the others under theirs; `param`
36
+ names the dynamic segment that holds it (`/[locale]/blog/[slug]`),
37
+ without one Mapled puts the language before the page. Part of the
38
+ integration hash: Mapled builds each language's address of a record
39
+ from it (the editor's routes, Preview). */
40
+ export type ManifestLocales = {
41
+ routing: "prefix" | "prefix-except-default";
42
+ param?: string;
43
+ };
44
+ export declare const LOCALE_ROUTINGS: readonly ["prefix", "prefix-except-default"];
33
45
  export type Manifest = {
34
46
  framework?: string;
35
47
  integrationMode?: string;
@@ -37,6 +49,7 @@ export type Manifest = {
37
49
  bindings: ManifestBinding[];
38
50
  notes?: string[];
39
51
  capabilities?: ManifestCapabilities;
52
+ locales?: ManifestLocales;
40
53
  };
41
54
  export declare const KEY_PATTERN: RegExp;
42
55
  export declare const LIMITS: {
@@ -93,6 +106,8 @@ export declare function readManifestFile(dir: string): Promise<{
93
106
  the order they came in. */
94
107
  export declare function writeManifestFile(dir: string, manifest: Manifest): Promise<string>;
95
108
  export declare function orderManifest(manifest: Manifest): Manifest;
109
+ /** Whether two manifests route languages the same way (missing on both is the same). */
110
+ export declare function sameLocales(a: ManifestLocales | undefined, b: ManifestLocales | undefined): boolean;
96
111
  /** Whether two manifests declare the same capabilities (missing and empty are the same). */
97
112
  export declare function sameCapabilities(a: ManifestCapabilities | undefined, b: ManifestCapabilities | undefined): boolean;
98
113
  export declare function orderBinding(b: ManifestBinding): ManifestBinding;
@@ -100,12 +115,14 @@ export declare function plural(n: number, word: string, pluralWord?: string): st
100
115
  export declare function manifestSummary(manifest: Manifest): string;
101
116
  /** Bindings that mean the same thing to Mapled (what a push stores). */
102
117
  export declare function sameBinding(a: ManifestBinding, b: ManifestBinding): boolean;
103
- /** `same` is about the bindings; `capabilitiesDiffer` says whether the capabilities do. */
118
+ /** `same` is about the bindings; `capabilitiesDiffer` says whether the
119
+ capabilities do, `localesDiffer` whether the routes by language do. */
104
120
  export type ManifestComparison = {
105
121
  same: boolean;
106
122
  onlyLocal: string[];
107
123
  onlyRemote: string[];
108
124
  changed: string[];
109
125
  capabilitiesDiffer: boolean;
126
+ localesDiffer: boolean;
110
127
  };
111
128
  export declare function compareManifests(local: Manifest, remote: Manifest): ManifestComparison;