@ultimat3/cli 27.8.1 → 27.9.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.9.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.9.0",
43
+ "@ultimat3/admin": "27.9.0",
44
+ "@ultimat3/ai": "27.9.0",
45
+ "@ultimat3/auth": "27.9.0",
46
+ "@ultimat3/cache": "27.9.0",
47
+ "@ultimat3/core": "27.9.0",
48
+ "@ultimat3/db": "27.9.0",
49
+ "@ultimat3/entity": "27.9.0",
50
+ "@ultimat3/flags": "27.9.0",
51
+ "@ultimat3/http": "27.9.0",
52
+ "@ultimat3/i18n": "27.9.0",
53
+ "@ultimat3/jobs": "27.9.0",
54
+ "@ultimat3/mail": "27.9.0",
55
+ "@ultimat3/manifest": "27.9.0",
56
+ "@ultimat3/mcp": "27.9.0",
57
+ "@ultimat3/money": "27.9.0",
58
+ "@ultimat3/notify": "27.9.0",
59
+ "@ultimat3/policy": "27.9.0",
60
+ "@ultimat3/pwa": "27.9.0",
61
+ "@ultimat3/query": "27.9.0",
62
+ "@ultimat3/realtime": "27.9.0",
63
+ "@ultimat3/render": "27.9.0",
64
+ "@ultimat3/schema": "27.9.0",
65
+ "@ultimat3/seo": "27.9.0",
66
+ "@ultimat3/storage": "27.9.0",
67
+ "@ultimat3/testing": "27.9.0",
68
+ "@ultimat3/time": "27.9.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
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/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,
@@ -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) {