@mapled/cli 0.14.2 → 0.14.3

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
@@ -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
@@ -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));
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, (live?.listeners.length ?? 0) > 0));
719
736
  checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), 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
@@ -153,9 +153,12 @@ export declare function detectFramework(dir: string): Promise<string | undefined
153
153
  export declare function compareVersions(a: string, b: string): number;
154
154
  export type RouteKind = "revalidate" | "preview";
155
155
  export declare const ROUTE_HANDLER: Record<RouteKind, string>;
156
+ /** `cacheMark` and `fetchCache` — the revalidate route only (revalidateRouteFacts). */
156
157
  export type RouteFile = {
157
158
  file: string;
158
159
  usesAdapter: boolean;
160
+ cacheMark?: boolean;
161
+ fetchCache?: boolean;
159
162
  };
160
163
  export declare function findRoute(dir: string, kind: RouteKind): Promise<RouteFile | null>;
161
164
  /** GET the site's preview route without a token — the adapter answers 400. */
@@ -180,7 +183,9 @@ export declare function checkEnv(names: Map<string, string>, env: NodeJS.Process
180
183
  export declare function checkSecrets(facts: GitFacts): Check;
181
184
  export declare function checkSdk(pkg: PackageVersion, latest: string | undefined, name?: string): Check;
182
185
  export declare function checkCli(current: string, latest: string | undefined): Check;
183
- export declare function checkRoute(kind: RouteKind, found: RouteFile | null, framework: string | undefined): Check;
186
+ /** `listens`: a page follows Publish (<MapledLive /> or watchRelease) — then
187
+ the revalidate route's answer is what the release route trusts (#435). */
188
+ export declare function checkRoute(kind: RouteKind, found: RouteFile | null, framework: string | undefined, listens?: boolean): Check;
184
189
  /** Live updates (§18.8): whether open tabs follow Publish, and when the
185
190
  code listens, whether a route answers it. `facts` is null when the
186
191
  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";
@@ -468,7 +468,8 @@ export async function findRoute(dir, kind) {
468
468
  catch {
469
469
  continue;
470
470
  }
471
- return { file: rel, usesAdapter: text.includes(ROUTE_HANDLER[kind]) };
471
+ const usesAdapter = text.includes(ROUTE_HANDLER[kind]);
472
+ return kind === "revalidate" ? { file: rel, usesAdapter, ...revalidateRouteFacts(text) } : { file: rel, usesAdapter };
472
473
  }
473
474
  }
474
475
  return null;
@@ -639,7 +640,9 @@ export function checkCli(current, latest) {
639
640
  }
640
641
  return { key: "cli", label, status: "passed", detail: latest ? `${current} (current)` : current };
641
642
  }
642
- export function checkRoute(kind, found, framework) {
643
+ /** `listens`: a page follows Publish (<MapledLive /> or watchRelease) — then
644
+ the revalidate route's answer is what the release route trusts (#435). */
645
+ export function checkRoute(kind, found, framework, listens = false) {
643
646
  const key = `route_${kind}`;
644
647
  const label = kind === "revalidate" ? "Revalidation route" : "Preview route";
645
648
  const handler = ROUTE_HANDLER[kind];
@@ -671,6 +674,22 @@ export function checkRoute(kind, found, framework) {
671
674
  detail: `app/api/mapled/preview/route.ts is missing — mount ${handler} from "@mapled/next/server" so Preview from Mapled works.`,
672
675
  };
673
676
  }
677
+ if (kind === "revalidate" && listens && !found.usesAdapter && !found.cacheMark) {
678
+ return {
679
+ key,
680
+ label,
681
+ status: "warning",
682
+ detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from "@mapled/next/server", or answer with the header x-mapled-cache: await cacheMark().`,
683
+ };
684
+ }
685
+ if (kind === "revalidate" && listens && found.fetchCache) {
686
+ return {
687
+ key,
688
+ label,
689
+ status: "warning",
690
+ 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.`,
691
+ };
692
+ }
674
693
  if (!found.usesAdapter) {
675
694
  return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from "@mapled/next/server".` };
676
695
  }
@@ -1021,7 +1040,7 @@ export function checkManifestFile(local, remote) {
1021
1040
  if (remote === null)
1022
1041
  return { key: "manifest", label, status: "warning", detail: `${MANIFEST_FILE} isn't pushed yet — run \`mapled bindings push\`.` };
1023
1042
  const diff = compareManifests(local.manifest, remote.manifest);
1024
- if (diff.same && !diff.capabilitiesDiffer) {
1043
+ if (diff.same && !diff.capabilitiesDiffer && !diff.localesDiffer) {
1025
1044
  return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE} is pushed as manifest v${remote.version} (${manifestSummary(local.manifest)})` };
1026
1045
  }
1027
1046
  const parts = [];
@@ -1034,6 +1053,8 @@ export function checkManifestFile(local, remote) {
1034
1053
  // capabilities aren't in the integration hash, so this is the line that notices them
1035
1054
  if (diff.capabilitiesDiffer)
1036
1055
  parts.push("capabilities changed");
1056
+ if (diff.localesDiffer)
1057
+ parts.push("routes by language changed");
1037
1058
  return {
1038
1059
  key: "manifest",
1039
1060
  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;
package/dist/manifest.js CHANGED
@@ -22,6 +22,7 @@ export const BINDING_TARGETS = [
22
22
  "form_field",
23
23
  "other",
24
24
  ];
25
+ export const LOCALE_ROUTINGS = ["prefix", "prefix-except-default"];
25
26
  export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,119}$/;
26
27
  export const LIMITS = { bindings: 500, pages: 200, notes: 20 };
27
28
  /** Field types a target renders without a transform — the API's table
@@ -91,7 +92,8 @@ export function typeLabel(type) {
91
92
  }
92
93
  const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
93
94
  const isText = (v, max, min = 1) => typeof v === "string" && v.length >= min && v.length <= max;
94
- const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes", "capabilities"]);
95
+ const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes", "capabilities", "locales"]);
96
+ const LOCALE_PARAM = /^[A-Za-z0-9_-]{1,60}$/;
95
97
  export const CAPABILITY_KEYS = ["realtime", "releaseRoute"];
96
98
  const BINDING_KEYS = new Set(["key", "page", "component", "file", "collection", "field", "target", "required"]);
97
99
  /** Parses the file's text into a manifest with only the known fields,
@@ -271,6 +273,31 @@ export function parseManifest(raw, file) {
271
273
  manifest.capabilities = kept;
272
274
  }
273
275
  }
276
+ if (data.locales !== undefined) {
277
+ const loc = data.locales;
278
+ if (!isObject(loc)) {
279
+ error("locales", `"locales" must be an object — { routing, param }.`);
280
+ }
281
+ else {
282
+ let ok = true;
283
+ for (const key of Object.keys(loc)) {
284
+ if (key !== "routing" && key !== "param") {
285
+ error(`locales.${key}`, `locales.${key} isn't part of the manifest — only routing and param; Mapled refuses the push.`);
286
+ ok = false;
287
+ }
288
+ }
289
+ if (!LOCALE_ROUTINGS.includes(loc.routing)) {
290
+ error("locales.routing", `locales.routing must be "prefix" (every language under its own, /en/…) or "prefix-except-default" (the default language at the root).`);
291
+ ok = false;
292
+ }
293
+ if (loc.param !== undefined && !(typeof loc.param === "string" && LOCALE_PARAM.test(loc.param))) {
294
+ error("locales.param", `locales.param must name the dynamic segment that carries the language, e.g. "locale" for /[locale]/blog/[slug].`);
295
+ ok = false;
296
+ }
297
+ if (ok)
298
+ manifest.locales = { routing: loc.routing, ...(typeof loc.param === "string" ? { param: loc.param } : {}) };
299
+ }
300
+ }
274
301
  return { manifest, problems };
