@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 +28 -28
- package/src/cmd-generate.ts +10 -1
- package/src/error-codes.ts +2 -0
- package/src/generate-files.ts +4 -0
- package/src/generate-reexports.ts +40 -0
- package/src/generate-slice-policy.ts +29 -0
- package/src/index.ts +2 -1
- package/src/mcp-errors.ts +2 -0
- package/src/seo-meta.ts +14 -4
- package/src/site-config.ts +2 -2
- package/src/site-seo-write.ts +16 -0
- package/src/site-seo.ts +89 -31
- package/src/templates/action.ts +112 -12
- package/src/templates/scaffold-entries.ts +14 -7
- package/src/templates/slice-foundation.ts +66 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
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
|
}
|
package/src/cmd-generate.ts
CHANGED
|
@@ -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}
|
|
335
|
+
const file = containedPath(root, `${slice}/${name}`);
|
|
327
336
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
328
337
|
}
|
|
329
338
|
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
package/src/generate-files.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/site-config.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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,
|
|
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?:
|
|
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
|
-
|
|
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
|
|
71
|
-
* copy (`/blog/a` and `/en/blog/a`), and the sitemap
|
|
72
|
-
* prefixed copies back as pages of their own listed
|
|
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 (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
|
146
|
+
const indexed = await indexability(locales);
|
|
92
147
|
return routeEntries()
|
|
93
148
|
.filter(isPublicSite)
|
|
94
|
-
.map((entry) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
//
|
|
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,
|
package/src/templates/action.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 = ['${
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|