@ultimat3/cli 1.0.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// `x g query <name> [--live]` — a read. Live queries must be deterministic and bounded, so the
|
|
2
|
+
// generated declaration always carries `orderBy` + `limit` and the generated test pins them:
|
|
3
|
+
// an unbounded live query is a memory leak that only shows up under load.
|
|
4
|
+
|
|
5
|
+
import type { FeatureTarget } from './entity';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
|
|
9
|
+
/** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
|
|
10
|
+
const cacheLine = (feature: NameSet, live: boolean): string =>
|
|
11
|
+
live ? '' : `\n cache: { tags: [${feature.camel}Tag], ttlMs: 30_000 },`;
|
|
12
|
+
|
|
13
|
+
const querySource = (
|
|
14
|
+
name: NameSet,
|
|
15
|
+
feature: NameSet,
|
|
16
|
+
live: boolean,
|
|
17
|
+
): string => `// ${name.camel}: a ${live ? 'live (subscribable)' : 'one-shot'} read over ${feature.pluralKebab}.
|
|
18
|
+
// Bounded and ordered — required for${live ? ' live queries' : ' predictable pagination'}.
|
|
19
|
+
// \`t\` comes from @ultimat3/query, not @ultimat3/schema: a query file imports one package.
|
|
20
|
+
|
|
21
|
+
import { from, query, t } from '@ultimat3/query';
|
|
22
|
+
import type { ${feature.pascal} } from '../entity';
|
|
23
|
+
import { can${feature.pascal}Read${live ? '' : `, ${feature.camel}Tag`} } from '../policy';
|
|
24
|
+
import * as repo from '../repo';
|
|
25
|
+
|
|
26
|
+
export const ${name.camel} = query({
|
|
27
|
+
input: t.object({ orgId: t.uuid, limit: t.number.default(50) }),
|
|
28
|
+
policy: can${feature.pascal}Read,
|
|
29
|
+
live: ${String(live)},${cacheLine(feature, live)}
|
|
30
|
+
// Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
|
|
31
|
+
mcp: { expose: true, description: '${name.raw} — generated, edit the description' },
|
|
32
|
+
sql: ({ orgId, limit }) =>
|
|
33
|
+
// \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
|
|
34
|
+
// and the entity created the table as snake_case.
|
|
35
|
+
from<${feature.pascal}>('${feature.table}', () => repo.listByOrg(orgId, limit))
|
|
36
|
+
.where({ orgId })
|
|
37
|
+
.orderBy('createdAt')
|
|
38
|
+
// The primary key last is what makes the order TOTAL: \`createdAt\` alone ties, and two
|
|
39
|
+
// rows that tie can swap between evaluations — a bounded read then drops one and repeats
|
|
40
|
+
// the other, and a live subscription patches a row it never sent.
|
|
41
|
+
.orderBy('id')
|
|
42
|
+
.limit(limit),
|
|
43
|
+
});
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const queryTest = (name: NameSet, feature: NameSet, live: boolean): string => {
|
|
47
|
+
const wrapper = live ? 'liveTest' : 'unitTest';
|
|
48
|
+
return `import { testActor } from '@ultimat3/policy';
|
|
49
|
+
import { sourceFor } from '@ultimat3/query';
|
|
50
|
+
import { expect, ${wrapper} } from '@ultimat3/testing';
|
|
51
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
52
|
+
|
|
53
|
+
// A real v4 uuid: the read parses its input the way a request does, so a placeholder that only
|
|
54
|
+
// looks like a uuid would fail before it ever built any SQL.
|
|
55
|
+
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
56
|
+
|
|
57
|
+
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
58
|
+
// At boot \`registerQueries(await import('./${live ? 'live' : 'queries'}'))\` stamps the same name
|
|
59
|
+
// onto the same object.
|
|
60
|
+
const target = ${name.camel}.named('${name.camel}');
|
|
61
|
+
|
|
62
|
+
// Holds the grant, wrong org — so a denial here is the predicate deciding, not the grant.
|
|
63
|
+
const outsider = testActor('outsider', {
|
|
64
|
+
orgId: '00000000-0000-4000-8000-000000000009',
|
|
65
|
+
permissions: ['${feature.kebab}:read'],
|
|
66
|
+
}).actor;
|
|
67
|
+
|
|
68
|
+
${wrapper}('${name.camel} is a declared ${live ? 'live ' : ''}query', () => {
|
|
69
|
+
expect(target.kind).toBe('query');
|
|
70
|
+
expect(target.isLive).toBe(${String(live)});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
74
|
+
// The SQL text is the contract an agent reads to self-correct, so assert on it, not on a
|
|
75
|
+
// shape. \`sourceFor\` is the one read path — it parses the input and builds the source exactly
|
|
76
|
+
// as a request does. \`actor: null\` gives the call a context of its own rather than borrowing
|
|
77
|
+
// an ambient one, and \`enforce: false\` leaves the policy to the test below.
|
|
78
|
+
const source = await sourceFor(target, { orgId, limit: 50 }, { actor: null, enforce: false });
|
|
79
|
+
const { sql } = source.toSQL();
|
|
80
|
+
const text = sql.toLowerCase();
|
|
81
|
+
expect(text).toContain('order by');
|
|
82
|
+
expect(text).toContain('limit');
|
|
83
|
+
// "ordered" is not enough. Dropping the id tiebreak still leaves an ORDER BY, so asserting on
|
|
84
|
+
// its presence alone would keep passing while the read went non-deterministic under ties.
|
|
85
|
+
expect(text.slice(text.lastIndexOf('order by'))).toContain('id');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
${wrapper}('${name.camel} denies a foreign org before it reads a row', async () => {
|
|
89
|
+
// \`.as()\` is the one read path with the actor swapped: validate, authorize, then read. The
|
|
90
|
+
// denial lands before any SQL executes, which is why this needs no database.
|
|
91
|
+
const denied = await target.as(outsider, { orgId, limit: 50 }).catch((error: unknown) => error);
|
|
92
|
+
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
${wrapper}('${name.camel} exposes one MCP tool that reads, and never writes', () => {
|
|
96
|
+
// Same policy object on both surfaces — an agent cannot reach a different authz path.
|
|
97
|
+
expect(target.tool().policy).toBe(target.policy);
|
|
98
|
+
expect(target.tool().mutates).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export interface QueryOptions extends FeatureTarget {
|
|
104
|
+
readonly live?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function queryFiles(rawName: string, target: QueryOptions): readonly GeneratedFile[] {
|
|
108
|
+
const name = names(rawName);
|
|
109
|
+
const feature = names(target.feature);
|
|
110
|
+
const live = target.live === true;
|
|
111
|
+
const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
|
|
112
|
+
return [
|
|
113
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
|
|
114
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: queryTest(name, feature, live) },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// `x g resource <name>` — the whole feature slice in one command: entity, repo, service, policy,
|
|
2
|
+
// two actions, a live list query, a UI component and a route, each with a passing test. This is
|
|
3
|
+
// the blessed path; the individual generators exist for adding to a slice that already exists.
|
|
4
|
+
|
|
5
|
+
import { actionFiles } from './action';
|
|
6
|
+
import { adminFiles } from './admin';
|
|
7
|
+
import { catalogJson } from './catalog-json';
|
|
8
|
+
import type { FeatureTarget } from './entity';
|
|
9
|
+
import { entityFiles } from './entity';
|
|
10
|
+
import { jobFiles } from './job';
|
|
11
|
+
import { catalogPath, resolveLocales } from './locales';
|
|
12
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
13
|
+
import { names, pascal } from './naming';
|
|
14
|
+
import { policyFiles } from './policy';
|
|
15
|
+
import { queryFiles } from './query';
|
|
16
|
+
import { routeFiles } from './route';
|
|
17
|
+
|
|
18
|
+
const serviceSource = (
|
|
19
|
+
feature: NameSet,
|
|
20
|
+
): string => `// Business logic for ${feature.pluralKebab}. Knows nothing about HTTP or requests, so a job and an
|
|
21
|
+
// action can both call it. Takes values, not a request.
|
|
22
|
+
|
|
23
|
+
import type { ${feature.pascal} } from './entity';
|
|
24
|
+
import { ${feature.pascal}NotFoundError } from './errors';
|
|
25
|
+
import * as repo from './repo';
|
|
26
|
+
|
|
27
|
+
/** Derived from the row, never restated: a new column reaches this input without an edit here. */
|
|
28
|
+
export type Create${feature.pascal}Input = Omit<${feature.pascal}, 'id' | 'createdAt'>;
|
|
29
|
+
|
|
30
|
+
export async function create(input: Create${feature.pascal}Input): Promise<${feature.pascal}> {
|
|
31
|
+
return repo.insert(input);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function require${feature.pascal}(id: string): Promise<${feature.pascal}> {
|
|
35
|
+
const row = await repo.byId(id);
|
|
36
|
+
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id });
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const serviceTest = (
|
|
42
|
+
feature: NameSet,
|
|
43
|
+
): string => `import { expect, unitTest } from '@ultimat3/testing';
|
|
44
|
+
import { ${feature.pascal}NotFoundError } from './errors';
|
|
45
|
+
|
|
46
|
+
unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', () => {
|
|
47
|
+
const error = new ${feature.pascal}NotFoundError({ id: 'missing' });
|
|
48
|
+
expect(error).toBeUltimateError('X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND');
|
|
49
|
+
expect(error.cause).toContain('missing');
|
|
50
|
+
expect(error.fix.length).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const uiSource = (
|
|
55
|
+
feature: NameSet,
|
|
56
|
+
): string => `// Presentation only. No fetching, no business logic: the list arrives as a prop from the route,
|
|
57
|
+
// which got it from the live query.
|
|
58
|
+
|
|
59
|
+
import { t } from '@ultimat3/i18n';
|
|
60
|
+
import { For } from 'solid-js';
|
|
61
|
+
import type { ${feature.pascal} } from './entity';
|
|
62
|
+
import styles from './ui.module.scss';
|
|
63
|
+
|
|
64
|
+
export interface ${feature.pascal}ListProps {
|
|
65
|
+
readonly rows: readonly ${feature.pascal}[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
|
|
69
|
+
return (
|
|
70
|
+
<ul class={styles.list}>
|
|
71
|
+
<For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
|
|
72
|
+
{/* The item arrives as an accessor: reading it inside the row is what keeps the update
|
|
73
|
+
surgical instead of re-rendering the list. */}
|
|
74
|
+
{(row) => <li class={styles.item}>{row().title}</li>}
|
|
75
|
+
</For>
|
|
76
|
+
</ul>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
82
|
+
|
|
83
|
+
.list {
|
|
84
|
+
display: grid;
|
|
85
|
+
gap: tokens.$space-2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.item {
|
|
89
|
+
padding: tokens.$space-2;
|
|
90
|
+
border-radius: tokens.$radius-sm;
|
|
91
|
+
background: tokens.$surface-raised;
|
|
92
|
+
color: tokens.$text-primary;
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const cardSource = (
|
|
97
|
+
feature: NameSet,
|
|
98
|
+
): string => `// One ${feature.camel} rendered on its own — the list's \`item\` shown outside a list, so a
|
|
99
|
+
// detail route and a search result render the identical markup.
|
|
100
|
+
|
|
101
|
+
import { t } from '@ultimat3/i18n';
|
|
102
|
+
import type { ${feature.pascal} } from '../entity';
|
|
103
|
+
import styles from '../ui.module.scss';
|
|
104
|
+
|
|
105
|
+
export interface ${feature.pascal}CardProps {
|
|
106
|
+
readonly row: ${feature.pascal};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
|
|
110
|
+
return (
|
|
111
|
+
<article class={styles.item}>
|
|
112
|
+
<h3>{props.row.title}</h3>
|
|
113
|
+
<p>{t('app.${feature.kebab}.updated')}</p>
|
|
114
|
+
</article>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const formSource = (
|
|
120
|
+
feature: NameSet,
|
|
121
|
+
): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
|
|
122
|
+
// never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
|
|
123
|
+
|
|
124
|
+
import { t } from '@ultimat3/i18n';
|
|
125
|
+
import { createSignal } from 'solid-js';
|
|
126
|
+
import styles from '../ui.module.scss';
|
|
127
|
+
|
|
128
|
+
export interface ${feature.pascal}FormProps {
|
|
129
|
+
readonly onSubmit: (title: string) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
|
|
133
|
+
const [title, setTitle] = createSignal('');
|
|
134
|
+
return (
|
|
135
|
+
<form
|
|
136
|
+
class={styles.item}
|
|
137
|
+
onSubmit={(event) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
props.onSubmit(title());
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<label>
|
|
143
|
+
{t('app.${feature.kebab}.titleLabel')}
|
|
144
|
+
<input value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
|
|
145
|
+
</label>
|
|
146
|
+
<button type="submit">{t('app.${feature.kebab}.submit')}</button>
|
|
147
|
+
</form>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
// `admin.<feature>.title` is always here, `--admin` or not: `defineAdmin()` resolves that key the
|
|
153
|
+
// moment anyone writes the override, and a missing key renders ⟦key⟧ and fails the i18n gate,
|
|
154
|
+
// while an unused key is only ever reported (`auditCatalogs` fails on `missing`, never `unused`).
|
|
155
|
+
const catalogSource = (feature: NameSet): string =>
|
|
156
|
+
catalogJson({
|
|
157
|
+
[`app.${feature.kebab}.empty`]: `No ${feature.pluralKebab} yet.`,
|
|
158
|
+
[`app.${feature.kebab}.updated`]: 'Last updated',
|
|
159
|
+
[`app.${feature.kebab}.titleLabel`]: 'Title',
|
|
160
|
+
[`app.${feature.kebab}.submit`]: 'Save',
|
|
161
|
+
[`admin.${feature.kebab}.title`]: pascal(feature.plural),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export interface ResourceOptions extends FeatureTarget {
|
|
165
|
+
/** `x g resource post --admin` — also emits the per-entity admin override. */
|
|
166
|
+
readonly admin?: boolean;
|
|
167
|
+
/** Every locale the feature's catalog ships for. Defaults to `['en']`. */
|
|
168
|
+
readonly locales?: readonly string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resourceFiles(rawName: string, target: ResourceOptions): readonly GeneratedFile[] {
|
|
172
|
+
const feature = names(rawName);
|
|
173
|
+
const slice: FeatureTarget = { surfaceDir: target.surfaceDir, feature: feature.kebab };
|
|
174
|
+
const dir = `${slice.surfaceDir}/${slice.feature}`;
|
|
175
|
+
const locales = resolveLocales(target.locales);
|
|
176
|
+
return [
|
|
177
|
+
...entityFiles(rawName, slice),
|
|
178
|
+
...policyFiles(rawName, slice),
|
|
179
|
+
...actionFiles(`create-${feature.kebab}`, slice),
|
|
180
|
+
...actionFiles(`archive-${feature.kebab}`, slice),
|
|
181
|
+
...queryFiles(`${feature.camel}List`, { ...slice, live: true }),
|
|
182
|
+
...jobFiles(`reindex-${feature.kebab}`, slice),
|
|
183
|
+
{ path: `${dir}/service.ts`, contents: serviceSource(feature) },
|
|
184
|
+
{ path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
|
|
185
|
+
{ path: `${dir}/ui.tsx`, contents: uiSource(feature) },
|
|
186
|
+
{ path: `${dir}/ui.module.scss`, contents: uiStyle() },
|
|
187
|
+
{ path: `${dir}/ui/${feature.kebab}-card.tsx`, contents: cardSource(feature) },
|
|
188
|
+
{ path: `${dir}/ui/${feature.kebab}-form.tsx`, contents: formSource(feature) },
|
|
189
|
+
...locales.map((locale) => ({
|
|
190
|
+
path: catalogPath(locale),
|
|
191
|
+
contents: catalogSource(feature),
|
|
192
|
+
merge: 'json' as const,
|
|
193
|
+
})),
|
|
194
|
+
// Always an app route: a slice ships a live query, a form and actions, and `generate()`
|
|
195
|
+
// refuses `--surface site` for a resource rather than emit them behind a 0kb budget.
|
|
196
|
+
...routeFiles(feature.pluralKebab, { surface: 'app', locales }),
|
|
197
|
+
...(target.admin === true ? adminFiles(rawName, slice) : []),
|
|
198
|
+
];
|
|
199
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// `x g route <path>` — a URL, its render mode, its metadata, its offline strategy and its budget.
|
|
2
|
+
// The generated test pins metadata presence and the offline fallback: a route with no title is an
|
|
3
|
+
// SEO regression and a route with no fallback is a blank screen on a train.
|
|
4
|
+
|
|
5
|
+
import { catalogJson } from './catalog-json';
|
|
6
|
+
import { catalogPath, resolveLocales } from './locales';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
import { kebab, pascal, titleKey } from './naming';
|
|
9
|
+
|
|
10
|
+
export type Surface = 'site' | 'app';
|
|
11
|
+
|
|
12
|
+
const RENDER: Record<Surface, string> = { site: 'isr', app: 'stream' };
|
|
13
|
+
const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
|
|
14
|
+
const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
|
|
15
|
+
/** Structured, not a literal string: the route and the test that pins it read the same fact. */
|
|
16
|
+
const BUDGET: Record<Surface, { readonly js: string; readonly lcp: number }> = {
|
|
17
|
+
site: { js: '0kb', lcp: 1800 },
|
|
18
|
+
app: { js: '60kb', lcp: 2500 },
|
|
19
|
+
};
|
|
20
|
+
const budgetLiteral = (surface: Surface): string =>
|
|
21
|
+
`{ js: '${BUDGET[surface].js}', lcp: ${BUDGET[surface].lcp} }`;
|
|
22
|
+
/** `isr` without a trigger is `static` wearing a costume — @ultimat3/render rejects it at boot. */
|
|
23
|
+
const REVALIDATE: Record<Surface, string> = { site: "\n revalidate: { ttl: '1h' },", app: '' };
|
|
24
|
+
|
|
25
|
+
const routeDir = (surface: Surface, path: string): string =>
|
|
26
|
+
`apps/web/${surface}/${path
|
|
27
|
+
.split('/')
|
|
28
|
+
.filter((part) => part.length > 0)
|
|
29
|
+
.map(kebab)
|
|
30
|
+
.join('/')}`;
|
|
31
|
+
|
|
32
|
+
const pageSource = (surface: Surface, path: string): string => {
|
|
33
|
+
const name = pascal(
|
|
34
|
+
path
|
|
35
|
+
.split('/')
|
|
36
|
+
.filter((part) => part.length > 0)
|
|
37
|
+
.at(-1) ?? 'page',
|
|
38
|
+
);
|
|
39
|
+
return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
|
|
40
|
+
// strategy and budget are declarations, not runtime choices.
|
|
41
|
+
|
|
42
|
+
import { t } from '@ultimat3/i18n';
|
|
43
|
+
import { defineRoute } from '@ultimat3/render';
|
|
44
|
+
import styles from './page.module.scss';
|
|
45
|
+
|
|
46
|
+
export const config = defineRoute({
|
|
47
|
+
render: '${RENDER[surface]}',${REVALIDATE[surface]}
|
|
48
|
+
hydrate: '${HYDRATE[surface]}',
|
|
49
|
+
offline: '${OFFLINE[surface]}',
|
|
50
|
+
budget: ${budgetLiteral(surface)},
|
|
51
|
+
meta: () => ({
|
|
52
|
+
title: t('${titleKey(path)}'),
|
|
53
|
+
description: t('${titleKey(path).replace('.title', '.description')}'),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export function ${name}Page() {
|
|
58
|
+
return (
|
|
59
|
+
<main class={styles.page}>
|
|
60
|
+
<h1>{t('${titleKey(path)}')}</h1>
|
|
61
|
+
</main>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const styleSource =
|
|
68
|
+
(): string => `// Semantic tokens only — a raw hex here is a dark-theme bug and a lint failure.
|
|
69
|
+
@use '@ultimat3/ui/tokens' as tokens;
|
|
70
|
+
|
|
71
|
+
.page {
|
|
72
|
+
padding: tokens.$space-6;
|
|
73
|
+
background: tokens.$surface-base;
|
|
74
|
+
color: tokens.$text-primary;
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const routeTest = (
|
|
79
|
+
surface: Surface,
|
|
80
|
+
path: string,
|
|
81
|
+
): string => `import { e2eTest, expect, unitTest } from '@ultimat3/testing';
|
|
82
|
+
import { config } from './page';
|
|
83
|
+
|
|
84
|
+
unitTest('/${path} declares metadata', async () => {
|
|
85
|
+
expect(config.kind).toBe('route');
|
|
86
|
+
// meta() takes the route's data and always resolves — awaiting is the one shape, whether the
|
|
87
|
+
// declaration was written sync or async.
|
|
88
|
+
const meta = await config.meta({});
|
|
89
|
+
expect(meta.title ?? '').not.toBe('');
|
|
90
|
+
expect(meta.description ?? '').not.toBe('');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
unitTest('/${path} declares a render mode, an offline strategy and a budget', () => {
|
|
94
|
+
expect(config.render).toBe('${RENDER[surface]}');
|
|
95
|
+
expect(config.offline).toBe('${OFFLINE[surface]}');
|
|
96
|
+
// budget is always on the descriptor, so pin the number: presence cannot fail.
|
|
97
|
+
expect(config.budget.lcp).toBe(${BUDGET[surface].lcp});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
unitTest('/${path} stays inside its byte budget declaration', () => {
|
|
101
|
+
expect(config.budget.js).toBe('${BUDGET[surface].js}');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
e2eTest('/${path} renders offline from its fallback', async ({ page, offline }) => {
|
|
105
|
+
await page.goto('/${path}');
|
|
106
|
+
await offline();
|
|
107
|
+
await page.reload();
|
|
108
|
+
expect(await page.title()).not.toBe('');
|
|
109
|
+
});
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const catalogSource = (path: string): string =>
|
|
113
|
+
catalogJson({
|
|
114
|
+
[titleKey(path)]: pascal(path.split('/').at(-1) ?? 'Page'),
|
|
115
|
+
[titleKey(path).replace('.title', '.description')]: 'Describe this page for search results.',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export interface RouteOptions {
|
|
119
|
+
readonly surface: Surface;
|
|
120
|
+
/** Every locale the catalog entry ships for. Defaults to `['en']` — an app narrows or grows it. */
|
|
121
|
+
readonly locales?: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function routeFiles(rawPath: string, options: RouteOptions): readonly GeneratedFile[] {
|
|
125
|
+
const path = rawPath.replace(/^\/+/, '');
|
|
126
|
+
const dir = routeDir(options.surface, path);
|
|
127
|
+
const locales = resolveLocales(options.locales);
|
|
128
|
+
return [
|
|
129
|
+
{ path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
|
|
130
|
+
{ path: `${dir}/page.module.scss`, contents: styleSource() },
|
|
131
|
+
{ path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
|
|
132
|
+
...locales.map((locale) => ({
|
|
133
|
+
path: catalogPath(locale),
|
|
134
|
+
contents: catalogSource(path),
|
|
135
|
+
merge: 'json' as const,
|
|
136
|
+
})),
|
|
137
|
+
];
|
|
138
|
+
}
|