@starci/hfs 4.16.5 → 4.17.2
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 +36 -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 +255 -30
- 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/architecture/unit-spec-providers.d.mts +2 -1
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +12 -5
- 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,483 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
5
|
+
import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
|
|
6
|
+
import { shadowed } from './small-renames.mjs';
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
|
|
9
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
10
|
+
/** Exports whose JSX elements become a fragment: the placement they supplied is now read from the structure. */
|
|
11
|
+
const UNWRAPPED = new Set(['CollectionItem', 'ItemActions', 'ToolbarActions']);
|
|
12
|
+
/** Exports a person migrates: the finding sits on the import (and on a namespace member use). */
|
|
13
|
+
const REMOVED = new Map([
|
|
14
|
+
['List', { code: 'LIST_REMOVED', reason: 'List is gone: a long list that searches or filters is Section kind="list" with items (rows from data), a short one is SurfaceCard kind="list"' }],
|
|
15
|
+
['ListItem', { code: 'LIST_REMOVED', reason: 'ListItem is gone: a row is an item of Section kind="list" or of a list card; custom content goes in the item detail field' }],
|
|
16
|
+
['LeadingNumber', { code: 'LEADING_NUMBER_REMOVED', reason: 'LeadingNumber is gone: a row of an ordered list takes position: n' }],
|
|
17
|
+
['Label', { code: 'LABEL_REMOVED', reason: 'Label is gone: a card names itself with label, a field with its own label prop' }],
|
|
18
|
+
['RegionProps', { code: 'SECTION_TYPES', reason: 'RegionProps is gone: type the section as SectionProps' }],
|
|
19
|
+
['RegionSpacing', { code: 'SECTION_TYPES', reason: 'RegionSpacing is gone: the space around a section is a role of its parent' }],
|
|
20
|
+
['SectionHeaderProps', { code: 'SECTION_TYPES', reason: 'SectionHeaderProps is gone: the head is SectionHeadProps, and a section is SectionProps' }],
|
|
21
|
+
['ListProps', { code: 'SECTION_TYPES', reason: 'ListProps is gone with List' }],
|
|
22
|
+
['ListItemProps', { code: 'SECTION_TYPES', reason: 'ListItemProps is gone with ListItem' }],
|
|
23
|
+
['LeadingNumberProps', { code: 'SECTION_TYPES', reason: 'LeadingNumberProps is gone with LeadingNumber' }],
|
|
24
|
+
['LabelProps', { code: 'SECTION_TYPES', reason: 'LabelProps is gone with Label' }],
|
|
25
|
+
['CollectionItemProps', { code: 'SECTION_TYPES', reason: 'CollectionItemProps is gone with CollectionItem' }],
|
|
26
|
+
['ItemActionsProps', { code: 'SECTION_TYPES', reason: 'ItemActionsProps is gone with ItemActions' }],
|
|
27
|
+
['ToolbarActionsProps', { code: 'SECTION_TYPES', reason: 'ToolbarActionsProps is gone with ToolbarActions' }],
|
|
28
|
+
['TextWeight', { code: 'SECTION_TYPES', reason: 'TextWeight is gone: Text takes isStrong' }],
|
|
29
|
+
]);
|
|
30
|
+
/** The `SectionHeader` props that move to `Section` as they are. */
|
|
31
|
+
const HEAD_PROPS = new Set(['title', 'description', 'count', 'meta', 'action', 'isSkeleton']);
|
|
32
|
+
/** The `Region` props that are dropped: the title names the section, and the space around it is its parent's role. */
|
|
33
|
+
const REGION_DROPPED = new Set(['label', 'labelledBy', 'spacing']);
|
|
34
|
+
/** Migrate Region, SectionHeader, the placement providers and `Text weight`; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
35
|
+
export function transformSectionFrame({ ts, text, file = 'component.tsx' }) {
|
|
36
|
+
const changes = [];
|
|
37
|
+
const findings = [];
|
|
38
|
+
if (!text.includes('@starci/grammar'))
|
|
39
|
+
return { text, changes, findings };
|
|
40
|
+
const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
|
|
41
|
+
if (syntax.length)
|
|
42
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
|
|
43
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
44
|
+
const bindings = new Map();
|
|
45
|
+
const namespaces = new Set();
|
|
46
|
+
const foreign = new Set();
|
|
47
|
+
const grammarDeclarations = [];
|
|
48
|
+
for (const statement of source.statements) {
|
|
49
|
+
if (ts.isImportDeclaration(statement)) {
|
|
50
|
+
const clause = statement.importClause;
|
|
51
|
+
const grammar = ts.isStringLiteral(statement.moduleSpecifier) && GRAMMAR.test(statement.moduleSpecifier.text);
|
|
52
|
+
if (grammar)
|
|
53
|
+
grammarDeclarations.push(statement);
|
|
54
|
+
if (clause?.name)
|
|
55
|
+
foreign.add(clause.name.text);
|
|
56
|
+
const named = clause?.namedBindings;
|
|
57
|
+
if (named && ts.isNamespaceImport(named))
|
|
58
|
+
(grammar ? namespaces : foreign).add(named.name.text);
|
|
59
|
+
if (named && ts.isNamedImports(named)) {
|
|
60
|
+
for (const specifier of named.elements) {
|
|
61
|
+
if (grammar)
|
|
62
|
+
bindings.set(specifier.name.text, { imported: specifier.propertyName?.text ?? specifier.name.text, local: specifier.name.text, declaration: statement, specifier });
|
|
63
|
+
else
|
|
64
|
+
foreign.add(specifier.name.text);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name)
|
|
69
|
+
foreign.add(statement.name.text);
|
|
70
|
+
else if (ts.isVariableStatement(statement))
|
|
71
|
+
for (const declaration of statement.declarationList.declarations)
|
|
72
|
+
if (ts.isIdentifier(declaration.name))
|
|
73
|
+
foreign.add(declaration.name.text);
|
|
74
|
+
}
|
|
75
|
+
if (!bindings.size && !namespaces.size)
|
|
76
|
+
return { text, changes, findings };
|
|
77
|
+
const edits = [];
|
|
78
|
+
const gone = new Set();
|
|
79
|
+
const consumed = new Set();
|
|
80
|
+
const additions = new Map();
|
|
81
|
+
const added = new Set();
|
|
82
|
+
const edit = (start, end, replacement) => { edits.push({ start, end, text: replacement }); };
|
|
83
|
+
const flag = (node, code, reason, component) => {
|
|
84
|
+
findings.push({ file, ...sourceLocation(source, node), code, reason, ...(component === undefined ? {} : { component }), source: node.getText(source).slice(0, 200) });
|
|
85
|
+
};
|
|
86
|
+
const change = (node, code, from, to) => { changes.push({ file, ...sourceLocation(source, node), code, from, to }); };
|
|
87
|
+
const boundTo = (name) => [...bindings.values()].find(binding => binding.imported === name);
|
|
88
|
+
/** The text to write for the Grammar export `name` next to `reference`: an existing binding, the namespace member, or a new specifier in the same declaration. */
|
|
89
|
+
const use = (name, reference) => {
|
|
90
|
+
if (reference.namespace)
|
|
91
|
+
return `${reference.namespace}.${name}`;
|
|
92
|
+
const existing = boundTo(name);
|
|
93
|
+
if (existing)
|
|
94
|
+
return existing.local;
|
|
95
|
+
if (added.has(name))
|
|
96
|
+
return name;
|
|
97
|
+
if (foreign.has(name) || !reference.binding)
|
|
98
|
+
return null;
|
|
99
|
+
added.add(name);
|
|
100
|
+
additions.set(reference.binding.declaration, [...additions.get(reference.binding.declaration) ?? [], name]);
|
|
101
|
+
return name;
|
|
102
|
+
};
|
|
103
|
+
const references = new Map();
|
|
104
|
+
const isReference = (identifier) => {
|
|
105
|
+
const parent = identifier.parent;
|
|
106
|
+
if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent))
|
|
107
|
+
return false;
|
|
108
|
+
if ((ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodDeclaration(parent)
|
|
109
|
+
|| ts.isMethodSignature(parent) || ts.isJsxAttribute(parent) || ts.isEnumMember(parent)) && parent.name === identifier)
|
|
110
|
+
return false;
|
|
111
|
+
if (ts.isQualifiedName(parent) && parent.right === identifier)
|
|
112
|
+
return false;
|
|
113
|
+
return !(ts.isBindingElement(parent) && parent.propertyName === identifier);
|
|
114
|
+
};
|
|
115
|
+
const collect = (node) => {
|
|
116
|
+
if (ts.isImportDeclaration(node))
|
|
117
|
+
return;
|
|
118
|
+
if (ts.isIdentifier(node) && bindings.has(node.text) && isReference(node) && !shadowed(ts, node))
|
|
119
|
+
references.set(node.text, [...references.get(node.text) ?? [], node]);
|
|
120
|
+
ts.forEachChild(node, collect);
|
|
121
|
+
};
|
|
122
|
+
collect(source);
|
|
123
|
+
const spread = (opening) => opening.attributes.properties.some(ts.isJsxSpreadAttribute);
|
|
124
|
+
const attributesOf = (opening) => new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map((prop) => [prop.name.getText(source), prop]));
|
|
125
|
+
const literal = (attribute) => {
|
|
126
|
+
const value = attribute?.initializer;
|
|
127
|
+
if (value && ts.isStringLiteralLike(value))
|
|
128
|
+
return value.text;
|
|
129
|
+
return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
|
|
130
|
+
};
|
|
131
|
+
const numberLiteral = (attribute) => {
|
|
132
|
+
const value = attribute?.initializer;
|
|
133
|
+
const expression = value && ts.isJsxExpression(value) ? value.expression : undefined;
|
|
134
|
+
return expression && ts.isNumericLiteral(expression) ? Number(expression.text) : null;
|
|
135
|
+
};
|
|
136
|
+
const meaningful = (element) => element.children.filter(child => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces) && !(ts.isJsxExpression(child) && !child.expression));
|
|
137
|
+
const removeAttribute = (attribute) => { edit(attribute.getFullStart(), attribute.end, ''); };
|
|
138
|
+
/** Delete a JSX child with the whitespace text before it, so no blank line is left behind. */
|
|
139
|
+
const removeChild = (element) => {
|
|
140
|
+
const parent = element.parent;
|
|
141
|
+
const children = ts.isJsxElement(parent) || ts.isJsxFragment(parent) ? parent.children : [];
|
|
142
|
+
const previous = children[children.indexOf(element) - 1];
|
|
143
|
+
const start = previous && ts.isJsxText(previous) && previous.containsOnlyTriviaWhiteSpaces ? previous.getFullStart() : element.getStart(source);
|
|
144
|
+
edit(start, element.end, '');
|
|
145
|
+
};
|
|
146
|
+
const retagName = (element, opening, tag) => {
|
|
147
|
+
edit(opening.tagName.getStart(source), opening.tagName.end, tag);
|
|
148
|
+
if (ts.isIdentifier(opening.tagName))
|
|
149
|
+
gone.add(opening.tagName);
|
|
150
|
+
if (ts.isJsxElement(element)) {
|
|
151
|
+
edit(element.closingElement.tagName.getStart(source), element.closingElement.tagName.end, tag);
|
|
152
|
+
if (ts.isIdentifier(element.closingElement.tagName))
|
|
153
|
+
gone.add(element.closingElement.tagName);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const resolve = (tag) => {
|
|
157
|
+
if (ts.isIdentifier(tag)) {
|
|
158
|
+
const binding = bindings.get(tag.text);
|
|
159
|
+
return binding && !shadowed(ts, tag) ? { name: binding.imported, namespace: null, binding } : null;
|
|
160
|
+
}
|
|
161
|
+
if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) && !shadowed(ts, tag.expression))
|
|
162
|
+
return { name: tag.name.text, namespace: tag.expression.text, binding: null };
|
|
163
|
+
return null;
|
|
164
|
+
};
|
|
165
|
+
const resolved = (child, name) => {
|
|
166
|
+
const element = ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) ? child : null;
|
|
167
|
+
if (!element)
|
|
168
|
+
return null;
|
|
169
|
+
const opening = ts.isJsxElement(element) ? element.openingElement : element;
|
|
170
|
+
const reference = resolve(opening.tagName);
|
|
171
|
+
return reference && reference.name === name ? { element, opening, reference } : null;
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* The `Section` props a `SectionHeader` becomes, or the reason it cannot: its eyebrow and context-intro composition are gone,
|
|
175
|
+
* a title is not a heading at rank 1, and a className or a spread has no home.
|
|
176
|
+
*/
|
|
177
|
+
const headProps = (opening) => {
|
|
178
|
+
if (spread(opening))
|
|
179
|
+
return { attrs: [], id: null, blocked: 'spread props may carry a prop Section lacks; rewrite by hand' };
|
|
180
|
+
const props = attributesOf(opening);
|
|
181
|
+
const attrs = [];
|
|
182
|
+
let id = null;
|
|
183
|
+
let rank = null;
|
|
184
|
+
for (const [name, attribute] of props) {
|
|
185
|
+
if (HEAD_PROPS.has(name))
|
|
186
|
+
attrs.push(attribute.getText(source));
|
|
187
|
+
else if (name === 'id')
|
|
188
|
+
id = attribute;
|
|
189
|
+
else if (name === 'level') {
|
|
190
|
+
const level = numberLiteral(attribute);
|
|
191
|
+
if (level === null)
|
|
192
|
+
return { attrs, id, blocked: 'level is not a number literal; Section takes headingLevel 2 to 6' };
|
|
193
|
+
if (level === 1)
|
|
194
|
+
return { attrs, id, blocked: 'a Section is never the page title (rank 1): use the page header, or headingLevel 2 under it' };
|
|
195
|
+
if (level !== 2)
|
|
196
|
+
rank = `headingLevel={${level}}`;
|
|
197
|
+
}
|
|
198
|
+
else if (name === 'eyebrow')
|
|
199
|
+
return { attrs, id, blocked: 'eyebrow is gone from the head: say it in the description or the page header' };
|
|
200
|
+
else if (name === 'composition')
|
|
201
|
+
return { attrs, id, blocked: 'composition is gone: the head has one anatomy' };
|
|
202
|
+
else
|
|
203
|
+
return { attrs, id, blocked: `${name} has no home on Section` };
|
|
204
|
+
}
|
|
205
|
+
if (!props.has('title'))
|
|
206
|
+
return { attrs, id, blocked: 'a Section needs a title' };
|
|
207
|
+
if (rank !== null)
|
|
208
|
+
attrs.push(rank);
|
|
209
|
+
return { attrs, id, blocked: null };
|
|
210
|
+
};
|
|
211
|
+
const visitRegion = (element, opening, reference) => {
|
|
212
|
+
const component = opening.tagName.getText(source);
|
|
213
|
+
const tag = use('Section', reference);
|
|
214
|
+
if (tag === null) {
|
|
215
|
+
flag(element, 'IMPORT_NAME_CONFLICT', 'Section is already bound to something that is not the grammar export; rewrite by hand', component);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (spread(opening)) {
|
|
219
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry a prop Section lacks; rewrite by hand', component);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const props = attributesOf(opening);
|
|
223
|
+
const others = [...props.keys()].filter(name => !REGION_DROPPED.has(name) && name !== 'id');
|
|
224
|
+
if (others.length) {
|
|
225
|
+
flag(element, 'REGION_PROPS', `${others.join(', ')} has no home on Section: a section has no props of its own beyond its head and id`, component);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const children = ts.isJsxElement(element) ? meaningful(element) : [];
|
|
229
|
+
const first = children[0] === undefined ? null : resolved(children[0], 'SectionHeader');
|
|
230
|
+
if (first === null) {
|
|
231
|
+
// No head to merge: the label becomes a title, which draws a visible heading the region did not have.
|
|
232
|
+
const label = props.get('label');
|
|
233
|
+
const value = label?.initializer;
|
|
234
|
+
if (!label || !value) {
|
|
235
|
+
flag(element, 'REGION_WITHOUT_HEAD', 'a Region named by labelledBy without a SectionHeader first in it: give the Section a title by hand', component);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
retagName(element, opening, tag);
|
|
239
|
+
edit(label.name.getStart(source), label.name.end, 'title');
|
|
240
|
+
for (const name of ['labelledBy', 'spacing']) {
|
|
241
|
+
const attribute = props.get(name);
|
|
242
|
+
if (attribute)
|
|
243
|
+
removeAttribute(attribute);
|
|
244
|
+
}
|
|
245
|
+
change(element, 'REGION_SECTION', 'Region label', 'Section title');
|
|
246
|
+
flag(opening, 'SECTION_NEW_VISIBLE_TITLE', 'the label is now the section\'s visible heading: check the page does not already show it, or drop the Section', component);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const head = headProps(first.opening);
|
|
250
|
+
if (head.blocked) {
|
|
251
|
+
consumed.add(first.element);
|
|
252
|
+
flag(first.element, 'SECTION_HEADER_KEPT', head.blocked, first.opening.tagName.getText(source));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
retagName(element, opening, tag);
|
|
256
|
+
for (const name of REGION_DROPPED) {
|
|
257
|
+
const attribute = props.get(name);
|
|
258
|
+
if (attribute)
|
|
259
|
+
removeAttribute(attribute);
|
|
260
|
+
}
|
|
261
|
+
const anchor = head.id !== null && !props.has('id') && !props.has('labelledBy') ? ` id=${head.id.initializer?.getText(source) ?? '""'}` : '';
|
|
262
|
+
edit(opening.tagName.end, opening.tagName.end, ` ${head.attrs.join(' ')}${anchor}`.replace(/\s+$/, ''));
|
|
263
|
+
removeChild(first.element);
|
|
264
|
+
consumed.add(first.element);
|
|
265
|
+
if (ts.isIdentifier(first.opening.tagName))
|
|
266
|
+
gone.add(first.opening.tagName);
|
|
267
|
+
if (ts.isJsxElement(first.element) && ts.isIdentifier(first.element.closingElement.tagName))
|
|
268
|
+
gone.add(first.element.closingElement.tagName);
|
|
269
|
+
change(element, 'REGION_SECTION', 'Region + SectionHeader', 'Section');
|
|
270
|
+
};
|
|
271
|
+
const visitStandaloneHeader = (element, opening, reference) => {
|
|
272
|
+
const component = opening.tagName.getText(source);
|
|
273
|
+
const head = headProps(opening);
|
|
274
|
+
if (head.blocked) {
|
|
275
|
+
flag(element, 'SECTION_HEADER_FREE', head.blocked, component);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const parent = element.parent;
|
|
279
|
+
const siblings = ts.isJsxElement(parent) || ts.isJsxFragment(parent) ? parent.children.filter(child => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces)) : [];
|
|
280
|
+
const plain = ts.isJsxFragment(parent) || (ts.isJsxElement(parent) && /^(?:div|section)$/.test(parent.openingElement.tagName.getText(source)) && parent.openingElement.attributes.properties.length === 0);
|
|
281
|
+
if (!plain || siblings[0] !== element || siblings.length < 2) {
|
|
282
|
+
flag(element, 'SECTION_HEADER_FREE', 'a heading that does not lead a plain wrapper of what it heads: wrap that content in a Section by hand', component);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const tag = use('Section', reference);
|
|
286
|
+
if (tag === null) {
|
|
287
|
+
flag(element, 'IMPORT_NAME_CONFLICT', 'Section is already bound to something that is not the grammar export; rewrite by hand', component);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const attrs = [...head.attrs, ...(head.id === null ? [] : [head.id.getText(source)])].join(' ');
|
|
291
|
+
if (ts.isJsxFragment(parent)) {
|
|
292
|
+
edit(parent.openingFragment.getStart(source), parent.openingFragment.end, `<${tag} ${attrs}>`);
|
|
293
|
+
edit(parent.closingFragment.getStart(source), parent.closingFragment.end, `</${tag}>`);
|
|
294
|
+
}
|
|
295
|
+
else if (ts.isJsxElement(parent)) {
|
|
296
|
+
edit(parent.openingElement.getStart(source), parent.openingElement.end, `<${tag} ${attrs}>`);
|
|
297
|
+
edit(parent.closingElement.getStart(source), parent.closingElement.end, `</${tag}>`);
|
|
298
|
+
}
|
|
299
|
+
removeChild(element);
|
|
300
|
+
consumed.add(element);
|
|
301
|
+
if (ts.isIdentifier(opening.tagName))
|
|
302
|
+
gone.add(opening.tagName);
|
|
303
|
+
if (ts.isJsxElement(element) && ts.isIdentifier(element.closingElement.tagName))
|
|
304
|
+
gone.add(element.closingElement.tagName);
|
|
305
|
+
change(element, 'SECTION_HEADER_SECTION', 'SectionHeader + siblings', 'Section');
|
|
306
|
+
};
|
|
307
|
+
const visitText = (element, opening) => {
|
|
308
|
+
const component = opening.tagName.getText(source);
|
|
309
|
+
const props = attributesOf(opening);
|
|
310
|
+
let strong = props.has('isStrong');
|
|
311
|
+
const weight = props.get('weight');
|
|
312
|
+
if (weight) {
|
|
313
|
+
const value = literal(weight);
|
|
314
|
+
if (value === 'medium' || value === 'semibold') {
|
|
315
|
+
change(weight, 'TEXT_IS_STRONG', weight.getText(source), 'isStrong');
|
|
316
|
+
edit(weight.getStart(source), weight.end, 'isStrong');
|
|
317
|
+
strong = true;
|
|
318
|
+
}
|
|
319
|
+
else if (value === 'normal') {
|
|
320
|
+
change(weight, 'TEXT_IS_STRONG', weight.getText(source), '');
|
|
321
|
+
removeAttribute(weight);
|
|
322
|
+
}
|
|
323
|
+
else
|
|
324
|
+
flag(weight, 'TEXT_WEIGHT_DYNAMIC', 'weight is not a literal: Text takes isStrong, a boolean', component);
|
|
325
|
+
}
|
|
326
|
+
if (props.has('parentEmphasis'))
|
|
327
|
+
flag(props.get('parentEmphasis') ?? opening, 'TEXT_PARENT_EMPHASIS', 'parentEmphasis is gone: the accent on a selected parent has no replacement; the selection draws itself', component);
|
|
328
|
+
if (literal(props.get('size')) === 'metric-lead')
|
|
329
|
+
flag(props.get('size') ?? opening, 'TEXT_METRIC_LEAD', 'size="metric-lead" is gone: a key figure is a SurfaceCard kind="figures" item', component);
|
|
330
|
+
if (literal(props.get('tone')) === 'accent')
|
|
331
|
+
flag(props.get('tone') ?? opening, 'TEXT_TONE_ACCENT', 'tone="accent" is gone: a mention is Text isStrong, a destination is Link', component);
|
|
332
|
+
// A strong block line that heads a group of blocks is a heading wearing a weight.
|
|
333
|
+
const as = literal(props.get('as'));
|
|
334
|
+
const parent = element.parent;
|
|
335
|
+
if (strong && (as === null && !props.has('as') || as === 'div' || as === 'p') && ts.isJsxElement(parent)) {
|
|
336
|
+
const siblings = meaningful(parent);
|
|
337
|
+
if (siblings[0] === element && siblings.length > 1)
|
|
338
|
+
flag(element, 'TEXT_HEADING_FAKE', 'a strong Text that heads a group: the group is a Section (its title), a card (its label) or a field (its label), not a weight', component);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const visit = (node) => {
|
|
342
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
343
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
344
|
+
const reference = resolve(opening.tagName);
|
|
345
|
+
if (reference && !consumed.has(node)) {
|
|
346
|
+
if (reference.name === 'Region')
|
|
347
|
+
visitRegion(node, opening, reference);
|
|
348
|
+
else if (reference.name === 'SectionHeader')
|
|
349
|
+
visitStandaloneHeader(node, opening, reference);
|
|
350
|
+
else if (reference.name === 'Text')
|
|
351
|
+
visitText(node, opening);
|
|
352
|
+
else if (UNWRAPPED.has(reference.name)) {
|
|
353
|
+
if (ts.isJsxElement(node)) {
|
|
354
|
+
edit(node.openingElement.getStart(source), node.openingElement.end, '<>');
|
|
355
|
+
edit(node.closingElement.getStart(source), node.closingElement.end, '</>');
|
|
356
|
+
}
|
|
357
|
+
else
|
|
358
|
+
edit(node.getStart(source), node.end, '<></>');
|
|
359
|
+
if (ts.isIdentifier(opening.tagName))
|
|
360
|
+
gone.add(opening.tagName);
|
|
361
|
+
if (ts.isJsxElement(node) && ts.isIdentifier(node.closingElement.tagName))
|
|
362
|
+
gone.add(node.closingElement.tagName);
|
|
363
|
+
change(node, 'PLACEMENT_PROVIDER_REMOVED', reference.name, 'fragment');
|
|
364
|
+
}
|
|
365
|
+
else if (REMOVED.has(reference.name)) {
|
|
366
|
+
const removed = REMOVED.get(reference.name);
|
|
367
|
+
if (removed)
|
|
368
|
+
flag(node, removed.code, removed.reason, opening.tagName.getText(source));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if ((ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && namespaces.has(node.expression.text)) || (ts.isQualifiedName(node) && namespaces.has(node.left.getText(source)))) {
|
|
373
|
+
const member = ts.isPropertyAccessExpression(node) ? node.name : node.right;
|
|
374
|
+
const removed = REMOVED.get(member.text);
|
|
375
|
+
const isTag = ts.isJsxOpeningElement(node.parent) || ts.isJsxSelfClosingElement(node.parent) || ts.isJsxClosingElement(node.parent);
|
|
376
|
+
if (removed && !isTag && ts.isQualifiedName(node))
|
|
377
|
+
flag(node, removed.code, removed.reason);
|
|
378
|
+
}
|
|
379
|
+
ts.forEachChild(node, visit);
|
|
380
|
+
};
|
|
381
|
+
visit(source);
|
|
382
|
+
// Imports left for a person: the removed names that are still referenced after the rewrites.
|
|
383
|
+
for (const binding of bindings.values()) {
|
|
384
|
+
const removed = REMOVED.get(binding.imported);
|
|
385
|
+
if (!removed)
|
|
386
|
+
continue;
|
|
387
|
+
const left = (references.get(binding.local) ?? []).filter(identifier => !gone.has(identifier));
|
|
388
|
+
const taggedOnly = left.every(identifier => ts.isJsxOpeningElement(identifier.parent) || ts.isJsxSelfClosingElement(identifier.parent) || ts.isJsxClosingElement(identifier.parent));
|
|
389
|
+
if (!left.length || !taggedOnly)
|
|
390
|
+
flag(binding.specifier, removed.code, removed.reason);
|
|
391
|
+
}
|
|
392
|
+
const dropped = new Set();
|
|
393
|
+
for (const binding of bindings.values()) {
|
|
394
|
+
if (!['Region', 'SectionHeader', ...UNWRAPPED].includes(binding.imported))
|
|
395
|
+
continue;
|
|
396
|
+
const refs = references.get(binding.local) ?? [];
|
|
397
|
+
if (!refs.length || refs.every(identifier => gone.has(identifier)))
|
|
398
|
+
dropped.add(binding.specifier);
|
|
399
|
+
}
|
|
400
|
+
for (const declaration of grammarDeclarations) {
|
|
401
|
+
const named = declaration.importClause?.namedBindings;
|
|
402
|
+
const clause = declaration.importClause;
|
|
403
|
+
if (!clause || !named || !ts.isNamedImports(named))
|
|
404
|
+
continue;
|
|
405
|
+
const adds = additions.get(declaration) ?? [];
|
|
406
|
+
if (!adds.length && !named.elements.some(specifier => dropped.has(specifier)))
|
|
407
|
+
continue;
|
|
408
|
+
const kept = named.elements.filter(specifier => !dropped.has(specifier)).map(specifier => specifier.getText(source));
|
|
409
|
+
const list = [...kept, ...adds];
|
|
410
|
+
if (!list.length) {
|
|
411
|
+
if (clause.name)
|
|
412
|
+
edit(clause.name.end, named.end, '');
|
|
413
|
+
else
|
|
414
|
+
edit(declaration.getStart(source), declaration.end + (text.startsWith('\r\n', declaration.end) ? 2 : text.startsWith('\n', declaration.end) ? 1 : 0), '');
|
|
415
|
+
}
|
|
416
|
+
else
|
|
417
|
+
edit(named.getStart(source), named.end, /\n/.test(named.getText(source)) ? `{\n ${list.join(',\n ')},\n}` : `{ ${list.join(', ')} }`);
|
|
418
|
+
}
|
|
419
|
+
edits.sort((first, second) => first.start - second.start || first.end - second.end);
|
|
420
|
+
const unique = [];
|
|
421
|
+
for (const current of edits) {
|
|
422
|
+
const previous = unique.at(-1);
|
|
423
|
+
if (previous && previous.start === current.start && previous.end === current.end && previous.text === current.text)
|
|
424
|
+
continue;
|
|
425
|
+
if (previous && current.start < previous.end) {
|
|
426
|
+
return { text, changes: [], findings: [...findings, { file, line: 1, column: 1, code: 'EDIT_CONFLICT', reason: 'two rewrites overlap (a nested grammar element inside a rewritten one): nothing in this file was changed; migrate by hand' }] };
|
|
427
|
+
}
|
|
428
|
+
unique.push(current);
|
|
429
|
+
}
|
|
430
|
+
let output = text;
|
|
431
|
+
for (const current of unique.reverse())
|
|
432
|
+
output = output.slice(0, current.start) + current.text + output.slice(current.end);
|
|
433
|
+
return { text: output, changes, findings };
|
|
434
|
+
}
|
|
435
|
+
function sourceFiles(directory) {
|
|
436
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
437
|
+
const target = path.join(directory, entry.name);
|
|
438
|
+
if (entry.isDirectory())
|
|
439
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
440
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
441
|
+
}).sort();
|
|
442
|
+
}
|
|
443
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
444
|
+
export function migrateSectionFrame({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
445
|
+
if (!ts)
|
|
446
|
+
throw new Error('section-frame needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
447
|
+
const frontend = path.join(root, 'fe');
|
|
448
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
449
|
+
throw new Error('section-frame runs at an app root with a fe/ directory');
|
|
450
|
+
const report = { schema: 'starci/section-frame-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
451
|
+
for (const target of sourceFiles(frontend)) {
|
|
452
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
453
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
454
|
+
const result = transformSectionFrame({ ts, text: original, file });
|
|
455
|
+
report.filesScanned += 1;
|
|
456
|
+
report.changes.push(...result.changes);
|
|
457
|
+
report.findings.push(...result.findings);
|
|
458
|
+
if (result.text !== original) {
|
|
459
|
+
report.filesChanged.push(file);
|
|
460
|
+
if (write)
|
|
461
|
+
fs.writeFileSync(target, result.text);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return report;
|
|
465
|
+
}
|
|
466
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
467
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
468
|
+
export function sectionFrameMarkdown(report) {
|
|
469
|
+
const out = ['# section-frame', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
470
|
+
if (report.findings.length) {
|
|
471
|
+
out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
|
|
472
|
+
for (const finding of report.findings)
|
|
473
|
+
out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
474
|
+
out.push('');
|
|
475
|
+
}
|
|
476
|
+
if (report.changes.length) {
|
|
477
|
+
out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
|
|
478
|
+
for (const item of report.changes)
|
|
479
|
+
out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
|
|
480
|
+
out.push('');
|
|
481
|
+
}
|
|
482
|
+
return `${out.join('\n')}\n`;
|
|
483
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type * as TsTypes from 'typescript';
|
|
2
|
+
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
3
|
+
type Ts = typeof import('typescript');
|
|
4
|
+
/** One safe rewrite of the report. */
|
|
5
|
+
export interface SmallRenamesChange {
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
column: number;
|
|
9
|
+
code: string;
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
}
|
|
13
|
+
/** One case left for a person. */
|
|
14
|
+
export interface SmallRenamesFinding {
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
column: number;
|
|
18
|
+
code: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
component?: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
}
|
|
23
|
+
/** The report of a migration (`starci app codemod small-renames`). */
|
|
24
|
+
export interface SmallRenamesReport {
|
|
25
|
+
schema: string;
|
|
26
|
+
root: string;
|
|
27
|
+
write: boolean;
|
|
28
|
+
filesScanned: number;
|
|
29
|
+
filesChanged: string[];
|
|
30
|
+
changes: SmallRenamesChange[];
|
|
31
|
+
findings: SmallRenamesFinding[];
|
|
32
|
+
}
|
|
33
|
+
export declare function bindingNames(ts: Ts, binding: TsTypes.BindingName): string[];
|
|
34
|
+
/** Whether a local declaration (a parameter, a block-level declaration, a loop variable) hides the import `identifier` names. */
|
|
35
|
+
export declare function shadowed(ts: Ts, identifier: TsTypes.Identifier): boolean;
|
|
36
|
+
/** Migrate the removed and renamed Grammar names with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
37
|
+
export declare function transformSmallRenames({ ts, text, file }: {
|
|
38
|
+
ts: Ts;
|
|
39
|
+
text: string;
|
|
40
|
+
file?: string;
|
|
41
|
+
}): {
|
|
42
|
+
text: string;
|
|
43
|
+
changes: SmallRenamesChange[];
|
|
44
|
+
findings: SmallRenamesFinding[];
|
|
45
|
+
};
|
|
46
|
+
/** Preview or apply the safe renames in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
47
|
+
export declare function migrateSmallRenames({ root, write, ts }: {
|
|
48
|
+
root: string;
|
|
49
|
+
write?: boolean;
|
|
50
|
+
ts?: Ts | null;
|
|
51
|
+
}): SmallRenamesReport;
|
|
52
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
53
|
+
export declare function smallRenamesMarkdown(report: SmallRenamesReport): string;
|
|
54
|
+
export {};
|