275
302
  }
276
303
  export function indexSchema(schema) {
@@ -302,6 +329,14 @@ export async function checkManifest(manifest, opts) {
302
329
  const warn = (p, message) => problems.push({ level: "warning", path: p, message });
303
330
  const index = opts.schema ? indexSchema(opts.schema) : null;
304
331
  const routes = new Set((manifest.pages ?? []).map((p) => p.route));
332
+ // routes by language (§28.4): a segment the manifest names must be on some page
333
+ if (manifest.locales?.param !== undefined) {
334
+ const marker = `[${manifest.locales.param}]`;
335
+ const all = [...routes, ...manifest.bindings.map((b) => b.page)];
336
+ if (all.length > 0 && !all.some((r) => r.split("?")[0].split("/").includes(marker))) {
337
+ warn("locales.param", `locales.param names "${marker}", but no page route has that segment — the language then goes nowhere; leave param out for Mapled to put the language before the page.`);
338
+ }
339
+ }
305
340
  for (const [i, p] of (manifest.pages ?? []).entries()) {
306
341
  if (p.file && !(await opts.fileExists(p.file))) {
307
342
  warn(`pages[${i}].file`, `pages[${i}] (${p.route}): ${p.file} doesn't exist in the repository.`);
@@ -381,8 +416,13 @@ export function orderManifest(manifest) {
381
416
  bindings: manifest.bindings.map(orderBinding),
382
417
  ...(manifest.notes && manifest.notes.length > 0 ? { notes: [...manifest.notes] } : {}),
383
418
  ...(manifest.capabilities ? { capabilities: orderCapabilities(manifest.capabilities) } : {}),
419
+ ...(manifest.locales ? { locales: { routing: manifest.locales.routing, ...(manifest.locales.param !== undefined ? { param: manifest.locales.param } : {}) } } : {}),
384
420
  };
385
421
  }
422
+ /** Whether two manifests route languages the same way (missing on both is the same). */
423
+ export function sameLocales(a, b) {
424
+ return (a?.routing ?? null) === (b?.routing ?? null) && (a?.param ?? null) === (b?.param ?? null);
425
+ }
386
426
  function orderCapabilities(c) {
387
427
  return {
388
428
  ...(c.realtime !== undefined ? { realtime: c.realtime } : {}),
@@ -434,5 +474,6 @@ export function compareManifests(local, remote) {
434
474
  onlyRemote,
435
475
  changed,
436
476
  capabilitiesDiffer: !sameCapabilities(local.capabilities, remote.capabilities),
477
+ localesDiffer: !sameLocales(local.locales, remote.locales),
437
478
  };
438
479
  }
package/dist/report.d.ts CHANGED
@@ -114,6 +114,7 @@ export type PreviewAnswer = {
114
114
  url: string;
115
115
  expiresAt?: string;
116
116
  path: string | null;
117
+ locale?: string;
117
118
  } | {
118
119
  status: "not_deployed";
119
120
  origin: string;
package/dist/scan.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type * as TS from "typescript";
2
- import { type Manifest, type ManifestBinding, type ManifestCapabilities, type ManifestPage } from "./manifest.js";
2
+ import { type Manifest, type ManifestBinding, type ManifestCapabilities, type ManifestLocales, type ManifestPage } from "./manifest.js";
3
3
  import type { Schema } from "./schema.js";
4
4
  /** `mapled scan` (§19.5, §21.4 — the standalone scanner of wave 2): reads
5
5
  the site's source and writes down what the code reads from Mapled —
@@ -39,7 +39,14 @@ export type ScanResult = {
39
39
  notes: string[];
40
40
  /** what the code says the site can do (live updates of a Next.js site); undefined keeps the manifest's */
41
41
  capabilities?: ManifestCapabilities;
42
+ /** how the routes carry the language (§28.4), when a leading dynamic segment names it; undefined keeps the manifest's */
43
+ locales?: ManifestLocales;
42
44
  };
45
+ /** Routes by language from the file conventions (§28.4): a dynamic segment
46
+ named for the language at the front of a route (`/[locale]/…`,
47
+ `/[lang]/…`) — every language then lives under its own prefix, and
48
+ the segment is the manifest's `param`. Null when no route starts so. */
49
+ export declare function detectLocales(routes: Iterable<string>): ManifestLocales | null;
43
50
  export declare const SDK_METHODS: Set<string>;
44
51
  /** The repository's TypeScript, when it has one; the CLI ships none. */
45
52
  export declare function loadTypeScript(dir: string): TypeScriptModule | null;
@@ -90,6 +97,8 @@ export type MergeResult = {
90
97
  dropped: string[];
91
98
  /** the scan changed what an existing manifest says the site can do */
92
99
  capabilitiesChanged: boolean;
100
+ /** the scan changed how an existing manifest routes languages */
101
+ localesChanged: boolean;
93
102
  };
94
103
  /** The scan's bindings replace the ones with the same key; bindings the
95
104
  scanner can't see (an agent's, hand-written) stay unless pruned. */
package/dist/scan.js CHANGED
@@ -1,7 +1,22 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { sameCapabilities, targetFor } from "./manifest.js";
4
+ import { sameCapabilities, sameLocales, targetFor } from "./manifest.js";
5
+ /** The segment names a site gives the language at the front of its routes. */
6
+ const LOCALE_SEGMENTS = new Set(["locale", "lang", "lng", "language"]);
7
+ /** Routes by language from the file conventions (§28.4): a dynamic segment
8
+ named for the language at the front of a route (`/[locale]/…`,
9
+ `/[lang]/…`) — every language then lives under its own prefix, and
10
+ the segment is the manifest's `param`. Null when no route starts so. */
11
+ export function detectLocales(routes) {
12
+ for (const route of routes) {
13
+ const first = route.split("/")[1];
14
+ const m = first ? /^\[([A-Za-z0-9_-]{1,60})\]$/.exec(first) : null;
15
+ if (m && LOCALE_SEGMENTS.has(m[1].toLowerCase()))
16
+ return { routing: "prefix", param: m[1] };
17
+ }
18
+ return null;
19
+ }
5
20
  const SKIP_DIRS = new Set([
6
21
  "node_modules",
7
22
  ".git",
@@ -1398,6 +1413,7 @@ export async function scanRepository(dir, opts) {
1398
1413
  if (!opts.ts) {
1399
1414
  notes.push("TypeScript isn't installed in this repository, so fields weren't traced (collections only) — npm install -D typescript and scan again.");
1400
1415
  }
1416
+ const locales = detectLocales(routes.pages.values());
1401
1417
  return {
1402
1418
  framework,
1403
1419
  parser: opts.ts ? "typescript" : "tokens",
@@ -1405,6 +1421,7 @@ export async function scanRepository(dir, opts) {
1405
1421
  pages: uniquePages,
1406
1422
  bindings: sortedBindings,
1407
1423
  notes,
1424
+ ...(locales ? { locales } : {}),
1408
1425
  };
1409
1426
  }
1410
1427
  /** The scan's bindings replace the ones with the same key; bindings the
@@ -1451,7 +1468,10 @@ export function mergeManifest(existing, scan, opts) {
1451
1468
  bindings: out,
1452
1469
  ...(existing?.notes && existing.notes.length > 0 ? { notes: existing.notes } : {}),
1453
1470
  ...(scan.capabilities ? { capabilities: scan.capabilities } : existing?.capabilities ? { capabilities: existing.capabilities } : {}),
1471
+ // the scan's routes by language, else the manifest's (written by hand or by the agent)
1472
+ ...(scan.locales ? { locales: scan.locales } : existing?.locales ? { locales: existing.locales } : {}),
1454
1473
  };
1455
1474
  const capabilitiesChanged = existing !== null && !sameCapabilities(existing.capabilities, manifest.capabilities);
1456
- return { manifest, added, changed, unchanged, kept, dropped, capabilitiesChanged };
1475
+ const localesChanged = existing !== null && !sameLocales(existing.locales, manifest.locales);
1476
+ return { manifest, added, changed, unchanged, kept, dropped, capabilitiesChanged, localesChanged };
1457
1477
  }
@@ -11,4 +11,5 @@ export const VANILLA_DIGESTS = {
11
11
  "0.1.2": "sha384-bhIJoFuEGouAZtHbYeNZeN0m6fqFVWpQw+YkSMJhfk/7m5bUwS/qVWZ3xczw/1du",
12
12
  "0.2.0": "sha384-HJ5XG9AhAlbKe6jPRVaef3whiVup/5WhprMwqC6RwWhpq6Pb0OsRLzbLmLJ8WH0f",
13
13
  "0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
14
+ "0.2.2": "sha384-lfhwQoPUtrbLcD2HXSLr2o1z+aUAjotB62i3LFMGxzBlK5lfCQPQPLOoWL+1DBMc",
14
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
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",