@ouispec/cli 0.1.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 +112 -0
- package/dist/analyze.d.ts +282 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +1421 -0
- package/dist/analyze.js.map +1 -0
- package/dist/assemble.d.ts +50 -0
- package/dist/assemble.d.ts.map +1 -0
- package/dist/assemble.js +555 -0
- package/dist/assemble.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +126 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +10 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +15 -0
- package/dist/contract.js.map +1 -0
- package/dist/generate.d.ts +49 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +178 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs.d.ts +134 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +705 -0
- package/dist/inputs.js.map +1 -0
- package/dist/knowledge.d.ts +19 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +366 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/program.d.ts +60 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +302 -0
- package/dist/program.js.map +1 -0
- package/dist/recipes.d.ts +17 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/recipes.js +48 -0
- package/dist/recipes.js.map +1 -0
- package/dist/tier2.d.ts +74 -0
- package/dist/tier2.d.ts.map +1 -0
- package/dist/tier2.js +303 -0
- package/dist/tier2.js.map +1 -0
- package/package.json +62 -0
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarations the generator reads besides the page trees: each
|
|
3
|
+
* design-system package's control table, each room package's catalog, the
|
|
4
|
+
* API's operations (from its OpenAPI document), the navigation and the routes.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { agentDeclaration, catalogData, CONTROL_KINDS, controlKindRegistrationProblem, isRegisteredKind, readControlTable, registerControlKind, } from '@ouispec/bindings';
|
|
12
|
+
import { CONFIG_FILE } from './config.js';
|
|
13
|
+
import { contractFindings } from './contract.js';
|
|
14
|
+
import { constInitializer, evaluate, jsxAttribute, objectProperty, UNKNOWN, unwrap, } from './program.js';
|
|
15
|
+
/** A package's directory, found the way the app resolves it. */
|
|
16
|
+
function packageDir(root, pkg) {
|
|
17
|
+
const require = createRequire(join(root, 'package.json'));
|
|
18
|
+
for (const probe of [`${pkg}/package.json`]) {
|
|
19
|
+
try {
|
|
20
|
+
return dirname(require.resolve(probe));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Exports maps can hide package.json; walk node_modules instead.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let dir = root;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const candidate = join(dir, 'node_modules', pkg);
|
|
29
|
+
if (existsSync(join(candidate, 'package.json')))
|
|
30
|
+
return candidate;
|
|
31
|
+
const up = dirname(dir);
|
|
32
|
+
if (up === dir)
|
|
33
|
+
return null;
|
|
34
|
+
dir = up;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Whether a package is installed where the app's own `node_modules` hold it (not on a global path). */
|
|
38
|
+
function installedInApp(root, pkg) {
|
|
39
|
+
for (let dir = root;; dir = dirname(dir)) {
|
|
40
|
+
if (existsSync(join(dir, 'node_modules', pkg, 'package.json')))
|
|
41
|
+
return true;
|
|
42
|
+
if (dirname(dir) === dir)
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function packageJson(dir) {
|
|
47
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
/** A problem with what the app's settings point at, reported against `oui.config.json`. */
|
|
50
|
+
function configFinding(message) {
|
|
51
|
+
return { file: CONFIG_FILE, line: 0, message };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The components a package says only the person may use (`oui.personOnly`:
|
|
55
|
+
* export name → why), such as the approval card (ADR-0228): the assistant
|
|
56
|
+
* must never be offered a way to operate them, so the generator refuses an
|
|
57
|
+
* `agent` binding on one. Read on first use of each package the app imports;
|
|
58
|
+
* a package that declares none has none.
|
|
59
|
+
*/
|
|
60
|
+
export function personOnlyLookup(config) {
|
|
61
|
+
const cache = new Map();
|
|
62
|
+
return pkg => {
|
|
63
|
+
let found = cache.get(pkg);
|
|
64
|
+
if (!found) {
|
|
65
|
+
const dir = packageDir(config.root, pkg);
|
|
66
|
+
const declared = dir ? packageJson(dir).oui?.personOnly : undefined;
|
|
67
|
+
found = new Map(declared && typeof declared === 'object'
|
|
68
|
+
? Object.entries(declared).filter((e) => typeof e[1] === 'string' && !!e[1].trim())
|
|
69
|
+
: []);
|
|
70
|
+
cache.set(pkg, found);
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Every listed design-system package's control table. A package the app
|
|
77
|
+
* cannot resolve, one with no table, or one whose table is missing is an
|
|
78
|
+
* error: skipping it would leave its controls unread and the build green.
|
|
79
|
+
*/
|
|
80
|
+
export function loadControls(config) {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
const loaded = new Set();
|
|
83
|
+
const errors = [];
|
|
84
|
+
for (const pkg of config.designSystem) {
|
|
85
|
+
let file = config.controlTables?.[pkg];
|
|
86
|
+
if (!file) {
|
|
87
|
+
const dir = packageDir(config.root, pkg);
|
|
88
|
+
if (!dir) {
|
|
89
|
+
errors.push(configFinding(`designSystem lists "${pkg}", which cannot be resolved from the app: install it, or remove it from "designSystem"`));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const declared = agentDeclaration(pkg, packageJson(dir), 'agentControls');
|
|
93
|
+
if (declared.error) {
|
|
94
|
+
errors.push(configFinding(declared.error));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (typeof declared.value !== 'string') {
|
|
98
|
+
errors.push(configFinding(`designSystem lists "${pkg}", which declares no control table: its package.json needs "oui": { "agentControls": "<path to its control table>" }`));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const path = resolve(dir, declared.value);
|
|
102
|
+
if (!existsSync(path)) {
|
|
103
|
+
errors.push(configFinding(`"${pkg}" names ${declared.value} as its ${declared.source}, but it does not exist`));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
file = JSON.parse(readFileSync(path, 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
const before = errors.length;
|
|
109
|
+
const { controls, kinds } = readControlTable(file);
|
|
110
|
+
// The kinds the design system adds, registered before any of its controls is read.
|
|
111
|
+
const registered = new Set();
|
|
112
|
+
for (const registration of kinds) {
|
|
113
|
+
const problem = controlKindRegistrationProblem(registration);
|
|
114
|
+
if (problem) {
|
|
115
|
+
errors.push(configFinding(`"${pkg}" registers a control kind that is not valid: ${problem}`));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
registerControlKind(registration);
|
|
120
|
+
registered.add(registration.kind);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
errors.push(configFinding(`"${pkg}": ${err.message}`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const [name, descriptor] of Object.entries(controls)) {
|
|
127
|
+
const used = [
|
|
128
|
+
descriptor.kind,
|
|
129
|
+
descriptor.entries?.kind,
|
|
130
|
+
...Object.values(descriptor.slots ?? {}).map(slot => slot.kind),
|
|
131
|
+
].filter((k) => !!k);
|
|
132
|
+
const unknown = used.filter(k => (isRegisteredKind(k) ? !registered.has(k) : !CONTROL_KINDS.includes(k)));
|
|
133
|
+
if (unknown.length) {
|
|
134
|
+
for (const kind of new Set(unknown))
|
|
135
|
+
errors.push(configFinding(isRegisteredKind(kind)
|
|
136
|
+
? `"${pkg}" gives ${name} the kind ${kind}, which its $kinds do not register`
|
|
137
|
+
: `"${pkg}" gives ${name} the kind ${kind}, which is not a control kind: ${CONTROL_KINDS.join(', ')}, or an x- kind it registers`));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
out.set(`${pkg}#${name}`, descriptor);
|
|
141
|
+
}
|
|
142
|
+
// Beyond what is named above, the table must be what the contract says a
|
|
143
|
+
// control table is (`control-table.json`).
|
|
144
|
+
if (errors.length === before)
|
|
145
|
+
errors.push(...contractFindings('control-table.json', file, CONFIG_FILE, `"${pkg}"'s control table`));
|
|
146
|
+
loaded.add(pkg);
|
|
147
|
+
}
|
|
148
|
+
return { controls: out, loaded, errors };
|
|
149
|
+
}
|
|
150
|
+
/** Why an app room catalog cannot be loaded without Vite, and what to do. */
|
|
151
|
+
export const VITE_REQUIRED = 'appCatalogs are loaded through the app’s own Vite config, and vite cannot be resolved from the app: ' +
|
|
152
|
+
'add vite to its devDependencies, or ship the catalog in a package under "oui.agentCatalog"';
|
|
153
|
+
/**
|
|
154
|
+
* The room catalogs the app declares itself (`appCatalogs`), loaded through
|
|
155
|
+
* the app's Vite config and reduced to their declarations. A catalog is code
|
|
156
|
+
* (its entries may be made by functions), and its imports resolve only as
|
|
157
|
+
* the app's build resolves them, so the app's own Vite loads it.
|
|
158
|
+
*/
|
|
159
|
+
export async function loadAppCatalogs(config) {
|
|
160
|
+
if (config.appCatalogs.length === 0)
|
|
161
|
+
return [];
|
|
162
|
+
// Only the app's own vite: its config, its plugins, its resolution.
|
|
163
|
+
if (!installedInApp(config.root, 'vite'))
|
|
164
|
+
throw new Error(VITE_REQUIRED);
|
|
165
|
+
const vitePath = createRequire(join(config.root, 'package.json')).resolve('vite');
|
|
166
|
+
const vite = (await import(pathToFileURL(vitePath).href));
|
|
167
|
+
const server = await vite.createServer({
|
|
168
|
+
root: config.root,
|
|
169
|
+
logLevel: 'error',
|
|
170
|
+
appType: 'custom',
|
|
171
|
+
server: { middlewareMode: true, hmr: false, watch: null },
|
|
172
|
+
optimizeDeps: { noDiscovery: true, include: [] },
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const entry of config.appCatalogs) {
|
|
177
|
+
const file = resolve(config.root, entry.module);
|
|
178
|
+
let mod;
|
|
179
|
+
try {
|
|
180
|
+
mod = (await server.ssrLoadModule(file));
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw new Error(`${entry.module} could not be loaded through the app’s Vite config: ${err.message}`, { cause: err });
|
|
184
|
+
}
|
|
185
|
+
const catalog = mod[entry.export];
|
|
186
|
+
if (!catalog || typeof catalog !== 'object' || !Array.isArray(catalog.actions)) {
|
|
187
|
+
throw new Error(`${entry.module} has no room catalog exported as ${entry.export}`);
|
|
188
|
+
}
|
|
189
|
+
out.push({
|
|
190
|
+
package: `app:${entry.module}#${entry.export}`,
|
|
191
|
+
appModule: { file, export: entry.export },
|
|
192
|
+
hosts: entry.hosts,
|
|
193
|
+
catalog: JSON.parse(JSON.stringify(catalogData(catalog))),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await server.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Every room catalog among the app's dependencies (`oui.agentCatalog`, or `closure.agentCatalog`). */
|
|
203
|
+
export function loadCatalogs(config) {
|
|
204
|
+
if (config.catalogs)
|
|
205
|
+
return { catalogs: [...config.catalogs], errors: [] };
|
|
206
|
+
const app = packageJson(config.root);
|
|
207
|
+
const deps = Object.keys({ ...app.dependencies, ...app.devDependencies }).sort();
|
|
208
|
+
const out = [];
|
|
209
|
+
const errors = [];
|
|
210
|
+
for (const pkg of deps) {
|
|
211
|
+
const dir = packageDir(config.root, pkg);
|
|
212
|
+
if (!dir)
|
|
213
|
+
continue;
|
|
214
|
+
const declared = agentDeclaration(pkg, packageJson(dir), 'agentCatalog');
|
|
215
|
+
if (declared.error) {
|
|
216
|
+
errors.push(configFinding(declared.error));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!declared.value)
|
|
220
|
+
continue;
|
|
221
|
+
const entries = (Array.isArray(declared.value) ? declared.value : [declared.value]);
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const file = resolve(dir, entry.path);
|
|
224
|
+
if (!existsSync(file)) {
|
|
225
|
+
errors.push(configFinding(`"${pkg}" names ${entry.path} as its ${declared.source}, but it does not exist`));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
out.push({ package: pkg, hosts: entry.hosts, catalog: JSON.parse(readFileSync(file, 'utf8')) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { catalogs: out, errors };
|
|
232
|
+
}
|
|
233
|
+
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
234
|
+
/**
|
|
235
|
+
* The API's operations, from its OpenAPI 3 document (`apiSpec`), by
|
|
236
|
+
* `operationId`: method, path and summary line. The document is named by
|
|
237
|
+
* module path, so it is the one the installed API client ships; a path
|
|
238
|
+
* inside the app is also read. `apiSpec: null` is an app with no API.
|
|
239
|
+
*/
|
|
240
|
+
export function loadApiOperations(config) {
|
|
241
|
+
const operations = new Map();
|
|
242
|
+
const fail = (message) => ({
|
|
243
|
+
spec: config.apiSpec,
|
|
244
|
+
readable: false,
|
|
245
|
+
operations,
|
|
246
|
+
errors: [configFinding(`apiSpec "${config.apiSpec}" ${message}`)],
|
|
247
|
+
});
|
|
248
|
+
if (config.apiSpec === null)
|
|
249
|
+
return { spec: null, readable: true, operations, errors: [] };
|
|
250
|
+
const spec = config.apiSpec;
|
|
251
|
+
// A module path resolves as the app resolves the package; anything else is a file of the app.
|
|
252
|
+
let file = null;
|
|
253
|
+
if (!spec.startsWith('.') && !isAbsolute(spec)) {
|
|
254
|
+
try {
|
|
255
|
+
file = createRequire(join(config.root, 'package.json')).resolve(spec);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
file = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!file) {
|
|
262
|
+
file = resolve(config.root, spec);
|
|
263
|
+
const inside = relative(config.root, file);
|
|
264
|
+
if (inside.startsWith('..') || isAbsolute(inside))
|
|
265
|
+
return fail('is outside the app: name the document by its module path, as the installed API client ships it');
|
|
266
|
+
}
|
|
267
|
+
let doc;
|
|
268
|
+
try {
|
|
269
|
+
doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return fail(`cannot be read: ${err.message}`);
|
|
273
|
+
}
|
|
274
|
+
const d = doc;
|
|
275
|
+
if (typeof d?.openapi !== 'string' || !d.openapi.startsWith('3.') || !d.paths || typeof d.paths !== 'object')
|
|
276
|
+
return fail('is not an OpenAPI 3 document: it needs "openapi": "3.x" and "paths"');
|
|
277
|
+
const errors = [];
|
|
278
|
+
for (const [path, item] of Object.entries(d.paths)) {
|
|
279
|
+
for (const method of HTTP_METHODS) {
|
|
280
|
+
const op = item?.[method];
|
|
281
|
+
if (!op || typeof op.operationId !== 'string')
|
|
282
|
+
continue;
|
|
283
|
+
if (operations.has(op.operationId)) {
|
|
284
|
+
errors.push(configFinding(`apiSpec "${spec}" has two operations with the operationId ${op.operationId}`));
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
operations.set(op.operationId, {
|
|
288
|
+
name: op.operationId,
|
|
289
|
+
method: method.toUpperCase(),
|
|
290
|
+
url: path,
|
|
291
|
+
summary: operationSummary(op.summary, op.description),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { spec, readable: true, operations, errors };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* An operation's summary, or the first line of its description; a trailing
|
|
299
|
+
* code reference (`— \`client.call()\``) names the call, not what it does.
|
|
300
|
+
*/
|
|
301
|
+
function operationSummary(summary, description) {
|
|
302
|
+
const text = typeof summary === 'string' && summary.trim() ? summary : typeof description === 'string' ? description : '';
|
|
303
|
+
return text
|
|
304
|
+
.split('\n')[0]
|
|
305
|
+
.replace(/\s*—\s*`[^`]+`\s*$/, '')
|
|
306
|
+
.trim();
|
|
307
|
+
}
|
|
308
|
+
/** The navigation entries: every `{ label, route }` object literal in the nav files. */
|
|
309
|
+
export function loadNav(source, files) {
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const file of files) {
|
|
312
|
+
const sf = source.program.getSourceFile(file);
|
|
313
|
+
if (!sf)
|
|
314
|
+
throw new Error(`Navigation file ${source.rel(file)} is not in the program`);
|
|
315
|
+
const visit = (n) => {
|
|
316
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
317
|
+
const v = evaluate(source.checker, n);
|
|
318
|
+
if (typeof v.label === 'string' && typeof v.route === 'string' && v.showInNav !== false) {
|
|
319
|
+
out.push({ label: v.label, route: v.route, group: typeof v.group === 'string' ? v.group : '' });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
ts.forEachChild(n, visit);
|
|
323
|
+
};
|
|
324
|
+
visit(sf);
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
/** The frame components the config names, each with the declaration that renders it. */
|
|
329
|
+
export function loadShell(source, config) {
|
|
330
|
+
return config.shell.map(entry => {
|
|
331
|
+
const sf = source.program.getSourceFile(entry.module);
|
|
332
|
+
if (!sf)
|
|
333
|
+
throw new Error(`Shell module ${source.rel(entry.module)} is not in the program`);
|
|
334
|
+
const moduleSymbol = source.checker.getSymbolAtLocation(sf);
|
|
335
|
+
let exported = moduleSymbol
|
|
336
|
+
? source.checker.getExportsOfModule(moduleSymbol).find(s => s.name === entry.export)
|
|
337
|
+
: undefined;
|
|
338
|
+
if (exported && exported.flags & ts.SymbolFlags.Alias)
|
|
339
|
+
exported = source.checker.getAliasedSymbol(exported);
|
|
340
|
+
return {
|
|
341
|
+
component: entry.export,
|
|
342
|
+
routes: [...(entry.routes ?? ['*'])],
|
|
343
|
+
declaration: exported?.valueDeclaration ?? exported?.declarations?.[0] ?? null,
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** Calls that take the app's routes as data. */
|
|
348
|
+
const DATA_ROUTERS = new Set([
|
|
349
|
+
'createBrowserRouter',
|
|
350
|
+
'createHashRouter',
|
|
351
|
+
'createMemoryRouter',
|
|
352
|
+
'createStaticRouter',
|
|
353
|
+
'useRoutes',
|
|
354
|
+
]);
|
|
355
|
+
/**
|
|
356
|
+
* The app's routes, from the routes file: `<Route>` elements (nested ones
|
|
357
|
+
* joined to their parent, `index` routes at their parent's path) and data
|
|
358
|
+
* routers (`createBrowserRouter([...])` and its kin, `useRoutes`), with
|
|
359
|
+
* `element`, `Component` and `lazy` route modules. Wrappers are resolved to
|
|
360
|
+
* the page, `lazy` and `React.lazy` to the component they load.
|
|
361
|
+
*
|
|
362
|
+
* Not pages: splats, and redirects — a `<Navigate>`, and a component that
|
|
363
|
+
* only ever renders one (a redirect that keeps the id). A redirect is not a
|
|
364
|
+
* page, and naming it as one teaches an address that is not the destination.
|
|
365
|
+
* A route's element that has child routes is a layout: it frames them.
|
|
366
|
+
*/
|
|
367
|
+
export function loadRoutes(source, config) {
|
|
368
|
+
const sf = source.program.getSourceFile(config.routes);
|
|
369
|
+
if (!sf)
|
|
370
|
+
throw new Error(`Routes file ${source.rel(config.routes)} is not in the program`);
|
|
371
|
+
const reader = new RouteReader(source, new Set(config.routeWrappers));
|
|
372
|
+
const roots = [];
|
|
373
|
+
const visit = (n) => {
|
|
374
|
+
const route = routeElement(n);
|
|
375
|
+
if (route) {
|
|
376
|
+
roots.push(reader.fromJsx(route));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (ts.isCallExpression(n) && DATA_ROUTERS.has(calleeName(n.expression)) && n.arguments[0]) {
|
|
380
|
+
roots.push(...reader.fromArray(n.arguments[0]));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
ts.forEachChild(n, visit);
|
|
384
|
+
};
|
|
385
|
+
visit(sf);
|
|
386
|
+
const pages = new Map();
|
|
387
|
+
const layouts = new Map();
|
|
388
|
+
/** The pages' paths under a node, found while walking it. */
|
|
389
|
+
const walk = (node, parent) => {
|
|
390
|
+
if (node.path === UNKNOWN) {
|
|
391
|
+
reader.problem(node.site, 'A route’s path must be readable at build time: a literal or a constant');
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
const full = node.index || node.path === undefined ? parent : joinPath(parent, node.path);
|
|
395
|
+
if (node.path === '*')
|
|
396
|
+
return [];
|
|
397
|
+
if (node.children.length) {
|
|
398
|
+
const under = node.children.flatMap(child => walk(child, full));
|
|
399
|
+
if (node.renders && node.renders !== 'redirect' && node.renders.declaration) {
|
|
400
|
+
const own = node.path !== undefined && !node.children.some(c => c.index) ? [full] : [];
|
|
401
|
+
const entry = layouts.get(node.renders.component) ?? {
|
|
402
|
+
component: node.renders.component,
|
|
403
|
+
declaration: node.renders.declaration,
|
|
404
|
+
routes: [],
|
|
405
|
+
};
|
|
406
|
+
entry.routes = [...new Set([...entry.routes, ...own, ...under])].sort();
|
|
407
|
+
layouts.set(node.renders.component, entry);
|
|
408
|
+
}
|
|
409
|
+
return under;
|
|
410
|
+
}
|
|
411
|
+
if (!node.renders || node.renders === 'redirect' || pages.has(full))
|
|
412
|
+
return [];
|
|
413
|
+
pages.set(full, { path: full, ...node.renders });
|
|
414
|
+
return [full];
|
|
415
|
+
};
|
|
416
|
+
for (const root of roots)
|
|
417
|
+
walk(root, '');
|
|
418
|
+
return {
|
|
419
|
+
pages: [...pages.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
420
|
+
layouts: [...layouts.values()].sort((a, b) => a.component.localeCompare(b.component)),
|
|
421
|
+
errors: reader.errors,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** A child's path joined to its parent's; an absolute one is used as written. */
|
|
425
|
+
function joinPath(parent, path) {
|
|
426
|
+
const joined = path.startsWith('/') ? path : `${parent.replace(/\/+$/, '')}/${path}`;
|
|
427
|
+
const clean = joined.replace(/\/{2,}/g, '/').replace(/(.)\/+$/, '$1');
|
|
428
|
+
return clean.startsWith('/') ? clean : `/${clean}`;
|
|
429
|
+
}
|
|
430
|
+
/** A `<Route>` element's opening tag, taken whole: its nested routes are read from it, not beside it. */
|
|
431
|
+
function routeElement(n) {
|
|
432
|
+
const opening = ts.isJsxElement(n) ? n.openingElement : ts.isJsxSelfClosingElement(n) ? n : null;
|
|
433
|
+
return opening && opening.tagName.getText() === 'Route' ? opening : null;
|
|
434
|
+
}
|
|
435
|
+
/** `createBrowserRouter`, `React.lazy`, `ReactRouter.useRoutes`: the called name. */
|
|
436
|
+
function calleeName(expr) {
|
|
437
|
+
const e = unwrap(expr);
|
|
438
|
+
if (ts.isIdentifier(e))
|
|
439
|
+
return e.text;
|
|
440
|
+
if (ts.isPropertyAccessExpression(e))
|
|
441
|
+
return e.name.text;
|
|
442
|
+
return '';
|
|
443
|
+
}
|
|
444
|
+
/** Whether a call is `lazy(...)` or `React.lazy(...)`. */
|
|
445
|
+
export function isLazyCall(expr) {
|
|
446
|
+
const e = unwrap(expr);
|
|
447
|
+
return ts.isCallExpression(e) && calleeName(e.expression) === 'lazy';
|
|
448
|
+
}
|
|
449
|
+
class RouteReader {
|
|
450
|
+
source;
|
|
451
|
+
wrappers;
|
|
452
|
+
errors = [];
|
|
453
|
+
constructor(source, wrappers) {
|
|
454
|
+
this.source = source;
|
|
455
|
+
this.wrappers = wrappers;
|
|
456
|
+
}
|
|
457
|
+
problem(site, message) {
|
|
458
|
+
const sf = site.getSourceFile();
|
|
459
|
+
this.errors.push({
|
|
460
|
+
file: this.source.rel(sf),
|
|
461
|
+
line: sf.getLineAndCharacterOfPosition(site.getStart()).line + 1,
|
|
462
|
+
message,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/** A `<Route>` and the `<Route>`s nested in it. */
|
|
466
|
+
fromJsx(el) {
|
|
467
|
+
const pathAttr = jsxAttribute(el, 'path');
|
|
468
|
+
const indexAttr = jsxAttribute(el, 'index');
|
|
469
|
+
const children = [];
|
|
470
|
+
if (ts.isJsxOpeningElement(el)) {
|
|
471
|
+
const visit = (n) => {
|
|
472
|
+
const route = routeElement(n);
|
|
473
|
+
if (route) {
|
|
474
|
+
children.push(this.fromJsx(route));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
ts.forEachChild(n, visit);
|
|
478
|
+
};
|
|
479
|
+
el.parent.children.forEach(visit);
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
site: el,
|
|
483
|
+
path: pathAttr === undefined ? undefined : this.path(pathAttr === true ? undefined : pathAttr),
|
|
484
|
+
index: indexAttr === true || (indexAttr !== undefined && evaluate(this.source.checker, indexAttr) === true),
|
|
485
|
+
renders: this.renders(el, asExpression(jsxAttribute(el, 'element')), asExpression(jsxAttribute(el, 'Component')), asExpression(jsxAttribute(el, 'lazy'))),
|
|
486
|
+
children,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** The route objects of a data router's array: a literal, a constant, spreads of either. */
|
|
490
|
+
fromArray(expr, site = expr) {
|
|
491
|
+
const array = this.arrayLiteral(expr);
|
|
492
|
+
if (!array) {
|
|
493
|
+
this.problem(site, 'Routes must be readable at build time: an array literal of route objects, or a constant one');
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
const out = [];
|
|
497
|
+
for (const el of array.elements) {
|
|
498
|
+
if (ts.isSpreadElement(el)) {
|
|
499
|
+
out.push(...this.fromArray(el.expression, el));
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const obj = this.objectLiteral(el);
|
|
503
|
+
if (!obj) {
|
|
504
|
+
this.problem(el, 'A route must be readable at build time: an object literal, or a constant one');
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
out.push(this.fromObject(obj));
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
fromObject(obj) {
|
|
512
|
+
const pathExpr = objectProperty(obj, 'path');
|
|
513
|
+
const children = objectProperty(obj, 'children');
|
|
514
|
+
return {
|
|
515
|
+
site: obj,
|
|
516
|
+
path: pathExpr === undefined ? undefined : this.path(pathExpr),
|
|
517
|
+
index: evaluate(this.source.checker, objectProperty(obj, 'index')) === true,
|
|
518
|
+
renders: this.renders(obj, objectProperty(obj, 'element'), objectProperty(obj, 'Component'), objectProperty(obj, 'lazy')),
|
|
519
|
+
children: children ? this.fromArray(children) : [],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
path(expr) {
|
|
523
|
+
const v = evaluate(this.source.checker, expr);
|
|
524
|
+
return typeof v === 'string' ? v : UNKNOWN;
|
|
525
|
+
}
|
|
526
|
+
/** What a route renders, from its `element`, its `Component` or its `lazy` route module. */
|
|
527
|
+
renders(site, element, component, lazy) {
|
|
528
|
+
if (element) {
|
|
529
|
+
const page = pageElement(unwrap(element), this.wrappers);
|
|
530
|
+
if (!page)
|
|
531
|
+
return null;
|
|
532
|
+
if (page.tagName.getText() === 'Navigate')
|
|
533
|
+
return 'redirect';
|
|
534
|
+
const declaration = componentDeclaration(this.source, page.tagName);
|
|
535
|
+
if (declaration && onlyRedirects(declaration))
|
|
536
|
+
return 'redirect';
|
|
537
|
+
return { component: page.tagName.getText(), declaration };
|
|
538
|
+
}
|
|
539
|
+
if (component) {
|
|
540
|
+
const id = unwrap(component);
|
|
541
|
+
if (!ts.isIdentifier(id)) {
|
|
542
|
+
this.problem(site, 'A route’s Component must be a component’s name');
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
const declaration = componentDeclaration(this.source, id);
|
|
546
|
+
if (declaration && onlyRedirects(declaration))
|
|
547
|
+
return 'redirect';
|
|
548
|
+
return { component: id.text, declaration };
|
|
549
|
+
}
|
|
550
|
+
if (lazy) {
|
|
551
|
+
const loaded = importedModule(this.source, lazy);
|
|
552
|
+
const declaration = loaded && moduleExport(this.source, loaded, ['Component', 'default']);
|
|
553
|
+
if (!loaded || !declaration) {
|
|
554
|
+
this.problem(site, 'A route’s lazy must be () => import(\'…\') of a route module that exports Component (or a default)');
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
return { component: basename(loaded.fileName, extname(loaded.fileName)), declaration };
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
arrayLiteral(expr) {
|
|
562
|
+
const e = unwrap(expr);
|
|
563
|
+
if (ts.isArrayLiteralExpression(e))
|
|
564
|
+
return e;
|
|
565
|
+
if (ts.isIdentifier(e)) {
|
|
566
|
+
const init = constInitializer(this.source.checker, e);
|
|
567
|
+
return init ? this.arrayLiteral(init) : null;
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
objectLiteral(expr) {
|
|
572
|
+
const e = unwrap(expr);
|
|
573
|
+
if (ts.isObjectLiteralExpression(e))
|
|
574
|
+
return e;
|
|
575
|
+
if (ts.isIdentifier(e)) {
|
|
576
|
+
const init = constInitializer(this.source.checker, e);
|
|
577
|
+
return init ? this.objectLiteral(init) : null;
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function asExpression(v) {
|
|
583
|
+
return v === true ? undefined : v;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Whether a component only ever renders `<Navigate>`: every value it returns
|
|
587
|
+
* (its arrow body, or each `return` of its own body, not of functions inside
|
|
588
|
+
* it) is a `<Navigate>` element.
|
|
589
|
+
*/
|
|
590
|
+
function onlyRedirects(decl) {
|
|
591
|
+
const fn = ts.isVariableDeclaration(decl) && decl.initializer ? unwrap(decl.initializer) : decl;
|
|
592
|
+
if (!ts.isFunctionDeclaration(fn) && !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn))
|
|
593
|
+
return false;
|
|
594
|
+
if (!fn.body)
|
|
595
|
+
return false;
|
|
596
|
+
const returned = [];
|
|
597
|
+
if (ts.isBlock(fn.body)) {
|
|
598
|
+
const visit = (n) => {
|
|
599
|
+
if (ts.isFunctionLike(n))
|
|
600
|
+
return;
|
|
601
|
+
if (ts.isReturnStatement(n) && n.expression)
|
|
602
|
+
returned.push(n.expression);
|
|
603
|
+
ts.forEachChild(n, visit);
|
|
604
|
+
};
|
|
605
|
+
fn.body.statements.forEach(visit);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
returned.push(fn.body);
|
|
609
|
+
}
|
|
610
|
+
const isNavigate = (e) => {
|
|
611
|
+
const x = unwrap(e);
|
|
612
|
+
if (ts.isJsxSelfClosingElement(x))
|
|
613
|
+
return x.tagName.getText() === 'Navigate';
|
|
614
|
+
if (ts.isJsxElement(x))
|
|
615
|
+
return x.openingElement.tagName.getText() === 'Navigate';
|
|
616
|
+
return false;
|
|
617
|
+
};
|
|
618
|
+
return returned.length > 0 && returned.every(isNavigate);
|
|
619
|
+
}
|
|
620
|
+
/** The first JSX element under `expr` that is not a wrapper. */
|
|
621
|
+
function pageElement(expr, wrappers) {
|
|
622
|
+
let result = null;
|
|
623
|
+
const visit = (n) => {
|
|
624
|
+
if (result)
|
|
625
|
+
return;
|
|
626
|
+
if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) {
|
|
627
|
+
if (!wrappers.has(n.tagName.getText())) {
|
|
628
|
+
result = n;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
ts.forEachChild(n, visit);
|
|
633
|
+
};
|
|
634
|
+
visit(expr);
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The function that renders a component a JSX tag names: through imports and
|
|
639
|
+
* re-exports, `lazy(() => import('./X'))` and `React.lazy(...)` (a default
|
|
640
|
+
* export, or `.then(m => ({ default: m.X }))`), `forwardRef` and `memo`.
|
|
641
|
+
*/
|
|
642
|
+
export function componentDeclaration(source, tag) {
|
|
643
|
+
const symbol = source.checker.getSymbolAtLocation(tag);
|
|
644
|
+
let resolved = symbol;
|
|
645
|
+
if (resolved && resolved.flags & ts.SymbolFlags.Alias)
|
|
646
|
+
resolved = source.checker.getAliasedSymbol(resolved);
|
|
647
|
+
const decl = resolved?.valueDeclaration ?? resolved?.declarations?.[0];
|
|
648
|
+
if (!decl)
|
|
649
|
+
return null;
|
|
650
|
+
if (ts.isVariableDeclaration(decl) && decl.initializer && isLazyCall(decl.initializer)) {
|
|
651
|
+
return lazyTarget(source, unwrap(decl.initializer));
|
|
652
|
+
}
|
|
653
|
+
return decl;
|
|
654
|
+
}
|
|
655
|
+
/** The component a `lazy(() => import('./X'))` loads: the default export, or the one `.then` picks. */
|
|
656
|
+
function lazyTarget(source, call) {
|
|
657
|
+
let exportName = 'default';
|
|
658
|
+
const visit = (n) => {
|
|
659
|
+
if (ts.isPropertyAssignment(n) &&
|
|
660
|
+
n.name.getText() === 'default' &&
|
|
661
|
+
ts.isPropertyAccessExpression(n.initializer)) {
|
|
662
|
+
exportName = n.initializer.name.text;
|
|
663
|
+
}
|
|
664
|
+
ts.forEachChild(n, visit);
|
|
665
|
+
};
|
|
666
|
+
visit(call);
|
|
667
|
+
const target = importedModule(source, call);
|
|
668
|
+
return target ? moduleExport(source, target, [exportName]) : null;
|
|
669
|
+
}
|
|
670
|
+
/** The app module a `() => import('./x')` (anywhere under `node`) loads. */
|
|
671
|
+
function importedModule(source, node) {
|
|
672
|
+
let importPath = null;
|
|
673
|
+
const visit = (n) => {
|
|
674
|
+
if (importPath)
|
|
675
|
+
return;
|
|
676
|
+
if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
677
|
+
const arg = n.arguments[0];
|
|
678
|
+
if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)))
|
|
679
|
+
importPath = arg.text;
|
|
680
|
+
}
|
|
681
|
+
ts.forEachChild(n, visit);
|
|
682
|
+
};
|
|
683
|
+
visit(node);
|
|
684
|
+
if (!importPath)
|
|
685
|
+
return null;
|
|
686
|
+
const resolved = ts.resolveModuleName(importPath, node.getSourceFile().fileName, source.program.getCompilerOptions(), ts.sys);
|
|
687
|
+
return (resolved.resolvedModule && source.program.getSourceFile(resolved.resolvedModule.resolvedFileName)) ?? null;
|
|
688
|
+
}
|
|
689
|
+
/** The declaration of the first of `names` a module exports. */
|
|
690
|
+
function moduleExport(source, file, names) {
|
|
691
|
+
const moduleSymbol = source.checker.getSymbolAtLocation(file);
|
|
692
|
+
if (!moduleSymbol)
|
|
693
|
+
return null;
|
|
694
|
+
const exports = source.checker.getExportsOfModule(moduleSymbol);
|
|
695
|
+
for (const name of names) {
|
|
696
|
+
let exported = exports.find(s => s.name === name);
|
|
697
|
+
if (exported && exported.flags & ts.SymbolFlags.Alias)
|
|
698
|
+
exported = source.checker.getAliasedSymbol(exported);
|
|
699
|
+
const decl = exported?.valueDeclaration ?? exported?.declarations?.[0];
|
|
700
|
+
if (decl)
|
|
701
|
+
return decl;
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=inputs.js.map
|