@getrefino/onboarding 0.1.0-rc.1
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 +28 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +219 -0
- package/dist/apply.d.ts +23 -0
- package/dist/apply.js +142 -0
- package/dist/dataflow.d.ts +96 -0
- package/dist/dataflow.js +333 -0
- package/dist/fs.d.ts +20 -0
- package/dist/fs.js +131 -0
- package/dist/graph.d.ts +45 -0
- package/dist/graph.js +299 -0
- package/dist/ids.d.ts +20 -0
- package/dist/ids.js +367 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/inspect.d.ts +19 -0
- package/dist/inspect.js +471 -0
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +447 -0
- package/dist/scan.d.ts +67 -0
- package/dist/scan.js +1486 -0
- package/dist/templates-hosted.d.ts +34 -0
- package/dist/templates-hosted.js +332 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +671 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.js +5 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +403 -0
- package/package.json +54 -0
package/dist/dataflow.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static data flow from a string literal to the screen.
|
|
3
|
+
*
|
|
4
|
+
* The scanner only reports a string when there is evidence in the source
|
|
5
|
+
* that it reaches a reader. Beyond literal JSX text that means following a
|
|
6
|
+
* few well-defined shapes: a constant interpolated into JSX, an array the
|
|
7
|
+
* file maps into elements, a record indexed then mapped, a prop a component
|
|
8
|
+
* renders, a default for such a prop, a conditional between two literals,
|
|
9
|
+
* and a state value a handler sets to a literal. Everything here is
|
|
10
|
+
* syntactic; nothing is executed and no type checker is used.
|
|
11
|
+
*
|
|
12
|
+
* The rule that keeps this conservative: start from a *render sink* (a JSX
|
|
13
|
+
* expression child, or a prop a component renders) and walk backwards to
|
|
14
|
+
* literals. Never start from a constant and guess that it might be shown.
|
|
15
|
+
*/
|
|
16
|
+
import ts from "typescript";
|
|
17
|
+
import { isFile, readText, rel } from "./fs.js";
|
|
18
|
+
import { parseSource, resolveImport } from "./graph.js";
|
|
19
|
+
export function createDataflowContext(appDir, aliases) {
|
|
20
|
+
return { appDir, aliases, components: new Map() };
|
|
21
|
+
}
|
|
22
|
+
export function unwrap(expression) {
|
|
23
|
+
let current = expression;
|
|
24
|
+
while (ts.isAsExpression(current) ||
|
|
25
|
+
ts.isSatisfiesExpression(current) ||
|
|
26
|
+
ts.isParenthesizedExpression(current) ||
|
|
27
|
+
ts.isTypeAssertionExpression(current) ||
|
|
28
|
+
ts.isNonNullExpression(current)) {
|
|
29
|
+
current = current.expression;
|
|
30
|
+
}
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
export function literalText(expression) {
|
|
34
|
+
if (!expression)
|
|
35
|
+
return null;
|
|
36
|
+
const inner = unwrap(expression);
|
|
37
|
+
if (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner))
|
|
38
|
+
return inner.text;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function isFunctionLike(node) {
|
|
42
|
+
return ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node);
|
|
43
|
+
}
|
|
44
|
+
/** Find the declaration of a component by name in a module. */
|
|
45
|
+
function findComponent(sourceFile, name) {
|
|
46
|
+
let found = null;
|
|
47
|
+
const visit = (node) => {
|
|
48
|
+
if (found)
|
|
49
|
+
return;
|
|
50
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
51
|
+
found = node;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
|
|
55
|
+
const initializer = unwrap(node.initializer);
|
|
56
|
+
if (isFunctionLike(initializer)) {
|
|
57
|
+
found = initializer;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit);
|
|
62
|
+
};
|
|
63
|
+
visit(sourceFile);
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
/** Names bound by the component's first parameter, with any literal defaults. */
|
|
67
|
+
function destructuredProps(fn) {
|
|
68
|
+
const props = new Map();
|
|
69
|
+
const parameter = fn.parameters[0];
|
|
70
|
+
if (!parameter || !ts.isObjectBindingPattern(parameter.name))
|
|
71
|
+
return props;
|
|
72
|
+
for (const element of parameter.name.elements) {
|
|
73
|
+
if (!ts.isIdentifier(element.name))
|
|
74
|
+
continue;
|
|
75
|
+
const propName = (element.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName : element.name).text;
|
|
76
|
+
const defaultText = element.initializer ? literalText(element.initializer) : null;
|
|
77
|
+
props.set(propName, { local: element.name.text, defaultText, defaultNode: element.initializer ?? null });
|
|
78
|
+
}
|
|
79
|
+
return props;
|
|
80
|
+
}
|
|
81
|
+
/** Is this identifier rendered as a JSX child somewhere inside the function? */
|
|
82
|
+
function rendersAsChild(fn, local) {
|
|
83
|
+
let rendered = false;
|
|
84
|
+
const visit = (node) => {
|
|
85
|
+
if (rendered)
|
|
86
|
+
return;
|
|
87
|
+
if (ts.isJsxExpression(node) && node.expression && node.parent && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
88
|
+
const expression = unwrap(node.expression);
|
|
89
|
+
if (ts.isIdentifier(expression) && expression.text === local)
|
|
90
|
+
rendered = true;
|
|
91
|
+
// `{label ?? "Fallback"}` and `{label || "Fallback"}` still render the prop.
|
|
92
|
+
if (ts.isBinaryExpression(expression) && ts.isIdentifier(unwrap(expression.left)) && unwrap(expression.left).text === local)
|
|
93
|
+
rendered = true;
|
|
94
|
+
}
|
|
95
|
+
ts.forEachChild(node, visit);
|
|
96
|
+
};
|
|
97
|
+
visit(fn);
|
|
98
|
+
return rendered;
|
|
99
|
+
}
|
|
100
|
+
/** Is this identifier mapped into elements whose visible text is the item itself? */
|
|
101
|
+
function rendersAsList(fn, local) {
|
|
102
|
+
let rendered = false;
|
|
103
|
+
const visit = (node) => {
|
|
104
|
+
if (rendered)
|
|
105
|
+
return;
|
|
106
|
+
if (ts.isCallExpression(node) &&
|
|
107
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
108
|
+
node.expression.name.text === "map" &&
|
|
109
|
+
ts.isIdentifier(unwrap(node.expression.expression)) &&
|
|
110
|
+
unwrap(node.expression.expression).text === local) {
|
|
111
|
+
const callback = node.arguments[0];
|
|
112
|
+
if (callback && isFunctionLike(callback)) {
|
|
113
|
+
const parameter = callback.parameters[0]?.name;
|
|
114
|
+
if (parameter && ts.isIdentifier(parameter) && rendersAsChild(callback, parameter.text))
|
|
115
|
+
rendered = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ts.forEachChild(node, visit);
|
|
119
|
+
};
|
|
120
|
+
visit(fn);
|
|
121
|
+
return rendered;
|
|
122
|
+
}
|
|
123
|
+
/** Which props of a component in this module render as visible text. */
|
|
124
|
+
export function componentRenderInfo(sourceFile, file, name) {
|
|
125
|
+
const fn = findComponent(sourceFile, name);
|
|
126
|
+
if (!fn)
|
|
127
|
+
return null;
|
|
128
|
+
const props = new Map();
|
|
129
|
+
for (const [propName, binding] of destructuredProps(fn)) {
|
|
130
|
+
if (rendersAsChild(fn, binding.local))
|
|
131
|
+
props.set(propName, { role: "text", defaultText: binding.defaultText, defaultNode: binding.defaultNode });
|
|
132
|
+
else if (rendersAsList(fn, binding.local))
|
|
133
|
+
props.set(propName, { role: "list", defaultText: null, defaultNode: null });
|
|
134
|
+
}
|
|
135
|
+
return { props, sourceFile, file };
|
|
136
|
+
}
|
|
137
|
+
/** Where a JSX tag used in `sourceFile` is defined, following one local import. */
|
|
138
|
+
export function resolveComponent(context, sourceFile, absoluteFile, tag) {
|
|
139
|
+
if (!/^[A-Z]/.test(tag) || tag.includes("."))
|
|
140
|
+
return null;
|
|
141
|
+
const cacheKey = `${absoluteFile}#${tag}`;
|
|
142
|
+
const cached = context.components.get(cacheKey);
|
|
143
|
+
if (cached !== undefined)
|
|
144
|
+
return cached;
|
|
145
|
+
context.components.set(cacheKey, null); // guard against import cycles
|
|
146
|
+
let info = componentRenderInfo(sourceFile, rel(context.appDir, absoluteFile), tag);
|
|
147
|
+
if (!info) {
|
|
148
|
+
for (const statement of sourceFile.statements) {
|
|
149
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.importClause || statement.importClause.isTypeOnly)
|
|
150
|
+
continue;
|
|
151
|
+
const bindings = statement.importClause.namedBindings;
|
|
152
|
+
let exported = null;
|
|
153
|
+
if (statement.importClause.name?.text === tag)
|
|
154
|
+
exported = "default";
|
|
155
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
156
|
+
for (const element of bindings.elements) {
|
|
157
|
+
if (element.name.text === tag)
|
|
158
|
+
exported = (element.propertyName ?? element.name).text;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!exported)
|
|
162
|
+
continue;
|
|
163
|
+
const resolved = resolveImport(absoluteFile, statement.moduleSpecifier.text, context.appDir, context.aliases);
|
|
164
|
+
if (!resolved || resolved.includes("/node_modules/") || !isFile(resolved))
|
|
165
|
+
continue;
|
|
166
|
+
const text = readText(resolved);
|
|
167
|
+
if (!text)
|
|
168
|
+
continue;
|
|
169
|
+
const moduleSource = parseSource(resolved, text);
|
|
170
|
+
info = componentRenderInfo(moduleSource, rel(context.appDir, resolved), exported === "default" ? tag : exported);
|
|
171
|
+
if (info)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
context.components.set(cacheKey, info);
|
|
176
|
+
return info;
|
|
177
|
+
}
|
|
178
|
+
/** The nearest enclosing function, else the source file. */
|
|
179
|
+
export function enclosingScope(node) {
|
|
180
|
+
let current = node.parent;
|
|
181
|
+
while (current && !ts.isSourceFile(current)) {
|
|
182
|
+
if (isFunctionLike(current))
|
|
183
|
+
return current;
|
|
184
|
+
current = current.parent;
|
|
185
|
+
}
|
|
186
|
+
return node.getSourceFile();
|
|
187
|
+
}
|
|
188
|
+
/** Find `const NAME = …` visible from `node`, searching inner scopes first. */
|
|
189
|
+
export function findBinding(node, name) {
|
|
190
|
+
const scopes = [];
|
|
191
|
+
let current = node;
|
|
192
|
+
while (current) {
|
|
193
|
+
if (isFunctionLike(current) || ts.isSourceFile(current))
|
|
194
|
+
scopes.push(current);
|
|
195
|
+
current = current.parent;
|
|
196
|
+
}
|
|
197
|
+
for (const scope of scopes) {
|
|
198
|
+
let found = null;
|
|
199
|
+
const visit = (inner) => {
|
|
200
|
+
if (found)
|
|
201
|
+
return;
|
|
202
|
+
if (ts.isVariableDeclaration(inner) && ts.isIdentifier(inner.name) && inner.name.text === name && inner.initializer) {
|
|
203
|
+
found = inner;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Do not descend into nested functions; their locals are not in scope here.
|
|
207
|
+
if (inner !== scope && isFunctionLike(inner))
|
|
208
|
+
return;
|
|
209
|
+
ts.forEachChild(inner, visit);
|
|
210
|
+
};
|
|
211
|
+
ts.forEachChild(scope, visit);
|
|
212
|
+
if (found)
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/** Elements of an array literal that are all plain string literals. */
|
|
218
|
+
export function arrayOfStrings(expression) {
|
|
219
|
+
if (!expression)
|
|
220
|
+
return null;
|
|
221
|
+
const inner = unwrap(expression);
|
|
222
|
+
if (!ts.isArrayLiteralExpression(inner) || inner.elements.length === 0)
|
|
223
|
+
return null;
|
|
224
|
+
const elements = inner.elements.map((element) => unwrap(element));
|
|
225
|
+
return elements.every((element) => literalText(element) !== null) ? elements : null;
|
|
226
|
+
}
|
|
227
|
+
/** An object literal whose every value is an array of strings, keyed by property name. */
|
|
228
|
+
export function recordOfStringArrays(expression) {
|
|
229
|
+
if (!expression)
|
|
230
|
+
return null;
|
|
231
|
+
const inner = unwrap(expression);
|
|
232
|
+
if (!ts.isObjectLiteralExpression(inner) || inner.properties.length === 0)
|
|
233
|
+
return null;
|
|
234
|
+
const record = new Map();
|
|
235
|
+
for (const property of inner.properties) {
|
|
236
|
+
if (!ts.isPropertyAssignment(property))
|
|
237
|
+
return null;
|
|
238
|
+
const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null;
|
|
239
|
+
const elements = arrayOfStrings(property.initializer);
|
|
240
|
+
if (!key || !elements)
|
|
241
|
+
return null;
|
|
242
|
+
record.set(key, elements);
|
|
243
|
+
}
|
|
244
|
+
return record;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The literal a setter call actually assigns: the argument itself, or the
|
|
248
|
+
* fallback of `data.error ?? "Something went wrong."`, which is the string a
|
|
249
|
+
* reader sees whenever the other side supplies nothing.
|
|
250
|
+
*/
|
|
251
|
+
function assignedLiteral(expression) {
|
|
252
|
+
if (!expression)
|
|
253
|
+
return null;
|
|
254
|
+
const inner = unwrap(expression);
|
|
255
|
+
if (literalText(inner) !== null)
|
|
256
|
+
return inner;
|
|
257
|
+
if (ts.isBinaryExpression(inner) && (inner.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || inner.operatorToken.kind === ts.SyntaxKind.BarBarToken)) {
|
|
258
|
+
const right = unwrap(inner.right);
|
|
259
|
+
if (literalText(right) !== null)
|
|
260
|
+
return right;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Literals a `useState` value can hold: its initializer and every literal
|
|
266
|
+
* passed to its setter. Only recognised for the exact
|
|
267
|
+
* `const [value, setValue] = useState(...)` shape.
|
|
268
|
+
*/
|
|
269
|
+
export function stateLiterals(scope, name) {
|
|
270
|
+
let setter = null;
|
|
271
|
+
let initial = null;
|
|
272
|
+
const findDeclaration = (node) => {
|
|
273
|
+
if (setter)
|
|
274
|
+
return;
|
|
275
|
+
if (ts.isVariableDeclaration(node) &&
|
|
276
|
+
ts.isArrayBindingPattern(node.name) &&
|
|
277
|
+
node.name.elements.length === 2 &&
|
|
278
|
+
node.initializer &&
|
|
279
|
+
ts.isCallExpression(node.initializer) &&
|
|
280
|
+
/^(useState|React\.useState)$/.test(node.initializer.expression.getText())) {
|
|
281
|
+
const [value, set] = node.name.elements;
|
|
282
|
+
if (value && ts.isBindingElement(value) && ts.isIdentifier(value.name) && value.name.text === name && set && ts.isBindingElement(set) && ts.isIdentifier(set.name)) {
|
|
283
|
+
setter = set.name.text;
|
|
284
|
+
initial = node.initializer.arguments[0] ?? null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
ts.forEachChild(node, findDeclaration);
|
|
288
|
+
};
|
|
289
|
+
ts.forEachChild(scope, findDeclaration);
|
|
290
|
+
if (!setter)
|
|
291
|
+
return null;
|
|
292
|
+
const literals = [];
|
|
293
|
+
const initialLiteral = assignedLiteral(initial ?? undefined);
|
|
294
|
+
if (initialLiteral)
|
|
295
|
+
literals.push(initialLiteral);
|
|
296
|
+
const collect = (node) => {
|
|
297
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === setter) {
|
|
298
|
+
const assigned = assignedLiteral(node.arguments[0]);
|
|
299
|
+
if (assigned)
|
|
300
|
+
literals.push(assigned);
|
|
301
|
+
}
|
|
302
|
+
ts.forEachChild(node, collect);
|
|
303
|
+
};
|
|
304
|
+
ts.forEachChild(scope, collect);
|
|
305
|
+
return literals.length > 0 ? literals : null;
|
|
306
|
+
}
|
|
307
|
+
/** The callback of `<target>.map(cb)`, when the call maps `target`. */
|
|
308
|
+
export function mapCallbackOf(node) {
|
|
309
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression))
|
|
310
|
+
return null;
|
|
311
|
+
if (node.expression.name.text !== "map" && node.expression.name.text !== "flatMap")
|
|
312
|
+
return null;
|
|
313
|
+
const callback = node.arguments[0];
|
|
314
|
+
if (!callback || !isFunctionLike(callback))
|
|
315
|
+
return null;
|
|
316
|
+
return { target: unwrap(node.expression.expression), callback };
|
|
317
|
+
}
|
|
318
|
+
/** Does this callback render its first parameter directly as visible text? */
|
|
319
|
+
export function callbackRendersItem(callback) {
|
|
320
|
+
const parameter = callback.parameters[0]?.name;
|
|
321
|
+
if (!parameter || !ts.isIdentifier(parameter))
|
|
322
|
+
return false;
|
|
323
|
+
return rendersAsChild(callback, parameter.text);
|
|
324
|
+
}
|
|
325
|
+
/** The object-literal field an identifier in a map callback refers to (`item.answer`). */
|
|
326
|
+
export function fieldNameOf(expression, paramName, destructured) {
|
|
327
|
+
const inner = unwrap(expression);
|
|
328
|
+
if (paramName && ts.isPropertyAccessExpression(inner) && ts.isIdentifier(inner.expression) && inner.expression.text === paramName)
|
|
329
|
+
return inner.name.text;
|
|
330
|
+
if (ts.isIdentifier(inner) && destructured.has(inner.text))
|
|
331
|
+
return inner.text;
|
|
332
|
+
return null;
|
|
333
|
+
}
|
package/dist/fs.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const IGNORED_DIRS: Set<string>;
|
|
2
|
+
export declare const SOURCE_EXTENSIONS: string[];
|
|
3
|
+
export declare function toPosix(path: string): string;
|
|
4
|
+
export declare function rel(root: string, absolute: string): string;
|
|
5
|
+
export declare function exists(path: string): boolean;
|
|
6
|
+
export declare function isFile(path: string): boolean;
|
|
7
|
+
export declare function isDirectory(path: string): boolean;
|
|
8
|
+
export declare function readText(path: string): string | null;
|
|
9
|
+
export declare function readJson<T = unknown>(path: string): T | null;
|
|
10
|
+
/** Good enough for tsconfig-style JSON with comments and trailing commas. */
|
|
11
|
+
export declare function stripJsonComments(text: string): string;
|
|
12
|
+
export interface WalkOptions {
|
|
13
|
+
readonly extensions?: readonly string[];
|
|
14
|
+
readonly maxDepth?: number;
|
|
15
|
+
readonly ignore?: (relativePath: string, name: string) => boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Recursively list files under `dir`, skipping build output and dependencies. */
|
|
18
|
+
export declare function walkFiles(dir: string, options?: WalkOptions): string[];
|
|
19
|
+
export declare function isTestOrStoryFile(relativePath: string): boolean;
|
|
20
|
+
export declare function findUp(start: string, names: readonly string[], stopAt?: string): string | null;
|
package/dist/fs.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
export const IGNORED_DIRS = new Set([
|
|
4
|
+
"node_modules",
|
|
5
|
+
".git",
|
|
6
|
+
".next",
|
|
7
|
+
".nuxt",
|
|
8
|
+
"dist",
|
|
9
|
+
"build",
|
|
10
|
+
"out",
|
|
11
|
+
"coverage",
|
|
12
|
+
".turbo",
|
|
13
|
+
".vercel",
|
|
14
|
+
".netlify",
|
|
15
|
+
".cache",
|
|
16
|
+
".refino",
|
|
17
|
+
// A site onboarded before the Refino rename still has this directory.
|
|
18
|
+
".inline-copy",
|
|
19
|
+
"storybook-static",
|
|
20
|
+
"__tests__",
|
|
21
|
+
"__mocks__",
|
|
22
|
+
"e2e",
|
|
23
|
+
"cypress",
|
|
24
|
+
"playwright",
|
|
25
|
+
]);
|
|
26
|
+
export const SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"];
|
|
27
|
+
export function toPosix(path) {
|
|
28
|
+
return path.split(sep).join("/");
|
|
29
|
+
}
|
|
30
|
+
export function rel(root, absolute) {
|
|
31
|
+
return toPosix(relative(root, absolute)) || ".";
|
|
32
|
+
}
|
|
33
|
+
export function exists(path) {
|
|
34
|
+
return existsSync(path);
|
|
35
|
+
}
|
|
36
|
+
export function isFile(path) {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(path).isFile();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function isDirectory(path) {
|
|
45
|
+
try {
|
|
46
|
+
return statSync(path).isDirectory();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function readText(path) {
|
|
53
|
+
try {
|
|
54
|
+
return readFileSync(path, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function readJson(path) {
|
|
61
|
+
const text = readText(path);
|
|
62
|
+
if (text === null)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(stripJsonComments(text));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Good enough for tsconfig-style JSON with comments and trailing commas. */
|
|
72
|
+
export function stripJsonComments(text) {
|
|
73
|
+
return text
|
|
74
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
75
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
76
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
77
|
+
}
|
|
78
|
+
/** Recursively list files under `dir`, skipping build output and dependencies. */
|
|
79
|
+
export function walkFiles(dir, options = {}) {
|
|
80
|
+
const extensions = options.extensions ?? SOURCE_EXTENSIONS;
|
|
81
|
+
const maxDepth = options.maxDepth ?? 12;
|
|
82
|
+
const results = [];
|
|
83
|
+
const visit = (current, depth) => {
|
|
84
|
+
if (depth > maxDepth)
|
|
85
|
+
return;
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = readdirSync(current);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const name of entries.sort()) {
|
|
94
|
+
const absolute = join(current, name);
|
|
95
|
+
const relPath = rel(dir, absolute);
|
|
96
|
+
if (options.ignore?.(relPath, name))
|
|
97
|
+
continue;
|
|
98
|
+
if (isDirectory(absolute)) {
|
|
99
|
+
if (IGNORED_DIRS.has(name) || name.startsWith("."))
|
|
100
|
+
continue;
|
|
101
|
+
visit(absolute, depth + 1);
|
|
102
|
+
}
|
|
103
|
+
else if (extensions.some((ext) => name.endsWith(ext))) {
|
|
104
|
+
results.push(absolute);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
visit(resolve(dir), 0);
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
const TEST_FILE_PATTERN = /(\.(test|spec|stories|story)\.[cm]?[jt]sx?$)|(^|\/)(tests?|__tests__|__mocks__|stories|fixtures)\//;
|
|
112
|
+
export function isTestOrStoryFile(relativePath) {
|
|
113
|
+
return TEST_FILE_PATTERN.test(relativePath);
|
|
114
|
+
}
|
|
115
|
+
export function findUp(start, names, stopAt) {
|
|
116
|
+
let current = resolve(start);
|
|
117
|
+
for (let index = 0; index < 20; index += 1) {
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
const candidate = join(current, name);
|
|
120
|
+
if (existsSync(candidate))
|
|
121
|
+
return candidate;
|
|
122
|
+
}
|
|
123
|
+
if (stopAt && resolve(stopAt) === current)
|
|
124
|
+
return null;
|
|
125
|
+
const parent = resolve(current, "..");
|
|
126
|
+
if (parent === current)
|
|
127
|
+
return null;
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Framework, RouteInfo, Router } from "./types.js";
|
|
3
|
+
export interface RouteDiscovery {
|
|
4
|
+
readonly router: Router;
|
|
5
|
+
readonly routesDir: string | null;
|
|
6
|
+
readonly routes: readonly RouteInfo[];
|
|
7
|
+
readonly layouts: readonly string[];
|
|
8
|
+
readonly entry: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** Parse a file once; JSX is enabled for every extension so .js files with JSX work. */
|
|
11
|
+
export declare function parseSource(file: string, text: string): ts.SourceFile;
|
|
12
|
+
export interface PathAliases {
|
|
13
|
+
readonly baseUrl: string;
|
|
14
|
+
readonly paths: Readonly<Record<string, readonly string[]>>;
|
|
15
|
+
}
|
|
16
|
+
export declare function readPathAliases(appDir: string): PathAliases;
|
|
17
|
+
export declare function resolveImport(fromFile: string, specifier: string, appDir: string, aliases: PathAliases): string | null;
|
|
18
|
+
export declare function localImports(source: ts.SourceFile, file: string, appDir: string, aliases: PathAliases): string[];
|
|
19
|
+
export interface ReachabilityEntry {
|
|
20
|
+
readonly imports: readonly string[];
|
|
21
|
+
readonly jsx: boolean;
|
|
22
|
+
}
|
|
23
|
+
export type ReachabilityCache = Map<string, ReachabilityEntry>;
|
|
24
|
+
export interface ReachabilityOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Follow the render tree only: a file is entered when it contains JSX (or is
|
|
27
|
+
* the start file). Data and helper modules are leaves, so a layout that
|
|
28
|
+
* imports a schema helper which imports a blog registry does not turn every
|
|
29
|
+
* post into shared chrome.
|
|
30
|
+
*/
|
|
31
|
+
readonly renderTreeOnly?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** Files reachable from a start file through local imports, including itself. */
|
|
34
|
+
export declare function reachableFiles(start: string, appDir: string, aliases: PathAliases, cache: ReachabilityCache, options?: ReachabilityOptions): Set<string>;
|
|
35
|
+
export declare function discoverRoutes(appDir: string, framework: Framework, sourceDir: string): RouteDiscovery;
|
|
36
|
+
export interface FileScopes {
|
|
37
|
+
/** absolute file -> route paths that reach it */
|
|
38
|
+
readonly byFile: ReadonlyMap<string, ReadonlySet<string>>;
|
|
39
|
+
/** absolute files reachable from a layout / app shell, or rendered by more than one route */
|
|
40
|
+
readonly shared: ReadonlySet<string>;
|
|
41
|
+
/** the subset of `shared` that a layout / app shell renders (true chrome) */
|
|
42
|
+
readonly fromLayouts: ReadonlySet<string>;
|
|
43
|
+
}
|
|
44
|
+
/** Compute which route(s) each source file belongs to, and which files are shared chrome. */
|
|
45
|
+
export declare function computeFileScopes(appDir: string, routes: readonly RouteInfo[], layouts: readonly string[]): FileScopes;
|