@ultimat3/cli 22.2.1 → 22.2.2

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": "22.2.1",
3
+ "version": "22.2.2",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.2.1",
42
- "@ultimat3/admin": "22.2.1",
43
- "@ultimat3/ai": "22.2.1",
44
- "@ultimat3/auth": "22.2.1",
45
- "@ultimat3/cache": "22.2.1",
46
- "@ultimat3/core": "22.2.1",
47
- "@ultimat3/db": "22.2.1",
48
- "@ultimat3/entity": "22.2.1",
49
- "@ultimat3/flags": "22.2.1",
50
- "@ultimat3/http": "22.2.1",
51
- "@ultimat3/i18n": "22.2.1",
52
- "@ultimat3/jobs": "22.2.1",
53
- "@ultimat3/mail": "22.2.1",
54
- "@ultimat3/manifest": "22.2.1",
55
- "@ultimat3/mcp": "22.2.1",
56
- "@ultimat3/money": "22.2.1",
57
- "@ultimat3/notify": "22.2.1",
58
- "@ultimat3/policy": "22.2.1",
59
- "@ultimat3/pwa": "22.2.1",
60
- "@ultimat3/query": "22.2.1",
61
- "@ultimat3/realtime": "22.2.1",
62
- "@ultimat3/render": "22.2.1",
63
- "@ultimat3/schema": "22.2.1",
64
- "@ultimat3/seo": "22.2.1",
65
- "@ultimat3/storage": "22.2.1",
66
- "@ultimat3/testing": "22.2.1",
67
- "@ultimat3/time": "22.2.1",
41
+ "@ultimat3/action": "22.2.2",
42
+ "@ultimat3/admin": "22.2.2",
43
+ "@ultimat3/ai": "22.2.2",
44
+ "@ultimat3/auth": "22.2.2",
45
+ "@ultimat3/cache": "22.2.2",
46
+ "@ultimat3/core": "22.2.2",
47
+ "@ultimat3/db": "22.2.2",
48
+ "@ultimat3/entity": "22.2.2",
49
+ "@ultimat3/flags": "22.2.2",
50
+ "@ultimat3/http": "22.2.2",
51
+ "@ultimat3/i18n": "22.2.2",
52
+ "@ultimat3/jobs": "22.2.2",
53
+ "@ultimat3/mail": "22.2.2",
54
+ "@ultimat3/manifest": "22.2.2",
55
+ "@ultimat3/mcp": "22.2.2",
56
+ "@ultimat3/money": "22.2.2",
57
+ "@ultimat3/notify": "22.2.2",
58
+ "@ultimat3/policy": "22.2.2",
59
+ "@ultimat3/pwa": "22.2.2",
60
+ "@ultimat3/query": "22.2.2",
61
+ "@ultimat3/realtime": "22.2.2",
62
+ "@ultimat3/render": "22.2.2",
63
+ "@ultimat3/schema": "22.2.2",
64
+ "@ultimat3/seo": "22.2.2",
65
+ "@ultimat3/storage": "22.2.2",
66
+ "@ultimat3/testing": "22.2.2",
67
+ "@ultimat3/time": "22.2.2",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -34,14 +34,27 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
34
34
  });
35
35
  }
36
36
 
