@ultimat3/cli 27.8.1 → 27.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.8.1",
3
+ "version": "27.10.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.8.1",
43
- "@ultimat3/admin": "27.8.1",
44
- "@ultimat3/ai": "27.8.1",
45
- "@ultimat3/auth": "27.8.1",
46
- "@ultimat3/cache": "27.8.1",
47
- "@ultimat3/core": "27.8.1",
48
- "@ultimat3/db": "27.8.1",
49
- "@ultimat3/entity": "27.8.1",
50
- "@ultimat3/flags": "27.8.1",
51
- "@ultimat3/http": "27.8.1",
52
- "@ultimat3/i18n": "27.8.1",
53
- "@ultimat3/jobs": "27.8.1",
54
- "@ultimat3/mail": "27.8.1",
55
- "@ultimat3/manifest": "27.8.1",
56
- "@ultimat3/mcp": "27.8.1",
57
- "@ultimat3/money": "27.8.1",
58
- "@ultimat3/notify": "27.8.1",
59
- "@ultimat3/policy": "27.8.1",
60
- "@ultimat3/pwa": "27.8.1",
61
- "@ultimat3/query": "27.8.1",
62
- "@ultimat3/realtime": "27.8.1",
63
- "@ultimat3/render": "27.8.1",
64
- "@ultimat3/schema": "27.8.1",
65
- "@ultimat3/seo": "27.8.1",
66
- "@ultimat3/storage": "27.8.1",
67
- "@ultimat3/testing": "27.8.1",
68
- "@ultimat3/time": "27.8.1",
42
+ "@ultimat3/action": "27.10.0",
43
+ "@ultimat3/admin": "27.10.0",
44
+ "@ultimat3/ai": "27.10.0",
45
+ "@ultimat3/auth": "27.10.0",
46
+ "@ultimat3/cache": "27.10.0",
47
+ "@ultimat3/core": "27.10.0",
48
+ "@ultimat3/db": "27.10.0",
49
+ "@ultimat3/entity": "27.10.0",
50
+ "@ultimat3/flags": "27.10.0",
51
+ "@ultimat3/http": "27.10.0",
52
+ "@ultimat3/i18n": "27.10.0",
53
+ "@ultimat3/jobs": "27.10.0",
54
+ "@ultimat3/mail": "27.10.0",
55
+ "@ultimat3/manifest": "27.10.0",
56
+ "@ultimat3/mcp": "27.10.0",
57
+ "@ultimat3/money": "27.10.0",
58
+ "@ultimat3/notify": "27.10.0",
59
+ "@ultimat3/policy": "27.10.0",
60
+ "@ultimat3/pwa": "27.10.0",
61
+ "@ultimat3/query": "27.10.0",
62
+ "@ultimat3/realtime": "27.10.0",
63
+ "@ultimat3/render": "27.10.0",
64
+ "@ultimat3/schema": "27.10.0",
65
+ "@ultimat3/seo": "27.10.0",
66
+ "@ultimat3/storage": "27.10.0",
67
+ "@ultimat3/testing": "27.10.0",
68
+ "@ultimat3/time": "27.10.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -19,6 +19,7 @@ import { followUpEdits } from './generate-follow-ups';
19
19
  import type { Generator } from './generate-kinds';
20
20
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
21
21
  import { refusePluralTable } from './generate-plural';
22
+ import { followReexports } from './generate-reexports';
22
23
  import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
23
24
  import { declaresOnlyOtherEntities } from './generate-sibling';
24
25
  import { containedPath, planWrites, writeFiles } from './generate-write';
