@starci/hfs 4.16.4 → 4.17.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/CHANGELOG.md +35 -3
- package/README.md +4 -4
- package/codemod/loading-states.d.mts +50 -0
- package/codemod/loading-states.mjs +384 -0
- package/codemod/main.d.mts +1 -1
- package/codemod/main.mjs +46 -5
- package/codemod/page-navigation.d.mts +50 -0
- package/codemod/page-navigation.mjs +278 -0
- package/codemod/section-frame.d.mts +50 -0
- package/codemod/section-frame.mjs +483 -0
- package/codemod/small-renames.d.mts +54 -0
- package/codemod/small-renames.mjs +614 -0
- package/codemod/surface-card-kinds.d.mts +49 -0
- package/codemod/surface-card-kinds.mjs +404 -0
- package/codemod/theme-tokens.d.mts +45 -0
- package/codemod/theme-tokens.mjs +114 -0
- package/codemod/tier-binding.d.mts +50 -0
- package/codemod/tier-binding.mjs +214 -0
- package/package.json +1 -1
- package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +343 -26
- package/runtime/knowledge/hfs/slots.yaml +12 -5
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +23 -13
- package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
- package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
- package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
- package/runtime/scripts/hfs/architecture/index.mjs +6 -0
- package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
- package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
- package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
- package/scaffold/add-fe-layout.mjs +3 -3
- package/scaffold/add-fe-overlay.mjs +4 -4
- package/scaffold/add-fe-page.mjs +1 -1
- package/scaffold/add-fe-unit.d.mts +2 -1
- package/scaffold/add-fe-unit.mjs +14 -12
- package/scaffold/add.d.mts +1 -1
- package/scaffold/add.mjs +1 -1
- package/src/main.mjs +4 -4
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
- package/templates/fe/units/block/component.tsx.tpl +2 -3
- package/templates/fe/units/branch/index.tsx.tpl +4 -3
- package/templates/fe/units/composite/index.tsx.tpl +3 -3
- package/templates/fe/units/frame/index.tsx.tpl +10 -0
- package/templates/fe/units/layout/index.tsx.tpl +2 -2
- package/templates/fe/units/leaf/index.tsx.tpl +3 -3
- package/templates/fe/units/page/component.tsx.tpl +2 -2
- package/templates/fe/units/page/index.tsx.tpl +2 -2
- package/templates/fe/units/page/stories.tsx.tpl +2 -2
- package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
- package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
- package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
- package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
2
|
+
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
3
|
+
const GRAMMAR_PACKAGE = /^@starci\/grammar(?:\/|$)/u;
|
|
4
|
+
const MAX_DEPTH = 14;
|
|
5
|
+
const HELPER_DEPTH = 3;
|
|
6
|
+
const MAP_METHODS = new Set(['map', 'flatMap']);
|
|
7
|
+
/** Everything the walk needs about the files of one program. */
|
|
8
|
+
export class RenderGraph {
|
|
9
|
+
ts;
|
|
10
|
+
graph;
|
|
11
|
+
components = new Map();
|
|
12
|
+
imports = new Map();
|
|
13
|
+
exportsCache = new Map();
|
|
14
|
+
fileEdges = new Map();
|
|
15
|
+
constructor(ts, graph) {
|
|
16
|
+
this.ts = ts;
|
|
17
|
+
this.graph = graph;
|
|
18
|
+
for (const edge of graph.edges) {
|
|
19
|
+
let bySpecifier = this.fileEdges.get(edge.from);
|
|
20
|
+
if (!bySpecifier) {
|
|
21
|
+
bySpecifier = new Map();
|
|
22
|
+
this.fileEdges.set(edge.from, bySpecifier);
|
|
23
|
+
}
|
|
24
|
+
bySpecifier.set(edge.specifier, edge.to);
|
|
25
|
+
}
|
|
26
|
+
for (const file of graph.files.values())
|
|
27
|
+
if (/\.tsx$/u.test(file.rel) && !/\.(?:spec|test|stories)\.tsx$/u.test(file.rel))
|
|
28
|
+
this.readComponents(file.rel, file.sourceFile);
|
|
29
|
+
}
|
|
30
|
+
/** Every app component of the program. */
|
|
31
|
+
allComponents() {
|
|
32
|
+
return [...this.components.values()].flatMap(byName => [...byName.values()]);
|
|
33
|
+
}
|
|
34
|
+
/** The component a file declares under a name. */
|
|
35
|
+
componentOf(file, name) {
|
|
36
|
+
return this.components.get(file)?.get(name) ?? null;
|
|
37
|
+
}
|
|
38
|
+
/** The location of a node, 1-based. */
|
|
39
|
+
locate(element) {
|
|
40
|
+
const file = this.graph.files.get(element.file);
|
|
41
|
+
return { path: element.file, ...(file ? sourceLocation(file.sourceFile, element.node) : { line: 1, column: 1 }) };
|
|
42
|
+
}
|
|
43
|
+
// --- reading a file -------------------------------------------------------------------------------------------------------
|
|
44
|
+
readComponents(rel, sourceFile) {
|
|
45
|
+
const { ts } = this;
|
|
46
|
+
const byName = new Map();
|
|
47
|
+
const functions = new Map();
|
|
48
|
+
for (const statement of sourceFile.statements) {
|
|
49
|
+
if (ts.isFunctionDeclaration(statement) && statement.name)
|
|
50
|
+
functions.set(statement.name.text, statement);
|
|
51
|
+
if (ts.isVariableStatement(statement)) {
|
|
52
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
53
|
+
const fn = ts.isIdentifier(declaration.name) ? this.functionOf(declaration.initializer) : null;
|
|
54
|
+
if (fn && ts.isIdentifier(declaration.name))
|
|
55
|
+
functions.set(declaration.name.text, fn);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const [name, fn] of functions) {
|
|
60
|
+
if (!/^[A-Z]/u.test(name))
|
|
61
|
+
continue;
|
|
62
|
+
const returns = this.returnsOf(rel, fn, functions);
|
|
63
|
+
if (returns.length > 0)
|
|
64
|
+
byName.set(name, { file: rel, name, returns });
|
|
65
|
+
}
|
|
66
|
+
if (byName.size > 0)
|
|
67
|
+
this.components.set(rel, byName);
|
|
68
|
+
}
|
|
69
|
+
/** The function a declaration initializer is or wraps (`memo(fn)`, `forwardRef(fn)`). */
|
|
70
|
+
functionOf(initializer) {
|
|
71
|
+
const { ts } = this;
|
|
72
|
+
const value = unwrapExpression(ts, initializer);
|
|
73
|
+
if (!value)
|
|
74
|
+
return null;
|
|
75
|
+
if (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
|
|
76
|
+
return value;
|
|
77
|
+
if (ts.isCallExpression(value) && value.arguments[0])
|
|
78
|
+
return this.functionOf(value.arguments[0]);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
returnsOf(rel, fn, siblings) {
|
|
82
|
+
const { ts } = this;
|
|
83
|
+
const propNames = new Set();
|
|
84
|
+
let propsIdentifier = null;
|
|
85
|
+
const first = fn.parameters[0];
|
|
86
|
+
if (first) {
|
|
87
|
+
if (ts.isIdentifier(first.name))
|
|
88
|
+
propsIdentifier = first.name.text;
|
|
89
|
+
else if (ts.isObjectBindingPattern(first.name)) {
|
|
90
|
+
for (const element of first.name.elements) {
|
|
91
|
+
if (ts.isIdentifier(element.name))
|
|
92
|
+
propNames.add(element.propertyName && ts.isIdentifier(element.propertyName) ? `${element.name.text}\u0000${element.propertyName.text}` : element.name.text);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// destructured local names -> prop name
|
|
97
|
+
const propOfLocal = new Map();
|
|
98
|
+
for (const entry of propNames) {
|
|
99
|
+
const [local, prop] = entry.split('\u0000');
|
|
100
|
+
propOfLocal.set(local, prop ?? local);
|
|
101
|
+
}
|
|
102
|
+
const locals = new Map();
|
|
103
|
+
const returned = [];
|
|
104
|
+
const scan = (node) => {
|
|
105
|
+
if (ts.isFunctionLike(node) && node !== fn)
|
|
106
|
+
return;
|
|
107
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
108
|
+
locals.set(node.name.text, node.initializer);
|
|
109
|
+
}
|
|
110
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && ts.isIdentifier(node.initializer) && node.initializer.text === propsIdentifier) {
|
|
111
|
+
for (const element of node.name.elements)
|
|
112
|
+
if (ts.isIdentifier(element.name))
|
|
113
|
+
propOfLocal.set(element.name.text, element.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : element.name.text);
|
|
114
|
+
}
|
|
115
|
+
if (ts.isReturnStatement(node) && node.expression)
|
|
116
|
+
returned.push(node.expression);
|
|
117
|
+
ts.forEachChild(node, scan);
|
|
118
|
+
};
|
|
119
|
+
if (fn.body && !ts.isBlock(fn.body))
|
|
120
|
+
returned.push(fn.body);
|
|
121
|
+
else if (fn.body)
|
|
122
|
+
ts.forEachChild(fn.body, scan);
|
|
123
|
+
const reader = new PartReader(this, rel, { locals, propOfLocal, propsIdentifier, siblings });
|
|
124
|
+
return returned.flatMap(expression => reader.read(expression, false, 0));
|
|
125
|
+
}
|
|
126
|
+
// --- names ----------------------------------------------------------------------------------------------------------------
|
|
127
|
+
importsOf(rel) {
|
|
128
|
+
const cached = this.imports.get(rel);
|
|
129
|
+
if (cached)
|
|
130
|
+
return cached;
|
|
131
|
+
const { ts } = this;
|
|
132
|
+
const bindings = new Map();
|
|
133
|
+
const sourceFile = this.graph.files.get(rel)?.sourceFile;
|
|
134
|
+
this.imports.set(rel, bindings);
|
|
135
|
+
if (!sourceFile)
|
|
136
|
+
return bindings;
|
|
137
|
+
for (const statement of sourceFile.statements) {
|
|
138
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier))
|
|
139
|
+
continue;
|
|
140
|
+
const specifier = statement.moduleSpecifier.text;
|
|
141
|
+
const clause = statement.importClause;
|
|
142
|
+
if (!clause || clause.isTypeOnly)
|
|
143
|
+
continue;
|
|
144
|
+
const target = this.fileEdges.get(rel)?.get(specifier) ?? null;
|
|
145
|
+
const grammar = GRAMMAR_PACKAGE.test(specifier);
|
|
146
|
+
const bind = (local, imported) => {
|
|
147
|
+
if (grammar)
|
|
148
|
+
bindings.set(local, { grammar: imported });
|
|
149
|
+
else if (target) {
|
|
150
|
+
const resolved = this.resolveExport(target, imported, new Set());
|
|
151
|
+
bindings.set(local, resolved ?? { external: true });
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
bindings.set(local, { external: true });
|
|
155
|
+
};
|
|
156
|
+
if (clause.name)
|
|
157
|
+
bind(clause.name.text, 'default');
|
|
158
|
+
const named = clause.namedBindings;
|
|
159
|
+
if (named && ts.isNamespaceImport(named))
|
|
160
|
+
bindings.set(named.name.text, grammar ? { grammar: '*' } : { external: true });
|
|
161
|
+
else if (named)
|
|
162
|
+
for (const element of named.elements)
|
|
163
|
+
if (!element.isTypeOnly)
|
|
164
|
+
bind(element.name.text, element.propertyName?.text ?? element.name.text);
|
|
165
|
+
}
|
|
166
|
+
return bindings;
|
|
167
|
+
}
|
|
168
|
+
/** The file and local name that declare what `file` exports under `name`, following re-exports. */
|
|
169
|
+
resolveExport(file, name, seen) {
|
|
170
|
+
const key = `${file}\u0000${name}`;
|
|
171
|
+
if (seen.has(key))
|
|
172
|
+
return null;
|
|
173
|
+
seen.add(key);
|
|
174
|
+
if (this.components.get(file)?.has(name))
|
|
175
|
+
return { file, name };
|
|
176
|
+
const table = this.exportTable(file);
|
|
177
|
+
const direct = table.get(name);
|
|
178
|
+
if (direct)
|
|
179
|
+
return direct.file === file && direct.name === name ? direct : this.resolveExport(direct.file, direct.name, seen);
|
|
180
|
+
const star = table.get('*');
|
|
181
|
+
if (star)
|
|
182
|
+
return this.resolveExport(star.file, name, seen);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
exportTable(file) {
|
|
186
|
+
const cached = this.exportsCache.get(file);
|
|
187
|
+
if (cached)
|
|
188
|
+
return cached;
|
|
189
|
+
const table = new Map();
|
|
190
|
+
this.exportsCache.set(file, table);
|
|
191
|
+
const { ts } = this;
|
|
192
|
+
const sourceFile = this.graph.files.get(file)?.sourceFile;
|
|
193
|
+
if (!sourceFile)
|
|
194
|
+
return table;
|
|
195
|
+
for (const statement of sourceFile.statements) {
|
|
196
|
+
if (ts.isExportDeclaration(statement)) {
|
|
197
|
+
const target = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? this.fileEdges.get(file)?.get(statement.moduleSpecifier.text) ?? null : null;
|
|
198
|
+
if (!statement.exportClause) {
|
|
199
|
+
if (target)
|
|
200
|
+
table.set('*', { file: target, name: '*' });
|
|
201
|
+
}
|
|
202
|
+
else if (ts.isNamedExports(statement.exportClause)) {
|
|
203
|
+
for (const element of statement.exportClause.elements) {
|
|
204
|
+
const local = element.propertyName?.text ?? element.name.text;
|
|
205
|
+
table.set(element.name.text, { file: target ?? file, name: local });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression)) {
|
|
210
|
+
table.set('default', { file, name: statement.expression.text });
|
|
211
|
+
}
|
|
212
|
+
else if (ts.isFunctionDeclaration(statement) && statement.name && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
|
|
213
|
+
table.set('default', { file, name: statement.name.text });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return table;
|
|
217
|
+
}
|
|
218
|
+
/** What the JSX tag `tagName` of a file names. */
|
|
219
|
+
tagOf(rel, tagName) {
|
|
220
|
+
const { ts } = this;
|
|
221
|
+
if (ts.isIdentifier(tagName)) {
|
|
222
|
+
const name = tagName.text;
|
|
223
|
+
if (/^[a-z]/u.test(name))
|
|
224
|
+
return { kind: 'host', name };
|
|
225
|
+
if (this.components.get(rel)?.has(name))
|
|
226
|
+
return { kind: 'component', file: rel, name };
|
|
227
|
+
const bound = this.importsOf(rel).get(name);
|
|
228
|
+
if (bound && 'grammar' in bound)
|
|
229
|
+
return { kind: 'grammar', name: bound.grammar };
|
|
230
|
+
if (bound && 'file' in bound)
|
|
231
|
+
return { kind: 'component', file: bound.file, name: bound.name };
|
|
232
|
+
return { kind: 'unknown', name };
|
|
233
|
+
}
|
|
234
|
+
if (ts.isPropertyAccessExpression(tagName) && ts.isIdentifier(tagName.expression)) {
|
|
235
|
+
const bound = this.importsOf(rel).get(tagName.expression.text);
|
|
236
|
+
if (bound && 'grammar' in bound && bound.grammar === '*')
|
|
237
|
+
return { kind: 'grammar', name: tagName.name.text };
|
|
238
|
+
return { kind: 'unknown', name: `${tagName.expression.text}.${tagName.name.text}` };
|
|
239
|
+
}
|
|
240
|
+
return { kind: 'unknown', name: '?' };
|
|
241
|
+
}
|
|
242
|
+
// --- walking --------------------------------------------------------------------------------------------------------------
|
|
243
|
+
/** Walks everything `root` draws with the empty context, reporting each element and each run of siblings to the visitor. */
|
|
244
|
+
walkFrom(root, visitor) {
|
|
245
|
+
this.walk(root.returns, [], null, [`${root.file}#${root.name}`], visitor, 0);
|
|
246
|
+
}
|
|
247
|
+
walk(parts, ancestors, supplier, stack, visitor, depth) {
|
|
248
|
+
if (depth > MAX_DEPTH)
|
|
249
|
+
return;
|
|
250
|
+
const flat = this.flatten(parts, supplier);
|
|
251
|
+
const elements = flat.filter((entry) => entry.element !== null);
|
|
252
|
+
if (visitor.siblings && elements.length > 1)
|
|
253
|
+
visitor.siblings(elements.map(entry => entry.element), ancestors, stack);
|
|
254
|
+
for (const { element, supplier: supplied } of elements) {
|
|
255
|
+
visitor.enter?.(element, ancestors, stack);
|
|
256
|
+
const { tag } = element;
|
|
257
|
+
if (tag.kind === 'component') {
|
|
258
|
+
const definition = this.componentOf(tag.file, tag.name);
|
|
259
|
+
const key = `${tag.file}#${tag.name}`;
|
|
260
|
+
if (!definition || stack.includes(key))
|
|
261
|
+
continue;
|
|
262
|
+
const slots = new Map();
|
|
263
|
+
for (const attribute of element.attributes.values())
|
|
264
|
+
if (attribute.parts.length > 0)
|
|
265
|
+
slots.set(attribute.name, attribute.parts);
|
|
266
|
+
this.walk(definition.returns, ancestors, { children: element.children, slots, outer: supplied }, [...stack, key], visitor, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const inner = tag.kind === 'grammar' || tag.kind === 'host' ? [...ancestors, { element }] : ancestors;
|
|
270
|
+
const slotParts = [...element.attributes.values()].flatMap(attribute => attribute.parts);
|
|
271
|
+
if (visitor.parent)
|
|
272
|
+
visitor.parent(element, this.flatten(element.children, supplied).flatMap(entry => (entry.element ? [entry.element] : [])), ancestors, stack);
|
|
273
|
+
this.walk([...slotParts, ...element.children], inner, supplied, stack, visitor, depth + 1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Parts with placements replaced by what the supplier handed over; each element keeps the supplier its own placements read. */
|
|
278
|
+
flatten(parts, supplier) {
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const part of parts) {
|
|
281
|
+
if (part.kind === 'element') {
|
|
282
|
+
out.push({ element: part, supplier });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!supplier)
|
|
286
|
+
continue;
|
|
287
|
+
const handed = part.prop === 'children' ? supplier.children : supplier.slots.get(part.prop) ?? [];
|
|
288
|
+
out.push(...this.flatten(handed, supplier.outer));
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
/** The one element a component element draws at its root, or null when it draws several things or none. */
|
|
293
|
+
rootOf(element) {
|
|
294
|
+
if (element.tag.kind !== 'component')
|
|
295
|
+
return element;
|
|
296
|
+
const definition = this.componentOf(element.tag.file, element.tag.name);
|
|
297
|
+
const roots = definition?.returns.filter((part) => part.kind === 'element') ?? [];
|
|
298
|
+
const only = roots.length === 1 && definition?.returns.length === 1 ? roots[0] : undefined;
|
|
299
|
+
return only ?? null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Reads the expressions of one function into render parts. */
|
|
303
|
+
class PartReader {
|
|
304
|
+
graph;
|
|
305
|
+
rel;
|
|
306
|
+
scope;
|
|
307
|
+
followed = new Set();
|
|
308
|
+
constructor(graph, rel, scope) {
|
|
309
|
+
this.graph = graph;
|
|
310
|
+
this.rel = rel;
|
|
311
|
+
this.scope = scope;
|
|
312
|
+
}
|
|
313
|
+
read(input, repeated, depth) {
|
|
314
|
+
const { ts } = this.graph;
|
|
315
|
+
const expression = unwrapExpression(ts, input);
|
|
316
|
+
if (!expression || depth > 12)
|
|
317
|
+
return [];
|
|
318
|
+
if (ts.isJsxElement(expression))
|
|
319
|
+
return [this.element(expression.openingElement, expression.children, repeated, depth)];
|
|
320
|
+
if (ts.isJsxSelfClosingElement(expression))
|
|
321
|
+
return [this.element(expression, [], repeated, depth)];
|
|
322
|
+
if (ts.isJsxFragment(expression))
|
|
323
|
+
return this.children(expression.children, repeated, depth);
|
|
324
|
+
if (ts.isJsxExpression(expression))
|
|
325
|
+
return expression.expression ? this.read(expression.expression, repeated, depth + 1) : [];
|
|
326
|
+
if (ts.isConditionalExpression(expression))
|
|
327
|
+
return [...this.read(expression.whenTrue, repeated, depth + 1), ...this.read(expression.whenFalse, repeated, depth + 1)];
|
|
328
|
+
if (ts.isBinaryExpression(expression)) {
|
|
329
|
+
const kind = expression.operatorToken.kind;
|
|
330
|
+
if (kind === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
331
|
+
return this.read(expression.right, repeated, depth + 1);
|
|
332
|
+
if (kind === ts.SyntaxKind.BarBarToken || kind === ts.SyntaxKind.QuestionQuestionToken)
|
|
333
|
+
return [...this.read(expression.left, repeated, depth + 1), ...this.read(expression.right, repeated, depth + 1)];
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
if (ts.isArrayLiteralExpression(expression))
|
|
337
|
+
return expression.elements.flatMap(item => this.read(item, repeated, depth + 1));
|
|
338
|
+
if (ts.isIdentifier(expression))
|
|
339
|
+
return this.identifier(expression.text, expression, repeated, depth);
|
|
340
|
+
if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === this.scope.propsIdentifier) {
|
|
341
|
+
return [{ kind: 'placement', prop: expression.name.text, file: this.rel, node: expression }];
|
|
342
|
+
}
|
|
343
|
+
if (ts.isCallExpression(expression))
|
|
344
|
+
return this.call(expression, repeated, depth);
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
identifier(name, node, repeated, depth) {
|
|
348
|
+
const prop = this.scope.propOfLocal.get(name);
|
|
349
|
+
if (prop !== undefined)
|
|
350
|
+
return [{ kind: 'placement', prop, file: this.rel, node }];
|
|
351
|
+
const local = this.scope.locals.get(name);
|
|
352
|
+
if (local && !this.followed.has(local)) {
|
|
353
|
+
this.followed.add(local);
|
|
354
|
+
const parts = this.read(local, repeated, depth + 1);
|
|
355
|
+
this.followed.delete(local);
|
|
356
|
+
return parts;
|
|
357
|
+
}
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
call(call, repeated, depth) {
|
|
361
|
+
const { ts } = this.graph;
|
|
362
|
+
const callee = unwrapExpression(ts, call.expression);
|
|
363
|
+
if (ts.isPropertyAccessExpression(callee) && MAP_METHODS.has(callee.name.text)) {
|
|
364
|
+
const callback = call.arguments[0];
|
|
365
|
+
const fn = callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ? callback : null;
|
|
366
|
+
if (!fn)
|
|
367
|
+
return [];
|
|
368
|
+
const returned = [];
|
|
369
|
+
const scan = (node) => {
|
|
370
|
+
if (ts.isFunctionLike(node) && node !== fn)
|
|
371
|
+
return;
|
|
372
|
+
if (ts.isReturnStatement(node) && node.expression)
|
|
373
|
+
returned.push(node.expression);
|
|
374
|
+
ts.forEachChild(node, scan);
|
|
375
|
+
};
|
|
376
|
+
if (!ts.isBlock(fn.body))
|
|
377
|
+
returned.push(fn.body);
|
|
378
|
+
else
|
|
379
|
+
ts.forEachChild(fn.body, scan);
|
|
380
|
+
return returned.flatMap(expression => this.read(expression, true, depth + 1));
|
|
381
|
+
}
|
|
382
|
+
if (ts.isIdentifier(callee) && depth < HELPER_DEPTH * 4) {
|
|
383
|
+
const helper = this.scope.siblings.get(callee.text);
|
|
384
|
+
if (helper && !/^[A-Z]/u.test(callee.text) && !this.followed.has(helper)) {
|
|
385
|
+
this.followed.add(helper);
|
|
386
|
+
const returned = [];
|
|
387
|
+
const scan = (node) => {
|
|
388
|
+
if (ts.isFunctionLike(node) && node !== helper)
|
|
389
|
+
return;
|
|
390
|
+
if (ts.isReturnStatement(node) && node.expression)
|
|
391
|
+
returned.push(node.expression);
|
|
392
|
+
ts.forEachChild(node, scan);
|
|
393
|
+
};
|
|
394
|
+
if (helper.body && !ts.isBlock(helper.body))
|
|
395
|
+
returned.push(helper.body);
|
|
396
|
+
else if (helper.body)
|
|
397
|
+
ts.forEachChild(helper.body, scan);
|
|
398
|
+
const parts = returned.flatMap(expression => this.read(expression, repeated, depth + 4));
|
|
399
|
+
this.followed.delete(helper);
|
|
400
|
+
return parts;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
children(children, repeated, depth) {
|
|
406
|
+
return children.flatMap(child => this.read(child, repeated, depth + 1));
|
|
407
|
+
}
|
|
408
|
+
element(opening, children, repeated, depth) {
|
|
409
|
+
const { ts } = this.graph;
|
|
410
|
+
const attributes = new Map();
|
|
411
|
+
for (const attribute of opening.attributes.properties) {
|
|
412
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
413
|
+
attributes.set('...', { name: '...', text: null, parts: [] });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (!ts.isIdentifier(attribute.name))
|
|
417
|
+
continue;
|
|
418
|
+
const name = attribute.name.text;
|
|
419
|
+
const initializer = attribute.initializer;
|
|
420
|
+
let text = null;
|
|
421
|
+
let parts = [];
|
|
422
|
+
if (!initializer)
|
|
423
|
+
text = 'true';
|
|
424
|
+
else if (ts.isStringLiteralLike(initializer))
|
|
425
|
+
text = initializer.text;
|
|
426
|
+
else if (ts.isJsxExpression(initializer) && initializer.expression) {
|
|
427
|
+
const value = unwrapExpression(ts, initializer.expression);
|
|
428
|
+
if (ts.isStringLiteralLike(value) || ts.isNoSubstitutionTemplateLiteral(value))
|
|
429
|
+
text = value.text;
|
|
430
|
+
else if (value.kind === ts.SyntaxKind.TrueKeyword)
|
|
431
|
+
text = 'true';
|
|
432
|
+
else if (value.kind === ts.SyntaxKind.FalseKeyword)
|
|
433
|
+
text = 'false';
|
|
434
|
+
else if (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))
|
|
435
|
+
parts = this.read(value, false, depth + 1);
|
|
436
|
+
}
|
|
437
|
+
attributes.set(name, { name, text, parts });
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
kind: 'element',
|
|
441
|
+
tag: this.graph.tagOf(this.rel, opening.tagName),
|
|
442
|
+
attributes,
|
|
443
|
+
children: this.children(children, repeated, depth),
|
|
444
|
+
repeated,
|
|
445
|
+
file: this.rel,
|
|
446
|
+
node: opening,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Violation } from './ast-walks.mjs';
|
|
2
|
+
import type { CheckInput } from './hfs-graph.mjs';
|
|
3
|
+
import type { TypeScriptApi } from './config.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* R361 `render-prop-across-server` (FE_RENDER_PROP_ACROSS_SERVER): a function cannot cross from a server file into a client
|
|
6
|
+
* component. React serialises props across that line, and a function is not serialisable, so `<Client render={() => ...} />`
|
|
7
|
+
* from a server file fails at run time (or, with `'use server'`, becomes an action nobody meant). The check reads the render
|
|
8
|
+
* graph: a JSX element in a file with no `'use client'` directive whose tag is an app component declared in a file that has
|
|
9
|
+
* one, and one of whose props is an arrow or function expression (an inline `'use server'` function is an action and is let
|
|
10
|
+
* through). What a block takes as data it renders itself; a callback belongs on the client branch that owns the interaction.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RENDER_PROP_ACROSS_SERVER_RULE_IDS: string[];
|
|
13
|
+
export declare function checkRenderPropAcrossServer(input: Pick<CheckInput, 'graph'> & {
|
|
14
|
+
context: {
|
|
15
|
+
ts: TypeScriptApi;
|
|
16
|
+
};
|
|
17
|
+
}): {
|
|
18
|
+
violations: Violation[];
|
|
19
|
+
coverage: {
|
|
20
|
+
status: 'checked';
|
|
21
|
+
serverFiles: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { RenderGraph } from './render-graph.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* R361 `render-prop-across-server` (FE_RENDER_PROP_ACROSS_SERVER): a function cannot cross from a server file into a client
|
|
4
|
+
* component. React serialises props across that line, and a function is not serialisable, so `<Client render={() => ...} />`
|
|
5
|
+
* from a server file fails at run time (or, with `'use server'`, becomes an action nobody meant). The check reads the render
|
|
6
|
+
* graph: a JSX element in a file with no `'use client'` directive whose tag is an app component declared in a file that has
|
|
7
|
+
* one, and one of whose props is an arrow or function expression (an inline `'use server'` function is an action and is let
|
|
8
|
+
* through). What a block takes as data it renders itself; a callback belongs on the client branch that owns the interaction.
|
|
9
|
+
*/
|
|
10
|
+
export const RENDER_PROP_ACROSS_SERVER_RULE_IDS = ['FE_RENDER_PROP_ACROSS_SERVER'];
|
|
11
|
+
const RULE = 'FE_RENDER_PROP_ACROSS_SERVER';
|
|
12
|
+
/** Whether a file or a function body opens with the directive prologue string `directive`. */
|
|
13
|
+
const hasDirective = (ts, container, directive) => {
|
|
14
|
+
for (const statement of container.statements) {
|
|
15
|
+
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
|
|
16
|
+
return false;
|
|
17
|
+
if (statement.expression.text === directive)
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
};
|
|
22
|
+
export function checkRenderPropAcrossServer(input) {
|
|
23
|
+
const { ts } = input.context;
|
|
24
|
+
const graph = new RenderGraph(ts, input.graph);
|
|
25
|
+
const violations = [];
|
|
26
|
+
let serverFiles = 0;
|
|
27
|
+
for (const file of input.graph.files.values()) {
|
|
28
|
+
if (!/\.tsx$/u.test(file.rel) || /\.(?:spec|test|stories)\.tsx$/u.test(file.rel) || hasDirective(ts, file.sourceFile, 'use client'))
|
|
29
|
+
continue;
|
|
30
|
+
serverFiles += 1;
|
|
31
|
+
const visit = (node) => {
|
|
32
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
33
|
+
const tag = graph.tagOf(file.rel, node.tagName);
|
|
34
|
+
const target = tag.kind === 'component' ? input.graph.files.get(tag.file) : undefined;
|
|
35
|
+
if (tag.kind === 'component' && target && tag.file !== file.rel && hasDirective(ts, target.sourceFile, 'use client')) {
|
|
36
|
+
for (const attribute of node.attributes.properties) {
|
|
37
|
+
if (!ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
|
|
38
|
+
continue;
|
|
39
|
+
const value = attribute.initializer.expression;
|
|
40
|
+
if (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))
|
|
41
|
+
continue;
|
|
42
|
+
const body = value.body;
|
|
43
|
+
if (ts.isBlock(body) && hasDirective(ts, body, 'use server'))
|
|
44
|
+
continue;
|
|
45
|
+
const where = graph.locate({ file: file.rel, node: attribute });
|
|
46
|
+
violations.push({
|
|
47
|
+
ruleId: RULE, ...where, component: tag.name,
|
|
48
|
+
message: `${file.rel} passes a function as \`${ts.isIdentifier(attribute.name) ? attribute.name.text : 'a prop'}\` to ${tag.name}, a client component (${tag.file}), from a server file: a function does not cross the server-client line. Pass data and let ${tag.name} render it, or move the callback into a client branch that owns the interaction.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ts.forEachChild(node, visit);
|
|
54
|
+
};
|
|
55
|
+
visit(file.sourceFile);
|
|
56
|
+
}
|
|
57
|
+
return { violations, coverage: { status: 'checked', serverFiles } };
|
|
58
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RenderGraph } from './render-graph.mjs';
|
|
2
|
+
import type { Violation } from './ast-walks.mjs';
|
|
3
|
+
import type { CheckInput } from './hfs-graph.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* R356 `surface-nesting` (FE_SURFACE_NESTING), the whole-program half of the one-file card laws (R340, R351, R354, R348, R366).
|
|
6
|
+
* ESLint judges one file; an app draws a screen across several, so the check walks the render graph (render-graph.mts) from every
|
|
7
|
+
* app component and reports, at the inner element, what stands wrongly inside what once every component is replaced by what it
|
|
8
|
+
* draws and every `children` by what its caller handed it:
|
|
9
|
+
*
|
|
10
|
+
* card-in-card a card inside a card (surface in surface is banned totally, owner decision 2026-11-01);
|
|
11
|
+
* card-in-pane a card inside `Pane surface`;
|
|
12
|
+
* card-in-overlay a card inside a Dialog, Drawer, AlertDialog or Popover;
|
|
13
|
+
* table-in-card a `DataTable` inside a card (a table stands on the page);
|
|
14
|
+
* grid-outside-panes a `PaneGrid` inside a card or a Page whose chrome is not `panes`;
|
|
15
|
+
* heading-over-card a `Heading`, or a region `Section`, whose only neighbour or child is one card (a card carries its own label);
|
|
16
|
+
* label-in-grid a labelled card repeated by a `.map` in a `Grid` (a repeated tile has no label).
|
|
17
|
+
*
|
|
18
|
+
* A finding carries `crossFile: true` when the two elements are drawn in different files, which is what one-file lint cannot see.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SURFACE_NESTING_RULE_IDS: string[];
|
|
21
|
+
/** The grammar vocabulary this check reads, in one place so a rename in the grammar is one edit here. */
|
|
22
|
+
export declare const SURFACE_VOCABULARY: Readonly<{
|
|
23
|
+
cards: Set<string>;
|
|
24
|
+
overlays: Set<string>;
|
|
25
|
+
tables: Set<string>;
|
|
26
|
+
headings: Set<string>;
|
|
27
|
+
sections: Set<string>;
|
|
28
|
+
paneGrids: Set<string>;
|
|
29
|
+
pane: "Pane";
|
|
30
|
+
page: "Page";
|
|
31
|
+
grids: Set<string>;
|
|
32
|
+
}>;
|
|
33
|
+
export interface SurfaceNestingResult {
|
|
34
|
+
violations: Violation[];
|
|
35
|
+
coverage: {
|
|
36
|
+
status: 'checked';
|
|
37
|
+
components: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export declare function checkSurfaceNesting(input: Pick<CheckInput, 'graph'> & {
|
|
41
|
+
context: {
|
|
42
|
+
ts: ConstructorParameters<typeof RenderGraph>[0];
|
|
43
|
+
};
|
|
44
|
+
}): SurfaceNestingResult;
|