37
- /** Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call. */
37
+ /**
38
+ * Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call, and where it sits:
39
+ * `line` is the start of the line holding `key: [`, `start` is just past the `[`, `end` is the `]`.
40
+ *
41
+ * Searched from `from` (the `defineApi({` call), so a `jobs: [` in a comment or an object above the
42
+ * call is never the one edited. `line` is found from the key, never from `start`: in a list already
43
+ * wrapped one entry per line the character AT `start` is the newline after `[`, and a backwards
44
+ * search from there answered the first ITEM's line — the rewrite then nested a second `jobs: [`
45
+ * inside the first and left the old `]` behind.
46
+ */
38
47
  const listOf = (
39
48
  source: string,
40
49
  key: string,
41
- ): { start: number; end: number; items: string[] } | undefined => {
42
- const open = new RegExp(`\\n(\\s*)${key}: \\[`).exec(source);
43
- if (open === null) return undefined;
44
- const start = open.index + open[0].length;
50
+ from: number,
51
+ ): { line: number; start: number; end: number; items: string[] } | undefined => {
52
+ const open = new RegExp(`\\n([ \\t]*)${key}: \\[`, 'g');
53
+ open.lastIndex = from;
54
+ const found = open.exec(source);
55
+ if (found === null) return undefined;
56
+ const line = found.index + 1;
57
+ const start = found.index + found[0].length;
45
58
  const end = source.indexOf(']', start);
46
59
  if (end === -1) return undefined;
47
60
  const items = source
@@ -49,7 +62,7 @@ const listOf = (
49
62
  .split(',')
50
63
  .map((item) => item.trim())
51
64
  .filter((item) => item.length > 0);
52
- return { start, end, items };
65
+ return { line, start, end, items };
53
66
  };
54
67
 
55
68
  /**
@@ -68,12 +81,12 @@ export function insertApiEntries(
68
81
  for (const entry of entries) {
69
82
  const importLine = `import * as ${entry.binding} from '${entry.specifier}';`;
70
83
  const call = next.indexOf('defineApi({');
71
- const actions = listOf(next, 'actions');
84
+ const actions = call === -1 ? undefined : listOf(next, 'actions', call);
72
85
  if (call === -1 || actions === undefined) {
73
86
  skipped.push(entry);
74
87
  continue;
75
88
  }
76
- const list = listOf(next, entry.key);
89
+ const list = listOf(next, entry.key, call);
77
90
  if (list?.items.includes(entry.binding) === true) continue;
78
91
  if (list === undefined) {
79
92
  // After `actions: [...],` — the order `x new` writes: actions, queries, jobs, tasks.
@@ -82,10 +95,9 @@ export function insertApiEntries(
82
95
  next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
83
96
  } else {
84
97
  const items = [...list.items, entry.binding];
85
- const lineStart = next.lastIndexOf('\n', list.start) + 1;
86
- const indent = /^\s*/.exec(next.slice(lineStart))?.[0] ?? '';
98
+ const indent = /^[ \t]*/.exec(next.slice(list.line))?.[0] ?? '';
87
99
  const rewritten = wrapList(indent, `${entry.key}: [`, items, ']');
88
- next = `${next.slice(0, lineStart)}${rewritten}${next.slice(list.end + 1)}`;
100
+ next = `${next.slice(0, list.line)}${rewritten}${next.slice(list.end + 1)}`;
89
101
  }
90
102
  if (!next.includes(importLine)) next = withImport(next, importLine);
91
103
  }
package/src/cmd-dev.ts CHANGED
@@ -223,6 +223,7 @@ async function bootDev(
223
223
  storage: runtime.storage,
224
224
  dashboard,
225
225
  islands: () => state.islands,
226
+ realtime: runtime.realtime,
226
227
  });
227
228
 
228
229
  // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
@@ -2,6 +2,7 @@
2
2
  // names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
3
3
  // 500-line ceiling; `serve.ts` composes the production table from the same builders.
4
4
 
5
+ import type { RealtimeConfig } from '@ultimat3/core';
5
6
  import type { Route } from '@ultimat3/http';
6
7
  import { describeRoutes } from '@ultimat3/render';
7
8
  import type { Storage } from '@ultimat3/storage';
@@ -19,6 +20,7 @@ import { loadPwaArtifacts } from './pwa-artifacts';
19
20
  import { assetRoutes } from './runtime-assets';
20
21
  import { appRoutes } from './runtime-render';
21
22
  import { servedStorage, storageRoutes } from './runtime-storage';
23
+ import { seoRoutes } from './seo-routes';
22
24
  import { styleBundle } from './style-bundle';
23
25
  import { styleRoutes } from './style-routes';
24
26
  import { serviceWorkerArtifacts } from './sw-artifacts';