@@ -90,6 +91,12 @@ export const generateCommand: CliCommand = {
90
91
  // A named slice that is not there is refused, never invented (X_FEATURE_UNKNOWN).
91
92
  assertFeatureExists(root, kind, featureFlag, slice);
92
93
  const sliceErrors = await readSliceErrors(root, kind, slice);
94
+ // And its `policy.ts`, for the same two kinds and the same reason: the action imports from it.
95
+ const slicePolicy = await followReexports(
96
+ root,
97
+ slice,
98
+ await readSliceErrors(root, kind, slice, 'policy.ts'),
99
+ );
93
100
  // Same reason again: whether `job`/`task` may assume the tenant-scoped shape is a fact about
94
101
  // THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
95
102
  const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
@@ -105,6 +112,7 @@ export const generateCommand: CliCommand = {
105
112
  name: planned,
106
113
  ...(featureFlag === undefined ? {} : { feature: featureFlag }),
107
114
  ...(sliceErrors === undefined ? {} : { sliceErrors }),
115
+ ...(slicePolicy === undefined ? {} : { slicePolicy }),
108
116
  ...(sliceEntity === undefined ? {} : { sliceEntity }),
109
117
  ...(sliceRepo === undefined ? {} : { sliceRepo }),
110
118
  ...(at === undefined ? {} : { at }),
@@ -321,9 +329,10 @@ async function readSliceErrors(
321
329
  root: string,
322
330
  kind: Generator,
323
331
  slice: string,
332
+ name: 'errors.ts' | 'policy.ts' = 'errors.ts',
324
333
  ): Promise<string | undefined> {
325
334
  if (kind !== 'action' && kind !== 'mutator') return undefined;
326
- const file = containedPath(root, `${slice}/errors.ts`);
335
+ const file = containedPath(root, `${slice}/${name}`);
327
336
  return existsSync(file) ? await Bun.file(file).text() : undefined;
328
337
  }
329
338
 
@@ -233,6 +233,7 @@ export const CLI_OWNED_ERROR_CODES = [
233
233
  'X_ISLAND_MODULE_DUPLICATED',
234
234
  'X_BACKFILL_SLICE_ENTITY',
235
235
  'X_LAYOUT_SIBLING_MODULE',
236
+ 'X_SLICE_POLICY_INCOMPLETE',
236
237
  ] as const;
237
238
 
238
239
  /**
@@ -422,6 +423,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
422
423
  X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
423
424
  X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
424
425
  X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
426
+ X_SLICE_POLICY_INCOMPLETE: 'a generator needs an export the slice policy.ts does not declare',
425
427
  };
426
428
 
427
429
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -70,6 +70,8 @@ export interface GenerateOptions {
70
70
  readonly sliceEntity?: string;
71
71
  /** The same kinds: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
72
72
  readonly sliceRepo?: string;
73
+ /** `action` and `mutator`: the slice's `policy.ts` as it stands on disk, absent when it has none. */
74
+ readonly slicePolicy?: string;
73
75
  /**
74
76
  * The app's own db package, `@<app>/db` — what every generated `repo.ts` imports the typed handle
75
77
  * from. Supplied by `run` (`resolveDbModule`) for `catalogModule`'s reason: the name lives in a
@@ -152,6 +154,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
152
154
  actionFiles(options.name, {
153
155
  ...target,
154
156
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
157
+ ...(options.slicePolicy === undefined ? {} : { slicePolicy: options.slicePolicy }),
155
158
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
156
159
  ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
157
160
  }),
@@ -162,6 +165,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
162
165
  ...target,
163
166
  mutator: true,
164
167
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
168
+ ...(options.slicePolicy === undefined ? {} : { slicePolicy: options.slicePolicy }),
165
169
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
166
170
  ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
167
171
  }),
@@ -0,0 +1,40 @@
1
+ // Single responsibility: reading a slice module's relative `export * from './x'` targets off the
2
+ // app's disk, one level deep, so a barrel is judged by what it really exports.
3
+
4
+ // why: Bun has no synchronous "is this a FILE": a directory passes `existsSync`, and reading one
5
+ // as a file is an uncoded EISDIR.
6
+ import { statSync } from 'node:fs';
7
+ import { containedPath } from './generate-write';
8
+ import { inlineReexports } from './templates/slice-foundation';
9
+
10
+ /**
11
+ * A slice module with its relative `export * from './x'` lines read in, one level deep — so a
12
+ * `policy.ts` that is a barrel is judged by what it really exports (`inlineReexports`).
13
+ */
14
+ export async function followReexports(
15
+ root: string,
16
+ slice: string,
17
+ source: string | undefined,
18
+ ): Promise<string | undefined> {
19
+ if (source === undefined) return undefined;
20
+ const found = new Map<string, string>();
21
+ for (const match of source.matchAll(/\bexport\s*\*\s*from\s*['"](\.[^'"]+)['"]/g)) {
22
+ const specifier = match[1] ?? '';
23
+ for (const candidate of [`${specifier}.ts`, `${specifier}/index.ts`, specifier]) {
24
+ const file = containedPath(root, `${slice}/${candidate}`);
25
+ // A FILE, or it is not followed: `./rules` naming a directory with no `index.ts` is left as
26
+ // the `export *` it was, which is "cannot tell".
27
+ if (found.has(specifier) || !isFile(file)) continue;
28
+ found.set(specifier, await Bun.file(file).text());
29
+ }
30
+ }
31
+ return inlineReexports(source, (specifier) => found.get(specifier));
32
+ }
33
+
34
+ function isFile(path: string): boolean {
35
+ try {
36
+ return statSync(path).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
@@ -0,0 +1,29 @@
1
+ // Single responsibility: the refusal `x g action` / `x g mutator` raise when the slice they were
2
+ // pointed at has a hand-written `policy.ts` without the exports the generated file imports.
3
+ // Declared outside `templates/` (source the CLI emits) and beside nothing else, as
4
+ // `generate-feature.ts`'s `FeatureUnknownError` is: `errors.ts` sits at its line ceiling.
5
+
6
+ import { UltimateError } from '@ultimat3/core';
7
+
8
+ /** Thrown while the files are PLANNED, so nothing is on disk when it is. */
9
+ export class SlicePolicyIncompleteError extends UltimateError {
10
+ constructor(input: {
11
+ readonly policy: string;
12
+ readonly feature: string;
13
+ readonly missing: readonly string[];
14
+ readonly kind: 'action' | 'mutator';
15
+ }) {
16
+ super({
17
+ code: 'X_SLICE_POLICY_INCOMPLETE',
18
+ cause: `${input.policy} does not export ${input.missing.join(' or ')}, which the ${input.kind} x g ${input.kind} writes imports from it — the file is hand-written, a generator never edits it, and nothing was written`,
19
+ // The fix IS the edit: `x g policy --dry-run` prints a write plan, never the source to copy.
20
+ fix: `${input.missing.map((name) => exportLine(name, input.feature)).join(' ')} # add to ${input.policy} (tag from @ultimat3/cache, can from @ultimat3/policy; the permission must be one its definePermissions() declares), then run x g ${input.kind} again`,
21
+ });
22
+ }
23
+ }
24
+
25
+ /** The export line a scaffolded `policy.ts` carries for `name`. */
26
+ const exportLine = (name: string, feature: string): string =>
27
+ name.endsWith('Tag')
28
+ ? `export const ${name} = tag('${feature}');`
29
+ : `export const ${name} = can('${feature}:write');`;
package/src/index.ts CHANGED
@@ -157,8 +157,9 @@ export {
157
157
  export { quoteArg } from './shell-quote';
158
158
  export type { SiteSettings } from './site-config';
159
159
  export { loadSiteSettings, publicOrigin } from './site-config';
160
- export type { SiteSeo, SiteSeoOptions } from './site-seo';
160
+ export type { SiteSeo, SiteSeoOptions, SiteSeoSitemap } from './site-seo';
161
161
  export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
162
+ export { writeSiteSeo } from './site-seo-write';
162
163
  export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
163
164
  export type { SkippedRoute, StaticReport } from './static-report';
164
165
  export { parseStaticReport } from './static-report';
package/src/mcp-errors.ts CHANGED
@@ -304,6 +304,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
304
304
  'x errors explain X_BACKFILL_SLICE_ENTITY --json # the refusal itself names the x g job line for this slice',
305
305
  X_LAYOUT_SIBLING_MODULE:
306
306
  'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
307
+ X_SLICE_POLICY_INCOMPLETE:
308
+ 'x policy list --json # names the slice policy module; add the export lines the thrown fix spells out to it, then run the generator again',
307
309
  };
308
310
 
309
311
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/seo-meta.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // this is the only tier that can see both: `@ultimat3/seo` is tier 1 and may not import the route
6
6
  // registry, which is tier 4.
7
7
 
8
+ import { ctxOf, runWithContext } from '@ultimat3/core';
9
+ import { localizedPath } from '@ultimat3/i18n';
8
10
  import { metaContextFor, type RouteEntry, routeDataFor, routeEntries } from '@ultimat3/render';
9
11
  import type { RouteRecord } from '@ultimat3/seo';
10
12
  import { loadApp } from './app-load';
@@ -66,11 +68,19 @@ export async function scanSiteMeta(root: string): Promise<SiteMetaScan> {
66
68
  /**
67
69
  * The same scan over the registry as it stands, without loading anything.
68
70
  *
71
+ * `locale` runs every `meta` inside that locale's context; omitted, in none (the gate's read).
72
+ *
69
73
  * Split from `scanSiteMeta` so the resolution rules are testable against routes registered by hand:
70
74
  * the half worth pinning is which routes are reachable and what happens when one throws, and
71
75
  * neither of those is a fact about globbing a directory.
72
76
  */
73
- export async function readSiteMeta(): Promise<SiteMetaScan> {
77
+ export async function readSiteMeta(locale?: string): Promise<SiteMetaScan> {
78
+ // In a locale: each `meta` runs as that locale's document would — `ctx.locale`, `t` and the URL
79
+ // are its own — which is what lets a page that is `noindex` in one language say so.
80
+ const scoped = <T>(run: () => Promise<T>): Promise<T> =>
81
+ locale === undefined ? run() : runWithContext(ctxOf({ role: 'web', locale }), run);
82
+ const pathOf = (path: string): string =>
83
+ locale === undefined ? path : localizedPath(path, locale);
74
84
  const records: RouteRecord[] = [];
75
85
  const unresolved: UnresolvedRoute[] = [];
76
86
  const findings: Finding[] = [];
@@ -82,10 +92,10 @@ export async function readSiteMeta(): Promise<SiteMetaScan> {
82
92
  unresolved.push({ path: entry.path, file: entry.file, reason });
83
93
  continue;
84
94
  }
85
- const ctx = { url: `${PROBE_ORIGIN}${entry.path}`, params: {} };
95
+ const ctx = { url: `${PROBE_ORIGIN}${pathOf(entry.path)}`, params: {} };
86
96
  try {
87
- const meta = await entry.config.meta(
88
- metaContextFor(ctx, await routeDataFor(entry.config, ctx)),
97
+ const meta = await scoped(async () =>
98
+ entry.config.meta(metaContextFor(ctx, await routeDataFor(entry.config, ctx))),
89
99
  );
90
100
  records.push({
91
101
  path: entry.path,
@@ -11,14 +11,14 @@ export interface SiteSettings {
11
11
  readonly origin: string | null;
12
12
  /** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
13
13
  readonly disallow: readonly string[];
14
- /** `seo.sitemap` — public `app/` pages to list, and where `<lastmod>` comes from. */
14
+ /** `seo.sitemap` — public `app/` pages to list, where `<lastmod>` comes from, sections, page size. */
15
15
  readonly sitemap: SeoSitemapConfig;
16
16
  }
17
17
 
18
18
  export const NO_SITE_SETTINGS: SiteSettings = {
19
19
  origin: null,
20
20
  disallow: [],
21
- sitemap: { extra: [], lastmod: 'none' },
21
+ sitemap: { extra: [], lastmod: 'none', sections: [], pageSize: 50_000 },
22
22
  };
23
23
 
24
24
  const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
@@ -0,0 +1,16 @@
1
+ // Single responsibility: `siteSeo()`'s answer as files in a static export — `/sitemap.xml`, every
2
+ // part an index names, and `robots.txt` — so a CDN serves the bytes a running web role answers.
3
+
4
+ // why: Bun exposes no path-join primitive, and every file path is relative to the output root.
5
+ import { join } from 'node:path';
6
+ import { ROBOTS_PATH, type SiteSeo } from './site-seo';
7
+
8
+ /**
9
+ * Writes every file of `seo` under `out` and answers their site-relative paths, the sitemap files
10
+ * first. `Bun.write` creates `sitemaps/` on the way, so an index and its parts land together.
11
+ */
12
+ export async function writeSiteSeo(out: string, seo: SiteSeo): Promise<readonly string[]> {
13
+ for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
14
+ await Bun.write(join(out, ROBOTS_PATH), seo.robots);
15
+ return [...seo.sitemaps.map((file) => file.path), ROBOTS_PATH];
16
+ }
package/src/site-seo.ts CHANGED
@@ -8,8 +8,8 @@ import { localizedPath, unlocalizedPath } from '@ultimat3/i18n';
8
8
  import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
9
9
  import type { RouteEntry } from '@ultimat3/render';
10
10
  import { routeEntries } from '@ultimat3/render';
11
- import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
12
- import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
11
+ import { enumeratePrerenderEntries, fillPath } from '@ultimat3/render/server';
12
+ import type { RouteRecord, SitemapFile, SitemapPage } from '@ultimat3/seo';
13
13
  import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
14
14
  import { readSiteMeta } from './seo-meta';
15
15
  import { lastmodOf } from './sitemap-lastmod';
@@ -25,7 +25,8 @@ export interface SiteSeoOptions {
25
25
  /**
26
26
  * The concrete pages a build EMITTED for a dynamic route. The static export passes its report's,
27
27
  * so the sitemap cannot name a page the artifact lacks; absent, `prerender()` is asked — which is
28
- * the same list, enumerated by the same function the prerenderer calls.
28
+ * the same list, enumerated by the same function the prerenderer calls. Given, `prerender()` is
29
+ * still asked what each page says of ITSELF (`lastmod`, `locales`); the list stays this one.
29
30
  */
30
31
  readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
31
32
  /**
@@ -35,14 +36,22 @@ export interface SiteSeoOptions {
35
36
  readonly disallow?: readonly string[] | undefined;
36
37
  /**
37
38
  * `seo.sitemap` from `app.config.ts` (`loadSiteSettings`): the public `app/` pages to list beside
38
- * the `site/` ones, and where each `<lastmod>` comes from. Omitted: `site/` pages, no `<lastmod>`.
39
+ * the `site/` ones, where each `<lastmod>` comes from, and the sections and page size of an
40
+ * index. Omitted: `site/` pages, no `<lastmod>`, one urlset.
39
41
  */
40
- readonly sitemap?: SeoSitemapConfig | undefined;
42
+ readonly sitemap?: SiteSeoSitemap | undefined;
41
43
  /** The app root the route files are relative to — read for `lastmod: 'git' | 'mtime'`. */
42
44
  readonly root?: string | undefined;
43
45
  }
44
46
 
45
- const NO_SITEMAP_SETTINGS: SeoSitemapConfig = { extra: [], lastmod: 'none' };
47
+ /**
48
+ * `seo.sitemap` as `siteSeo` takes it: `sections` and `pageSize` optional, so a caller that wrote
49
+ * `{ extra, lastmod }` before either existed still compiles and still gets the one urlset.
50
+ */
51
+ export type SiteSeoSitemap = Pick<SeoSitemapConfig, 'extra' | 'lastmod'> &
52
+ Partial<Pick<SeoSitemapConfig, 'sections' | 'pageSize'>>;
53
+
54
+ const NO_SITEMAP_SETTINGS: SiteSeoSitemap = { extra: [], lastmod: 'none' };
46
55
 
47
56
  /** A `seo.sitemap.extra` path that names no public page outside `site/`. */
48
57
  export class SitemapExtraInvalidError extends UltimateError {
@@ -67,42 +76,87 @@ const isPublicSite = (entry: RouteEntry): boolean =>
67
76
  entry.surface === 'site' && entry.config.policy === undefined;
68
77
 
69
78
  /**
70
- * A dynamic route's pages, UNPREFIXED and once each. A static export's report lists every locale's
71
- * copy (`/blog/a` and `/en/blog/a`), and the sitemap localizes each page itself — reading the
72
- * prefixed copies back as pages of their own listed `/en/en/blog/a`.
79
+ * A route's pages, UNPREFIXED and once each, with what `prerender()` said of each one. A
80
+ * static export's report lists every locale's copy (`/blog/a` and `/en/blog/a`), and the sitemap
81
+ * localizes each page itself — reading the prefixed copies back as pages of their own listed
82
+ * `/en/en/blog/a`.
73
83
  */
74
- const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<readonly string[]> => {
75
- if (options.pagesFor !== undefined) {
76
- return [...new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)))];
77
- }
78
- const params = await enumeratePrerender(entry);
79
- return params.map((set) => fillPath(entry.pattern.source, set));
84
+ const pagesOf = async (
85
+ entry: RouteEntry,
86
+ options: SiteSeoOptions,
87
+ ): Promise<readonly SitemapPage[]> => {
88
+ const said = (await enumeratePrerenderEntries(entry)).map(
89
+ (page): SitemapPage => ({
90
+ path: fillPath(entry.pattern.source, page.params),
91
+ ...(page.lastmod === undefined ? {} : { lastmod: page.lastmod }),
92
+ ...(page.locales === undefined ? {} : { locales: page.locales }),
93
+ }),
94
+ );
95
+ // Only a DYNAMIC route's list is the report's: a page with no params is listed whether or not
96
+ // this build emitted it (an `isr` one is rendered by the server), exactly as before.
97
+ if (options.pagesFor === undefined || !isDynamic(entry.path)) return said;
98
+ const byPath = new Map(said.map((page) => [page.path, page]));
99
+ const emitted = new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)));
100
+ return [...emitted].map((path) => byPath.get(path) ?? { path });
80
101
  };
81
102
 
103
+ /**
104
+ * What a page's own `meta` says about being indexed, per locale: the record's `meta` when every
105
+ * locale agrees, its `locales` when only some may be indexed. `robots: { index: false }` in ONE
106
+ * locale used to leave that copy listed and every other copy's hreflang pointing at it — the read
107
+ * was made once, in no locale at all.
108
+ */
109
+ async function indexability(
110
+ locales: readonly string[],
111
+ ): Promise<(path: string) => Pick<RouteRecord, 'meta' | 'locales'>> {
112
+ if (locales.length < 2) {
113
+ const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
114
+ return (path) => {
115
+ const meta = metaByPath.get(path);
116
+ return meta === undefined ? {} : { meta };
117
+ };
118
+ }
119
+ const scans = new Map<string, Map<string, RouteRecord['meta']>>();
120
+ // One locale at a time: each scan runs every route's `meta` inside that locale's context.
121
+ for (const locale of locales) {
122
+ scans.set(locale, new Map((await readSiteMeta(locale)).records.map((r) => [r.path, r.meta])));
123
+ }
124
+ return (path) => {
125
+ const listed = locales.filter(
126
+ (locale) => scans.get(locale)?.get(path)?.robots?.index !== false,
127
+ );
128
+ if (listed.length === locales.length) return {};
129
+ // No locale may be indexed: the `noindex` itself is what keeps the route out.
130
+ if (listed.length === 0) return { meta: { robots: { index: false } } };
131
+ return { locales: listed };
132
+ };
133
+ }
134
+
82
135
  /**
83
136
  * The public `site/` routes as `@ultimat3/seo` reads them. `meta` is carried where it resolves
84
137
  * without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
85
138
  * keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
139
+ * A route with params or a `load` resolves no `meta` here: its pages say it per entry (`locales`).
86
140
  */
87
141
  async function publicSiteRoutes(
88
142
  options: SiteSeoOptions,
89
143
  stamp: Stamp,
144
+ locales: readonly string[],
90
145
  ): Promise<readonly RouteRecord[]> {
91
- const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
146
+ const indexed = await indexability(locales);
92
147
  return routeEntries()
93
148
  .filter(isPublicSite)
94
- .map((entry) => {
95
- const meta = metaByPath.get(entry.path);
96
- return {
97
- path: entry.path,
98
- file: entry.file,
99
- surface: 'site' as const,
100
- render: entry.config.render,
101
- ...(meta === undefined ? {} : { meta }),
102
- ...(isDynamic(entry.path) ? { prerender: () => pagesOf(entry, options) } : {}),
103
- ...stamp(entry.file),
104
- };
105
- });
149
+ .map((entry) => ({
150
+ path: entry.path,
151
+ file: entry.file,
152
+ surface: 'site' as const,
153
+ render: entry.config.render,
154
+ ...indexed(entry.path),
155
+ ...(isDynamic(entry.path) || entry.config.prerender !== undefined
156
+ ? { prerender: () => pagesOf(entry, options) }
157
+ : {}),
158
+ ...stamp(entry.file),
159
+ }));
106
160
  }
107
161
 
108
162
  type Stamp = (file: string) => { readonly lastmod?: string };
@@ -158,11 +212,15 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
158
212
  return lastmod === undefined ? {} : { lastmod };
159
213
  };
160
214
  const routes = [
161
- ...(await publicSiteRoutes(options, stamp)),
215
+ ...(await publicSiteRoutes(options, stamp, locales)),
162
216
  ...extraRoutes(settings.extra, stamp),
163
217
  ];
164
218
  const sitemap = await buildSitemap(routes, {
165
219
  baseUrl: options.baseUrl,
220
+ // A URL `robots.txt` keeps crawlers out of is not handed to them here: one config, two files.
221
+ ...(options.disallow === undefined ? {} : { disallow: options.disallow }),
222
+ ...(settings.sections === undefined ? {} : { sections: settings.sections }),
223
+ ...(settings.pageSize === undefined ? {} : { maxUrls: settings.pageSize }),
166
224
  ...(locales.length < 2
167
225
  ? {}
168
226
  : {
@@ -172,8 +230,8 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
172
230
  localizedPath(path, locale, defaultLocale),
173
231
  }),
174
232
  });
175
- // Past 50,000 URLs `files` are `/sitemaps/<n>.xml` and the index is `/sitemap.xml`; below it,
176
- // `files` is that one file and there is no index.
233
+ // With sections, or past a protocol cap, `files` are `/sitemaps/*.xml` and the index is
234
+ // `/sitemap.xml`; otherwise `files` is that one file and there is no index.
177
235
  const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
178
236
  const robots = buildRobots({
179
237
  baseUrl: options.baseUrl,
@@ -2,12 +2,19 @@
2
2
  // pins both. One declaration projects to an HTTP route, an OpenAPI operation, a typed client, a
3
3
  // job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
4
4
 
5
+ import { SlicePolicyIncompleteError } from '../generate-slice-policy';
5
6
  import type { FeatureTarget } from './entity';
6
7
  import { sortedImports } from './imports';
7
8
  import type { GeneratedFile, NameSet } from './naming';
8
9
  import { names } from './naming';
9
10
  import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
- import { sliceExports, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
11
+ import {
12
+ exportsUnknown,
13
+ grantOf,
14
+ sliceExports,
15
+ sliceFoundation,
16
+ sliceTakesScaffoldRow,
17
+ } from './slice-foundation';
11
18
  import { wrapImport } from './wrap';
12
19
 
13
20
  /**
@@ -177,6 +184,12 @@ interface HandlerShape {
177
184
  /** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
178
185
  readonly storesRow: boolean;
179
186
  readonly dbModule: string;
187
+ /**
188
+ * The permission the writer is granted — `<feature>:write` for the policy this generator wrote,
189
+ * the one literal `can('<perm>')` of a handwritten one, or `undefined` when that policy's grant
190
+ * cannot be read, and then no assertion depends on it.
191
+ */
192
+ readonly grant: string | undefined;
180
193
  }
181
194
 
182
195
  /**
@@ -204,6 +217,12 @@ unitTest('the local twin invents no row the store never held', () => {
204
217
 
205
218
  /** The authoritative half's cases, refusal first, in the shape the slice earns. */
206
219
  const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
220
+ if (shape.grant === undefined) {
221
+ return `
222
+ // No handler assertion: ../policy.ts is hand-written and its grant is not one literal can('…'),
223
+ // so which actor reaches the handler is the author's to state — add that test here.
224
+ `;
225
+ }
207
226
  const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
208
227
  if (!shape.lookup) {
209
228
  return `
@@ -253,7 +272,8 @@ unitTest('the handler answers the row it found', async () => {
253
272
  * it would be a guess about someone else's table.
254
273
  */
255
274
  const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
256
- const stores = shape.lookup && shape.storesRow;
275
+ const known = shape.grant !== undefined;
276
+ const stores = known && shape.lookup && shape.storesRow;
257
277
  const testing = [
258
278
  ...(stores ? ['afterEach'] : []),
259
279
  'expect',
@@ -264,7 +284,7 @@ const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): s
264
284
  // process, so all three belong to the \`unit\` step. The contract projections are next door.
265
285
  ${sortedImports([
266
286
  ...(stores ? ["import { ctxOf, runWithContext } from '@ultimat3/core';"] : []),
267
- "import { testActor } from '@ultimat3/policy';",
287
+ ...(known ? ["import { testActor } from '@ultimat3/policy';"] : []),
268
288
  `import { ${testing} } from '@ultimat3/testing';`,
269
289
  ...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
270
290
  ])}${stores ? "\nimport * as repo from '../repo';" : ''}
@@ -278,11 +298,15 @@ ${shape.isMutator ? `\n// The client store's table: the entity's own, so the twi
278
298
  // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
279
299
  // object, so each projection of it works there with nothing to remember.
280
300
  const target = ${name.camel}.named('${name.camel}');
281
-
301
+ ${
302
+ known
303
+ ? `
282
304
  // Holds the grant in the org the input names: past the policy, so what answers is the handler.
283
- const grant = ['${feature.kebab}:write'];
305
+ const grant = ['${shape.grant}'];
284
306
  const writer = testActor('writer', { orgId, permissions: grant }).actor;
285
- ${
307
+ `
308
+ : ''
309
+ }${
286
310
  stores
287
311
  ? `
288
312
  // One store per process: without this, one test's rows are the next test's fixtures.
@@ -305,9 +329,11 @@ const actionContractTest = (
305
329
  name: NameSet,
306
330
  feature: NameSet,
307
331
  isMutator: boolean,
332
+ /** The slice's policy is the one this generator wrote, so its tenancy rule is known. */
333
+ scaffoldPolicy = true,
308
334
  ): string => `// ${name.camel}: the contract every action owes, and the foreign-org actor it denies before the
309
335
  // handler runs. One declaration, every surface.
310
- ${preamble(name, feature, isMutator, 'contractTest, expect', true)}
336
+ ${preamble(name, feature, isMutator, 'contractTest, expect', scaffoldPolicy)}
311
337
  contractTest('${name.camel} passes the action contract', async () => {
312
338
  // Three assertions the framework makes for any action, without knowing what this one does:
313
339
  // garbage input is rejected, an anonymous actor is denied, and the operation reaches the
@@ -315,13 +341,19 @@ contractTest('${name.camel} passes the action contract', async () => {
315
341
  for (const contract of target.contract()) await contract.run();
316
342
  });
317
343
 
318
- contractTest('${name.camel} denies a foreign org', async () => {
344
+ ${
345
+ scaffoldPolicy
346
+ ? `contractTest('${name.camel} denies a foreign org', async () => {
319
347
  // \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
320
348
  // HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
321
349
  const denied = await target.as(outsider, input).catch((error: unknown) => error);
322
350
  expect(denied).toBeUltimateError('X_FORBIDDEN');
323
351
  });
324
-
352
+ `
353
+ : `// No foreign-org assertion: ../policy.ts is hand-written, and whether it checks the org is the
354
+ // author's rule to state — add that test here.
355
+ `
356
+ }
325
357
  // No MCP tool until it is described: a placeholder description misleads the agent reading it.
326
358
  contractTest('${name.camel} projects one operation and no tool', () => {
327
359
  expect(target.openapi().operationId).toBe('${name.camel}');
@@ -346,6 +378,12 @@ export interface ActionOptions extends FeatureTarget {
346
378
  readonly sliceEntity?: string;
347
379
  /** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
348
380
  readonly sliceRepo?: string;
381
+ /**
382
+ * The slice's `policy.ts` as it stands on disk, absent when the slice has none yet (and then the
383
+ * scaffolded one is written). Read for `sliceErrors`'s reason: the generated file IMPORTS from
384
+ * it, and what it exports is a fact about this app's disk.
385
+ */
386
+ readonly slicePolicy?: string;
349
387
  }
350
388
 
351
389
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -354,16 +392,26 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
354
392
  const dir = `${target.surfaceDir}/${target.feature}/actions`;
355
393
  const isMutator = target.mutator === true;
356
394
  const entity = target.sliceEntity !== undefined;
395
+ // Before a file is planned: a generated action that does not compile is refused, never written.
396
+ assertPolicyExports(target, feature, isMutator);
357
397
  const lookup =
358
398
  entity &&
359
399
  (target.sliceErrors === undefined ||
360
- sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`));
400
+ sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`)) &&
401
+ // The lookup calls `repo.byId`: a `repo.ts` an author wrote without one gets the neutral body.
402
+ (target.sliceRepo === undefined || sliceExports(target.sliceRepo, 'byId'));
361
403
  return [
362
404
  // The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
363
405
  // (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
364
406
  // into a slice no `x g resource` had created emitted all three imports and wrote none of them.
365
407
  // With no entity, only the policy: a lookup-free body imports neither ../errors nor ../repo.
366
- ...sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
408
+ // And ONLY those: an action is not a resource scaffold. A module already on disk is its
409
+ // author's — it gets no test from here and no admin labels, both of which assert the shape
410
+ // `x g resource` writes, not the one that is there (`withoutOwned`).
411
+ ...withoutOwned(
412
+ sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
413
+ target,
414
+ ),
367
415
  {
368
416
  path: `${dir}/${name.kebab}.ts`,
369
417
  contents: isMutator
@@ -381,11 +429,63 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
381
429
  lookup,
382
430
  storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
383
431
  dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
432
+ // The policy this run writes grants `<feature>:write`; a handwritten one, what it says.
433
+ grant:
434
+ target.slicePolicy === undefined
435
+ ? `${feature.kebab}:write`
436
+ : grantOf(target.slicePolicy, `can${feature.pascal}Write`),
384
437
  }),
385
438
  },
386
439
  {
387
440
  path: `${dir}/${name.kebab}.contract.test.ts`,
388
- contents: actionContractTest(name, feature, isMutator),
441
+ contents: actionContractTest(name, feature, isMutator, target.slicePolicy === undefined),
389
442
  },
390
443
  ];
391
444
  }
445
+
446
+ /** The slice modules a run may find already written, and the files each would otherwise be handed. */
447
+ const OWNED = [
448
+ ['sliceEntity', ['/entity.ts', '/entity.test.ts', '.json']],
449
+ ['sliceRepo', ['/repo.ts', '/repo.test.ts']],
450
+ ['slicePolicy', ['/policy.ts', '/policy.test.ts']],
451
+ ] as const;
452
+
453
+ /**
454
+ * The planted slice files, minus everything that belongs to a module ALREADY on disk. `if-absent`
455
+ * protects a file; it does not know that `repo.test.ts` belongs to a `repo.ts` somebody wrote, so
456
+ * a hand-written slice was handed three tests asserting the scaffold's shape and six admin labels
457
+ * (the catalog `.json`) for an entity it had declared itself. A module this run does write keeps
458
+ * every companion.
459
+ */
460
+ function withoutOwned(
461
+ files: readonly GeneratedFile[],
462
+ target: ActionOptions,
463
+ ): readonly GeneratedFile[] {
464
+ const owned = OWNED.flatMap(([module, suffixes]) =>
465
+ target[module] === undefined ? [] : suffixes,
466
+ );
467
+ return files.filter((file) => !owned.some((suffix) => file.path.endsWith(suffix)));
468
+ }
469
+
470
+ /**
471
+ * Refuse a slice whose own `policy.ts` lacks what the generated file imports from it. An absent
472
+ * `policy.ts` is not this case — the scaffolded one is written beside the action, with both names.
473
+ *
474
+ * Refused rather than adapted, unlike a missing `<Feature>NotFoundError`: there is no neutral
475
+ * policy to fall back to. An inline `can('<feature>:write')` names a permission the slice's
476
+ * `definePermissions()` never declared, which is a type error of its own.
477
+ */
478
+ function assertPolicyExports(target: ActionOptions, feature: NameSet, isMutator: boolean): void {
479
+ const source = target.slicePolicy;
480
+ // A barrel re-exporting a module nobody could read: cannot tell, so never refused on a guess.
481
+ if (source === undefined || exportsUnknown(source)) return;
482
+ const needed = [`can${feature.pascal}Write`, ...(isMutator ? [] : [`${feature.camel}Tag`])];
483
+ const missing = needed.filter((name) => !sliceExports(source, name));
484
+ if (missing.length === 0) return;
485
+ throw new SlicePolicyIncompleteError({
486
+ policy: `${target.surfaceDir}/${target.feature}/policy.ts`,
487
+ feature: target.feature,
488
+ missing,
489
+ kind: isMutator ? 'mutator' : 'action',
490
+ });
491
+ }
@@ -66,7 +66,13 @@ const prerender =
66
66
  // landed on disk — which is what \`x build --target static --json\` reads back.
67
67
 
68
68
  import { join } from 'node:path';
69
- import { loadSiteSettings, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
69
+ import {
70
+ loadSiteSettings,
71
+ type PrerenderReport,
72
+ prerenderSite,
73
+ siteSeo,
74
+ writeSiteSeo,
75
+ } from '@ultimat3/cli';
70
76
 
71
77
  const root = join(import.meta.dir, '..', '..');
72
78
  const flag = Bun.argv.indexOf('--out');
@@ -84,9 +90,12 @@ const origin = Bun.env.SITE_ORIGIN;
84
90
  * page whose \`meta\` says \`robots: { index: false }\` left out — so a crawler reads one sitemap
85
91
  * from the CDN and from the container.
86
92
  *
87
- * A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report
88
- * rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page the
89
- * artifact does not contain.
93
+ * A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report:
94
+ * the sitemap then cannot name a page the artifact does not contain. \`prerender()\` is asked again
95
+ * only for what each page says of itself — its \`lastmod\`, the \`locales\` it is listed in.
96
+ *
97
+ * With \`seo.sitemap.sections\` the answer is an index and its parts: \`/sitemap.xml\` plus
98
+ * \`/sitemaps/<name>-<n>.xml\`, every one written here.
90
99
  *
91
100
  * \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
92
101
  * \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
@@ -104,9 +113,7 @@ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]
104
113
  pagesFor: (route) =>
105
114
  report.pages.filter((page) => page.route === route).map((page) => page.path),
106
115
  });
107
- for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
108
- await Bun.write(join(out, 'robots.txt'), seo.robots);
109
- return [...seo.sitemaps.map((file) => file.path), '/robots.txt'];
116
+ return await writeSiteSeo(out, seo);
110
117
  }
111
118
 
112
119
  if (import.meta.main) {
@@ -131,7 +131,59 @@ export function sliceExports(source: string, name: string): boolean {
131
131
  const declared = new RegExp(
132
132
  `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
133
133
  );
134
- return declared.test(code) || listedExports(code).includes(name);
134
+ return (
135
+ declared.test(code) || listedExports(code).includes(name) || destructured(code).includes(name)
136
+ );
137
+ }
138
+
139
+ /** The names an `export const { a, b: c } = …` declares — `a` and `c`, never `b`. */
140
+ const destructured = (code: string): readonly string[] =>
141
+ [...code.matchAll(/\bexport\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/g)].flatMap((match) =>
142
+ (match[1] ?? '')
143
+ .split(',')
144
+ .map((entry) => (entry.split(':').at(-1) ?? '').split('=')[0]?.trim() ?? '')
145
+ .filter((entry) => entry !== ''),
146
+ );
147
+
148
+ const STAR = /\bexport\s*\*\s*from\s*['"]([^'"]+)['"]\s*;?/g;
149
+
150
+ /**
151
+ * `source` with each `export * from '<specifier>'` replaced by the module `read` answers for it —
152
+ * ONE level, which is what a slice's barrel is. A specifier `read` cannot answer (a package, a
153
+ * missing file) is left as it stands, and that leftover is what `exportsUnknown` reports.
154
+ */
155
+ export function inlineReexports(
156
+ source: string,
157
+ read: (specifier: string) => string | undefined,
158
+ ): string {
159
+ return stripComments(source).replace(STAR, (statement: string, specifier: string) => {
160
+ const inner = read(specifier);
161
+ // Its own `export *` lines are dropped rather than followed: one level, and a module that
162
+ // re-exports further is "cannot tell" only if THIS file still says so.
163
+ return inner === undefined ? statement : stripComments(inner).replace(STAR, '');
164
+ });
165
+ }
166
+
167
+ /** Whether `source` re-exports a module nobody read: what it exports cannot be known from here. */
168
+ export const exportsUnknown = (source: string): boolean => {
169
+ const code = stripComments(source);
170
+ return /\bexport\s*\*\s*from\b/.test(code) || nestedPattern(code);
171
+ };
172
+
173
+ /**
174
+ * Whether an exported destructuring pattern is one the flat reader above cannot spell: a nested
175
+ * object or array pattern, or a rest element. Its names are then UNKNOWN — never "missing".
176
+ */
177
+ function nestedPattern(code: string): boolean {
178
+ for (const match of code.matchAll(/\bexport\s+(?:const|let|var)\s*\{/g)) {
179
+ let depth = 1;
180
+ for (let at = (match.index ?? 0) + match[0].length; at < code.length && depth > 0; at += 1) {
181
+ const char = code[at];
182
+ if (char === '{' || char === '[' || code.startsWith('...', at)) return true;
183
+ if (char === '}') depth -= 1;
184
+ }
185
+ }
186
+ return false;
135
187
  }
136
188
 
137
189
  /** ` title: text({ max: 200 }),` — one column of an entity, as its name and its builder. */
@@ -162,3 +214,16 @@ export function sliceTakesScaffoldRow(
162
214
  if (wanted === '' || columnsOf(sliceEntity) !== wanted) return false;
163
215
  return sliceRepo === undefined || sliceExports(sliceRepo, 'insert');
164
216
  }
217
+
218
+ /**
219
+ * The ONE permission `name` grants, when `source` declares it as a single literal call:
220
+ * `export const canBlogWrite = can('blog:publish');`. Anything else — a predicate argument,
221
+ * `and`/`or`, a function, a re-export — is `undefined`: the grant cannot be read, and a generated
222
+ * test must not assume one.
223
+ */
224
+ export function grantOf(source: string, name: string): string | undefined {
225
+ const declared = new RegExp(
226
+ `\\bexport\\s+const\\s+${name}\\s*=\\s*can\\(\\s*(['"\`])([A-Za-z0-9_.*:-]+)\\1\\s*\\)\\s*;`,
227
+ );
228
+ return declared.exec(stripComments(source))?.[2];
229
+ }