@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 +28 -28
- package/src/api-registration.ts +23 -11
- package/src/cmd-dev.ts +1 -0
- package/src/dev-route-table.ts +8 -2
- package/src/index.ts +2 -0
- package/src/page-sync.ts +12 -1
- package/src/seo-routes.ts +67 -0
- package/src/serve-boot.ts +5 -2
- package/src/site-seo.ts +78 -0
- package/src/templates/scaffold-entries.ts +18 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.2.
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.2.
|
|
43
|
-
"@ultimat3/ai": "22.2.
|
|
44
|
-
"@ultimat3/auth": "22.2.
|
|
45
|
-
"@ultimat3/cache": "22.2.
|
|
46
|
-
"@ultimat3/core": "22.2.
|
|
47
|
-
"@ultimat3/db": "22.2.
|
|
48
|
-
"@ultimat3/entity": "22.2.
|
|
49
|
-
"@ultimat3/flags": "22.2.
|
|
50
|
-
"@ultimat3/http": "22.2.
|
|
51
|
-
"@ultimat3/i18n": "22.2.
|
|
52
|
-
"@ultimat3/jobs": "22.2.
|
|
53
|
-
"@ultimat3/mail": "22.2.
|
|
54
|
-
"@ultimat3/manifest": "22.2.
|
|
55
|
-
"@ultimat3/mcp": "22.2.
|
|
56
|
-
"@ultimat3/money": "22.2.
|
|
57
|
-
"@ultimat3/notify": "22.2.
|
|
58
|
-
"@ultimat3/policy": "22.2.
|
|
59
|
-
"@ultimat3/pwa": "22.2.
|
|
60
|
-
"@ultimat3/query": "22.2.
|
|
61
|
-
"@ultimat3/realtime": "22.2.
|
|
62
|
-
"@ultimat3/render": "22.2.
|
|
63
|
-
"@ultimat3/schema": "22.2.
|
|
64
|
-
"@ultimat3/seo": "22.2.
|
|
65
|
-
"@ultimat3/storage": "22.2.
|
|
66
|
-
"@ultimat3/testing": "22.2.
|
|
67
|
-
"@ultimat3/time": "22.2.
|
|
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
|
}
|
package/src/api-registration.ts
CHANGED
|
@@ -34,14 +34,27 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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,
|
|
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
package/src/dev-route-table.ts
CHANGED
|
@@ -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
|
-
|
|
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 ?? '') }),
|
package/src/site-seo.ts
ADDED
|
@@ -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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
* \`
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await Bun.write(join(out,
|
|
117
|
-
|
|
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) {
|