@@ -34,6 +36,8 @@ export interface DevRouteTableInput {
34
36
  readonly dashboard: DevDashboardInput;
35
37
  /** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
36
38
  readonly islands: () => IslandBundle;
39
+ /** `app.config.ts`'s `realtime`, as the boot obeyed it: off, no document names a sync node. */
40
+ readonly realtime: Pick<RealtimeConfig, 'enabled'>;
37
41
  }
38
42
 
39
43
  export interface DevRouteTable {
@@ -52,7 +56,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
52
56
  const pwa = await loadPwaArtifacts(input.root);
53
57
  const theme = themeBoot(await loadThemeMode(input.root));
54
58
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
55
- const sync = await pageSync(input.root, input.env, input.buildId);
59
+ const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
56
60
  const errorStyles = await errorPageStyleSources(input.root);
57
61
  // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
58
62
  // changes under a page it controls is the update path, and one per keystroke exercises it per save.
@@ -96,6 +100,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
96
100
  // reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
97
101
  // captured at boot would answer 404 for the href the document now carries.
98
102
  ...styleRoutes(() => styleBundle()),
103
+ // `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
104
+ ...seoRoutes({ env: input.env }),
99
105
  // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
100
106
  // lives here rather than in a second server because everything it needs is in THIS process:
101
107
  // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
@@ -110,7 +116,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
110
116
  ...appRoutes({
111
117
  buildId: input.buildId,
112
118
  resolveIsland: (file) => input.islands().resolverFor(file),
113
- sync: sync.head,
119
+ ...(sync.head === undefined ? {} : { sync: sync.head }),
114
120
  persisted: sync.persisted,
115
121
  themeHead: theme.head,
116
122
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
package/src/index.ts CHANGED
@@ -145,6 +145,8 @@ export {
145
145
  serveApp,
146
146
  } from './serve';
147
147
  export { quoteArg } from './shell-quote';
148
+ export type { SiteSeo, SiteSeoOptions } from './site-seo';
149
+ export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
148
150
  export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
149
151
  export type { SkippedRoute, StaticReport } from './static-report';
150
152
  export { parseStaticReport } from './static-report';
package/src/page-sync.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // `ultimate-build`, `ultimate-sync-worker`, the boot script), and the persisted record types a
4
4
  // private document names. One call from both boots, so the two cannot serve different targets.
5
5
 
6
+ import type { RealtimeConfig } from '@ultimat3/core';
6
7
  import { persistedRecordTypes } from '@ultimat3/entity';
7
8
  import type { Route } from '@ultimat3/http';
8
9
  import type { ClientSyncHead } from '@ultimat3/render';
@@ -19,7 +20,11 @@ export interface PageSync {
19
20
  readonly routes: readonly Route[];
20
21
  /** The scripts those routes serve, for the service worker to precache beside the islands. */
21
22
  readonly scripts: readonly FrameworkScript[];
22
- readonly head: ClientSyncHead;
23
+ /**
24
+ * `undefined` when `realtime.enabled` is false: no node is started (`role-realtime.ts`), so a
25
+ * document naming one would hand the page runtime a target nothing serves.
26
+ */
27
+ readonly head: ClientSyncHead | undefined;
23
28
  /**
24
29
  * The record types the app persists, read per render off the entity registry — the app's modules
25
30
  * register their entities during boot, so a value captured here could predate them.
@@ -36,7 +41,13 @@ export async function pageSync(
36
41
  root: string,
37
42
  env: Readonly<Record<string, string | undefined>>,
38
43
  buildId: string,
44
+ realtime: Pick<RealtimeConfig, 'enabled'>,
39
45
  ): Promise<PageSync> {
46
+ // Off: no `ultimate-sync`, no worker, no boot script — the boot is the outbox and the disk
47
+ // restore, which exist to feed the socket. Checked before either build, which would be wasted.
48
+ if (!realtime.enabled) {
49
+ return { routes: [], scripts: [], head: undefined, persisted: persistedRecordTypes };
50
+ }
40
51
  const syncUrl = syncUrlFrom(env);
41
52
  const worker = await buildSyncWorker(root);
42
53
  const boot = await buildPageBoot(root);
@@ -0,0 +1,67 @@
1
+ // `GET /robots.txt` and `GET /sitemap.xml` from a running web role — the files the static export
2
+ // writes, answered by the process for a deploy that serves its pages from a container. Mounted by
3
+ // `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
4
+
5
+ import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
6
+ import type { Route, UltimateRequest } from '@ultimat3/http';
7
+ import { applyCacheHeaders } from '@ultimat3/http';
8
+ import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
9
+
10
+ export interface SeoRoutesOptions {
11
+ readonly env: Readonly<Record<string, string | undefined>>;
12
+ }
13
+
14
+ /**
15
+ * The public origin: `APP_URL`, the one the framework's runtime already names for it (OAuth's
16
+ * redirect, the sync node's admitted origin); else `SITE_ORIGIN`, the static build's; else the
17
+ * request's own. A container behind an ingress sees its pod address as the request's host, which
18
+ * is why the declared origin comes first — a sitemap of `http://10.0.0.7:3000/…` indexes nothing.
19
+ */
20
+ function originOf(env: SeoRoutesOptions['env'], request: UltimateRequest): string {
21
+ for (const key of ['APP_URL', 'SITE_ORIGIN']) {
22
+ const declared = env[key]?.trim() ?? '';
23
+ if (declared !== '') return declared.replace(/\/+$/, '');
24
+ }
25
+ return new URL(request.url).origin;
26
+ }
27
+
28
+ /**
29
+ * Per request, never cached across one: `prerender()` may enumerate rows that change, and a
30
+ * crawler asks for these a handful of times a day. An hour of shared cache is what a CDN keeps.
31
+ */
32
+ const SEO_CACHE = { mode: 'public', maxAgeSeconds: 3600 } as const;
33
+
34
+ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
35
+ const environment: Environment =
36
+ tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
37
+ const answer = async (request: UltimateRequest) =>
38
+ await siteSeo({ baseUrl: originOf(options.env, request), environment });
39
+
40
+ return [
41
+ {
42
+ method: 'GET',
43
+ path: ROBOTS_PATH,
44
+ meta: { name: 'seo.robots', auth: 'public', tags: ['seo'] },
45
+ handler: async (request: UltimateRequest): Promise<Response> =>
46
+ applyCacheHeaders(
47
+ new Response((await answer(request)).robots, {
48
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
49
+ }),
50
+ SEO_CACHE,
51
+ ),
52
+ },
53
+ {
54
+ method: 'GET',
55
+ path: SITEMAP_PATH,
56
+ meta: { name: 'seo.sitemap', auth: 'public', tags: ['seo'] },
57
+ // The first file is `/sitemap.xml` in both shapes: the whole urlset, or the index of parts.
58
+ handler: async (request: UltimateRequest): Promise<Response> =>
59
+ applyCacheHeaders(
60
+ new Response((await answer(request)).sitemaps[0]?.xml ?? '', {
61
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
62
+ }),
63
+ SEO_CACHE,
64
+ ),
65
+ },
66
+ ];
67
+ }
package/src/serve-boot.ts CHANGED
@@ -25,6 +25,7 @@ import { appRoutes } from './runtime-render';
25
25
  import { replicaOverrides } from './runtime-replica';
26
26
  import type { RunningServices } from './runtime-services';
27
27
  import { servedStorage, storageRoutes } from './runtime-storage';
28
+ import { seoRoutes } from './seo-routes';
28
29
  import { loadDrainConfig } from './serve-drain';
29
30
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
30
31
  import type { ServedApp, ServeOptions } from './serve-types';
@@ -139,7 +140,7 @@ async function webSurface(
139
140
  const theme = themeBoot(await loadThemeMode(options.root));
140
141
  // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
141
142
  // Before the service worker, which precaches those scripts.
142
- const sync = await pageSync(options.root, options.env, buildId);
143
+ const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
143
144
  // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
144
145
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
145
146
  // route added here cannot be missing from the precache manifest.
@@ -173,12 +174,14 @@ async function webSurface(
173
174
  // The surface stylesheets the documents link. Built from the registry the `loadApp` above
174
175
  // filled, so this process serves exactly the CSS it renders against.
175
176
  ...styleRoutes(() => styleBundle()),
177
+ // `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
178
+ ...seoRoutes({ env: options.env }),
176
179
  // The page's one socket: its worker script, served beside the islands for their reason.
177
180
  ...sync.routes,
178
181
  ...appRoutes({
179
182
  buildId,
180
183
  resolveIsland: (file) => islands.resolverFor(file),
181
- sync: sync.head,
184
+ ...(sync.head === undefined ? {} : { sync: sync.head }),
182
185
  persisted: sync.persisted,
183
186
  themeHead: theme.head,
184
187
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
@@ -0,0 +1,78 @@
1
+ // `robots.txt` and `sitemap.xml`, off the route table: ONE answer for the static export
2
+ // (`apps/web/prerender.ts` writes it into the artifact) and the running web role (`seo-routes.ts`
3
+ // serves it), so a crawler reads the same two files from a CDN and from a container.
4
+
5
+ import type { Environment } from '@ultimat3/core';
6
+ import type { RouteEntry } from '@ultimat3/render';
7
+ import { routeEntries } from '@ultimat3/render';
8
+ import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
9
+ import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
10
+ import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
11
+ import { readSiteMeta } from './seo-meta';
12
+
13
+ export const ROBOTS_PATH = '/robots.txt';
14
+ export const SITEMAP_PATH = '/sitemap.xml';
15
+
16
+ export interface SiteSeoOptions {
17
+ /** The public origin every `<loc>` and the `Sitemap:` line are absolute against. */
18
+ readonly baseUrl: string;
19
+ /** Omitted: `ULTIMATE_ENV`, read by `@ultimat3/seo` — anything but `production` disallows. */
20
+ readonly environment?: Environment | undefined;
21
+ /**
22
+ * The concrete pages a build EMITTED for a dynamic route. The static export passes its report's,
23
+ * so the sitemap cannot name a page the artifact lacks; absent, `prerender()` is asked — which is
24
+ * the same list, enumerated by the same function the prerenderer calls.
25
+ */
26
+ readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
27
+ }
28
+
29
+ export interface SiteSeo {
30
+ readonly robots: string;
31
+ /** Every sitemap file: `/sitemap.xml` alone, or the index at that path first and its parts after. */
32
+ readonly sitemaps: readonly SitemapFile[];
33
+ }
34
+
35
+ /** A `site/` page anyone may fetch. A policy on a `site/` route makes it not public, whatever the surface. */
36
+ const isPublicSite = (entry: RouteEntry): boolean =>
37
+ entry.surface === 'site' && entry.config.policy === undefined;
38
+
39
+ const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<readonly string[]> => {
40
+ if (options.pagesFor !== undefined) return options.pagesFor(entry.path);
41
+ const params = await enumeratePrerender(entry);
42
+ return params.map((set) => fillPath(entry.pattern.source, set));
43
+ };
44
+
45
+ /**
46
+ * The public `site/` routes as `@ultimat3/seo` reads them. `meta` is carried where it resolves
47
+ * without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
48
+ * keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
49
+ */
50
+ async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly RouteRecord[]> {
51
+ const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
52
+ return routeEntries()
53
+ .filter(isPublicSite)
54
+ .map((entry) => {
55
+ const meta = metaByPath.get(entry.path);
56
+ return {
57
+ path: entry.path,
58
+ file: entry.file,
59
+ surface: 'site' as const,
60
+ render: entry.config.render,
61
+ ...(meta === undefined ? {} : { meta }),
62
+ ...(isDynamic(entry.path) ? { prerender: () => pagesOf(entry, options) } : {}),
63
+ };
64
+ });
65
+ }
66
+
67
+ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
68
+ const sitemap = await buildSitemap(await publicSiteRoutes(options), { baseUrl: options.baseUrl });
69
+ // Past 50,000 URLs `files` are `/sitemap-N.xml` and the index is `/sitemap.xml`; below it,
70
+ // `files` is that one file and there is no index.
71
+ const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
72
+ const robots = buildRobots({
73
+ baseUrl: options.baseUrl,
74
+ sitemaps: [SITEMAP_PATH],
75
+ ...(options.environment === undefined ? {} : { environment: options.environment }),
76
+ });
77
+ return { robots, sitemaps };
78
+ }
@@ -65,9 +65,7 @@ const prerender =
65
65
  // landed on disk — which is what \`x build --target static --json\` reads back.
66
66
 
67
67
  import { join } from 'node:path';
68
- import { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite } from '@ultimat3/cli';
69
- import { routeEntries } from '@ultimat3/render';
70
- import { buildRobots, buildSitemap, type RouteRecord } from '@ultimat3/seo';
68
+ import { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
71
69
 
72
70
  const root = join(import.meta.dir, '..', '..');
73
71
  const flag = Bun.argv.indexOf('--out');
@@ -79,42 +77,28 @@ const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', '
79
77
  const origin = Bun.env.SITE_ORIGIN;
80
78
 
81
79
  /**
82
- * The route table as \`@ultimat3/seo\` reads it. \`RouteRecord\` is a static row and \`defineRoute\`
83
- * is a live declaration, so one has to be projected onto the other — and \`prerenderSite\` has
84
- * already loaded the app by the time this runs, which is what fills \`routeEntries()\`.
80
+ * \`sitemap.xml\` and \`robots.txt\`, into the same directory the HTML went: a static export is
81
+ * served with no process behind it, so the CDN needs the files. \`siteSeo\` is the same answer a
82
+ * running web role serves at \`/sitemap.xml\` and \`/robots.txt\` — the public \`site/\` routes, a
83
+ * page whose \`meta\` says \`robots: { index: false }\` left out — so a crawler reads one sitemap
84
+ * from the CDN and from the container.
85
85
  *
86
- * A DYNAMIC route contributes exactly the URLs this build enumerated for it, read back off the
87
- * report rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page
88
- * the artifact does not contain, which is the only failure mode a sitemap really has.
89
- */
90
- const siteRoutes = (report: PrerenderReport): readonly RouteRecord[] =>
91
- routeEntries()
92
- .filter((entry) => entry.surface === 'site')
93
- .map((entry) => ({
94
- path: entry.path,
95
- file: entry.file,
96
- surface: 'site' as const,
97
- render: entry.config.render,
98
- prerender: () =>
99
- report.pages.filter((page) => page.route === entry.path).map((page) => page.path),
100
- }));
101
-
102
- /**
103
- * \`sitemap.xml\` and \`robots.txt\`, into the same directory the HTML went. Both belong to the
104
- * ARTIFACT rather than to a request: a static export is served with no process behind it, so a
105
- * route that answered them at run time would be a file the CDN never has.
86
+ * A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report
87
+ * rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page the
88
+ * artifact does not contain.
106
89
  *
107
- * \`buildRobots\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
90
+ * \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
108
91
  * \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
109
92
  */
110
93
  async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<readonly string[]> {
111
- const sitemap = await buildSitemap(siteRoutes(report), { baseUrl });
112
- // Past 50,000 URLs \`files\` are \`/sitemap-N.xml\` and the index is \`/sitemap.xml\`; below it,
113
- // \`files\` is that one file and there is no index. Writing both lists covers each case once.
114
- const written = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
115
- for (const file of written) await Bun.write(join(out, file.path), file.xml);
116
- await Bun.write(join(out, 'robots.txt'), buildRobots({ baseUrl, sitemaps: ['/sitemap.xml'] }));
117
- return [...written.map((file) => file.path), '/robots.txt'];
94
+ const seo = await siteSeo({
95
+ baseUrl,
96
+ pagesFor: (route) =>
97
+ report.pages.filter((page) => page.route === route).map((page) => page.path),
98
+ });
99
+ for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
100
+ await Bun.write(join(out, 'robots.txt'), seo.robots);
101
+ return [...seo.sitemaps.map((file) => file.path), '/robots.txt'];
118
102
  }
119
103
 
120
104
  if (import.meta.main) {