@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,614 @@
|
|
|
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
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
|
|
8
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
9
|
+
/** Exports renamed one to one: the import specifier and every reference move. */
|
|
10
|
+
const RENAMES = new Map([
|
|
11
|
+
['ChatBubble', 'MessageBubble'], ['ChatBubbleProps', 'MessageBubbleProps'], ['ChatBubbleSide', 'MessageBubbleSide'],
|
|
12
|
+
['flowGraphDiameter', 'bubbleDiameter'], ['orbitLayout', 'bubbleMapLayout'],
|
|
13
|
+
]);
|
|
14
|
+
/** Exports whose JSX uses are rewritten into another element: the import goes when nothing else uses it. */
|
|
15
|
+
const REPLACED = new Set(['ChatBubbleSkeletons', 'StatusDot', 'ConnectionStatus', 'CloseButton', 'TextAction', 'FlowGraph', 'SurfaceCopyGroup']);
|
|
16
|
+
/** Exports a person migrates: the finding sits on the import (and on a namespace member use). */
|
|
17
|
+
const REMOVED = new Map([
|
|
18
|
+
['Backdrop', { code: 'REMOVED_COMPONENT', reason: 'Backdrop is gone: a tinted brand ground is a soft card or Freestyle' }],
|
|
19
|
+
['RankArtwork', { code: 'REMOVED_COMPONENT', reason: 'RankArtwork is gone: move the artwork into the app as assets' }],
|
|
20
|
+
['IncludedMark', { code: 'REMOVED_COMPONENT', reason: 'IncludedMark is gone: use Icon with a check glyph' }],
|
|
21
|
+
['foldForSearch', { code: 'REMOVED_EXPORT', reason: 'foldForSearch is no longer exported from the grammar' }],
|
|
22
|
+
['ChatBubbleSkeletonsProps', { code: 'CHAT_BUBBLE_SKELETONS_PROPS', reason: 'ChatBubbleSkeletonsProps is gone: type the skeleton as MessageBubbleProps (isSkeleton, rows, hasAvatar)' }],
|
|
23
|
+
['ConnectionState', { code: 'CONNECTION_STATUS_TYPES', reason: 'ConnectionState is gone with ConnectionStatus: model the state as the Badge tone (success, warning, danger)' }],
|
|
24
|
+
['ConnectionStatusProps', { code: 'CONNECTION_STATUS_TYPES', reason: 'ConnectionStatusProps is gone with ConnectionStatus: use BadgeProps' }],
|
|
25
|
+
['FlowGraphNodeForm', { code: 'FLOW_GRAPH_TYPES', reason: 'FlowGraphNodeForm moved to the bubble map; choose BubbleMapNode field types by hand' }],
|
|
26
|
+
['FlowGraphFill', { code: 'FLOW_GRAPH_TYPES', reason: 'FlowGraphFill moved to the bubble map; choose BubbleMapNode field types by hand' }],
|
|
27
|
+
['FlowGraphRingPart', { code: 'FLOW_GRAPH_TYPES', reason: 'FlowGraphRingPart moved to the bubble map; choose BubbleMapNode field types by hand' }],
|
|
28
|
+
['FlowGraphNode', { code: 'BUBBLE_MAP_NODES', reason: 'FlowGraphNode fields form, mark, caption, diameter, fill and ring now belong to BubbleMapNode: move the nodes of a bubble map by hand' }],
|
|
29
|
+
]);
|
|
30
|
+
/** Components that lost `variant`: the surface context resolves it. */
|
|
31
|
+
const VARIANT_REMOVED = new Set(['Input', 'Textarea', 'SearchField', 'NumberField', 'DateField', 'TimeField', 'PressableField', 'Checkbox', 'CheckboxGroup', 'RadioGroup',
|
|
32
|
+
'Select', 'ComboBox', 'DatePicker', 'DateRangePicker', 'CopyField', 'OtpInput', 'Tabs']);
|
|
33
|
+
/** Components that lost `size="control"`: a bar sets it from context. */
|
|
34
|
+
const CONTROL_SIZED = new Set(['Button', 'IconButton', 'Input', 'SearchField', 'SegmentedControl']);
|
|
35
|
+
/** Components whose `control` step was their `lg`: the attribute becomes `size="lg"`, which draws the same. */
|
|
36
|
+
const CONTROL_IS_LG = new Set(['Switch', 'Avatar', 'AvatarButton']);
|
|
37
|
+
const TONES = new Map([['live', 'success'], ['reconnecting', 'warning'], ['lost', 'danger']]);
|
|
38
|
+
const OTP_RENAMES = new Map([['disabled', 'isDisabled'], ['invalid', 'isInvalid'], ['onChange', 'onValueChange']]);
|
|
39
|
+
const TEXT_ACTION_BLOCKERS = ['appearance', 'size', 'startContent', 'endContent', 'isPending', 'isSkeleton', 'download', 'onPress'];
|
|
40
|
+
const FORM_TAG = /^(?:[\w$]+\.)*(?:Form|Fieldset|form|fieldset)$/;
|
|
41
|
+
const BUTTON_GROUP_SHAPE = 'actions: ReadonlyArray<{ id: string; label: string; source?: IconSource; onPress?: () => void; isDisabled?: boolean; intent?: "commit" | "alternative" | "neutral" }>';
|
|
42
|
+
export function bindingNames(ts, binding) {
|
|
43
|
+
if (ts.isIdentifier(binding))
|
|
44
|
+
return [binding.text];
|
|
45
|
+
if (ts.isObjectBindingPattern(binding))
|
|
46
|
+
return binding.elements.flatMap(element => bindingNames(ts, element.name));
|
|
47
|
+
return binding.elements.flatMap((element) => ts.isBindingElement(element) ? bindingNames(ts, element.name) : []);
|
|
48
|
+
}
|
|
49
|
+
/** Whether a local declaration (a parameter, a block-level declaration, a loop variable) hides the import `identifier` names. */
|
|
50
|
+
export function shadowed(ts, identifier) {
|
|
51
|
+
const name = identifier.text;
|
|
52
|
+
for (let at = identifier.parent; at && !ts.isSourceFile(at); at = at.parent) {
|
|
53
|
+
if (ts.isFunctionLike(at) && (at.parameters.some(parameter => bindingNames(ts, parameter.name).includes(name)) || (ts.isFunctionExpression(at) && at.name?.text === name)))
|
|
54
|
+
return true;
|
|
55
|
+
if (ts.isCatchClause(at) && at.variableDeclaration && bindingNames(ts, at.variableDeclaration.name).includes(name))
|
|
56
|
+
return true;
|
|
57
|
+
for (const statement of ts.isBlock(at) ? at.statements : []) {
|
|
58
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name)
|
|
59
|
+
return true;
|
|
60
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name)))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if ((ts.isForStatement(at) || ts.isForOfStatement(at) || ts.isForInStatement(at)) && at.initializer && ts.isVariableDeclarationList(at.initializer)
|
|
64
|
+
&& at.initializer.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name)))
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/** Migrate the removed and renamed Grammar names with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
70
|
+
export function transformSmallRenames({ ts, text, file = 'component.tsx' }) {
|
|
71
|
+
const changes = [];
|
|
72
|
+
const findings = [];
|
|
73
|
+
if (!text.includes('@starci/grammar'))
|
|
74
|
+
return { text, changes, findings };
|
|
75
|
+
const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
|
|
76
|
+
if (syntax.length)
|
|
77
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
|
|
78
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
79
|
+
// Imports: grammar bindings by local name, grammar namespaces, and the names other modules or the file itself already own.
|
|
80
|
+
const bindings = new Map();
|
|
81
|
+
const namespaces = new Set();
|
|
82
|
+
const foreign = new Set();
|
|
83
|
+
const grammarDeclarations = [];
|
|
84
|
+
for (const statement of source.statements) {
|
|
85
|
+
if (ts.isImportDeclaration(statement)) {
|
|
86
|
+
const clause = statement.importClause;
|
|
87
|
+
const grammar = ts.isStringLiteral(statement.moduleSpecifier) && GRAMMAR.test(statement.moduleSpecifier.text);
|
|
88
|
+
if (grammar)
|
|
89
|
+
grammarDeclarations.push(statement);
|
|
90
|
+
if (clause?.name)
|
|
91
|
+
foreign.add(clause.name.text);
|
|
92
|
+
const named = clause?.namedBindings;
|
|
93
|
+
if (named && ts.isNamespaceImport(named))
|
|
94
|
+
(grammar ? namespaces : foreign).add(named.name.text);
|
|
95
|
+
if (named && ts.isNamedImports(named)) {
|
|
96
|
+
for (const specifier of named.elements) {
|
|
97
|
+
if (grammar)
|
|
98
|
+
bindings.set(specifier.name.text, { imported: specifier.propertyName?.text ?? specifier.name.text, local: specifier.name.text, declaration: statement, specifier });
|
|
99
|
+
else
|
|
100
|
+
foreign.add(specifier.name.text);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name)
|
|
105
|
+
foreign.add(statement.name.text);
|
|
106
|
+
else if (ts.isVariableStatement(statement))
|
|
107
|
+
for (const declaration of statement.declarationList.declarations)
|
|
108
|
+
for (const name of bindingNames(ts, declaration.name))
|
|
109
|
+
foreign.add(name);
|
|
110
|
+
}
|
|
111
|
+
if (!bindings.size && !namespaces.size)
|
|
112
|
+
return { text, changes, findings };
|
|
113
|
+
const edits = [];
|
|
114
|
+
const gone = new Set();
|
|
115
|
+
const additions = new Map();
|
|
116
|
+
const added = new Set();
|
|
117
|
+
const typeOnly = new Set();
|
|
118
|
+
const specifierRenames = new Map();
|
|
119
|
+
const edit = (start, end, replacement) => { edits.push({ start, end, text: replacement }); };
|
|
120
|
+
const flag = (node, code, reason, component) => {
|
|
121
|
+
findings.push({ file, ...sourceLocation(source, node), code, reason, ...(component === undefined ? {} : { component }), source: node.getText(source).slice(0, 200) });
|
|
122
|
+
};
|
|
123
|
+
const change = (node, code, from, to) => { changes.push({ file, ...sourceLocation(source, node), code, from, to }); };
|
|
124
|
+
const boundTo = (name) => [...bindings.values()].find(binding => binding.imported === name);
|
|
125
|
+
/** 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. */
|
|
126
|
+
const use = (name, reference) => {
|
|
127
|
+
if (reference.namespace)
|
|
128
|
+
return `${reference.namespace}.${name}`;
|
|
129
|
+
const existing = boundTo(name);
|
|
130
|
+
if (existing)
|
|
131
|
+
return existing.local;
|
|
132
|
+
if (added.has(name))
|
|
133
|
+
return name;
|
|
134
|
+
if (foreign.has(name) || !reference.binding)
|
|
135
|
+
return null;
|
|
136
|
+
added.add(name);
|
|
137
|
+
additions.set(reference.binding.declaration, [...additions.get(reference.binding.declaration) ?? [], name]);
|
|
138
|
+
return name;
|
|
139
|
+
};
|
|
140
|
+
// Every reference of a name, bar property names and import positions, with the shadowed ones left out.
|
|
141
|
+
const references = new Map();
|
|
142
|
+
const isReference = (identifier) => {
|
|
143
|
+
const parent = identifier.parent;
|
|
144
|
+
if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent))
|
|
145
|
+
return false;
|
|
146
|
+
if ((ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodDeclaration(parent)
|
|
147
|
+
|| ts.isMethodSignature(parent) || ts.isJsxAttribute(parent) || ts.isEnumMember(parent)) && parent.name === identifier)
|
|
148
|
+
return false;
|
|
149
|
+
if (ts.isQualifiedName(parent) && parent.right === identifier)
|
|
150
|
+
return false;
|
|
151
|
+
return !(ts.isBindingElement(parent) && parent.propertyName === identifier);
|
|
152
|
+
};
|
|
153
|
+
const collect = (node) => {
|
|
154
|
+
if (ts.isImportDeclaration(node))
|
|
155
|
+
return;
|
|
156
|
+
if (ts.isIdentifier(node) && bindings.has(node.text) && isReference(node) && !shadowed(ts, node))
|
|
157
|
+
references.set(node.text, [...references.get(node.text) ?? [], node]);
|
|
158
|
+
ts.forEachChild(node, collect);
|
|
159
|
+
};
|
|
160
|
+
collect(source);
|
|
161
|
+
const spread = (opening) => opening.attributes.properties.some(ts.isJsxSpreadAttribute);
|
|
162
|
+
const attributesOf = (opening) => new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map((prop) => [prop.name.getText(source), prop]));
|
|
163
|
+
const literal = (attribute) => {
|
|
164
|
+
const value = attribute?.initializer;
|
|
165
|
+
if (value && ts.isStringLiteralLike(value))
|
|
166
|
+
return value.text;
|
|
167
|
+
return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
|
|
168
|
+
};
|
|
169
|
+
const hasContent = (element) => ts.isJsxElement(element) && element.children.some(child => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces));
|
|
170
|
+
const removeAttribute = (attribute) => { edit(attribute.getFullStart(), attribute.end, ''); };
|
|
171
|
+
const renameAttribute = (attribute, name) => { edit(attribute.name.getStart(source), attribute.name.end, name); };
|
|
172
|
+
const retag = (element, opening, tag, extra = '') => {
|
|
173
|
+
edit(opening.tagName.getStart(source), opening.tagName.end, `${tag}${extra}`);
|
|
174
|
+
if (ts.isIdentifier(opening.tagName))
|
|
175
|
+
gone.add(opening.tagName);
|
|
176
|
+
if (ts.isJsxElement(element)) {
|
|
177
|
+
edit(element.closingElement.tagName.getStart(source), element.closingElement.tagName.end, tag);
|
|
178
|
+
if (ts.isIdentifier(element.closingElement.tagName))
|
|
179
|
+
gone.add(element.closingElement.tagName);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const resolve = (tag) => {
|
|
183
|
+
if (ts.isIdentifier(tag)) {
|
|
184
|
+
const binding = bindings.get(tag.text);
|
|
185
|
+
return binding && !shadowed(ts, tag) ? { name: binding.imported, namespace: null, binding } : null;
|
|
186
|
+
}
|
|
187
|
+
if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) && !shadowed(ts, tag.expression))
|
|
188
|
+
return { name: tag.name.text, namespace: tag.expression.text, binding: null };
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
const conflict = (node, name, component) => { flag(node, 'IMPORT_NAME_CONFLICT', `${name} is already bound to something that is not the grammar export; rewrite by hand`, component); };
|
|
192
|
+
// Exports renamed one to one.
|
|
193
|
+
for (const binding of bindings.values()) {
|
|
194
|
+
const to = RENAMES.get(binding.imported);
|
|
195
|
+
if (to === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
const aliased = binding.local !== binding.imported;
|
|
198
|
+
if (aliased || !references.get(binding.local)?.length) {
|
|
199
|
+
// An alias keeps its uses; an import nothing uses is only respelled.
|
|
200
|
+
specifierRenames.set(binding.specifier, `${binding.specifier.isTypeOnly ? 'type ' : ''}${to}${aliased ? ` as ${binding.local}` : ''}`);
|
|
201
|
+
change(binding.specifier, 'RENAMED_EXPORT', binding.imported, to);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const local = use(to, { name: binding.imported, namespace: null, binding });
|
|
205
|
+
if (local === null) {
|
|
206
|
+
conflict(binding.specifier, to, binding.imported);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
change(binding.specifier, 'RENAMED_EXPORT', binding.imported, to);
|
|
210
|
+
if (binding.specifier.isTypeOnly)
|
|
211
|
+
typeOnly.add(to);
|
|
212
|
+
for (const identifier of references.get(binding.local) ?? []) {
|
|
213
|
+
if (ts.isExportSpecifier(identifier.parent))
|
|
214
|
+
continue;
|
|
215
|
+
edit(identifier.getStart(source), identifier.end, ts.isShorthandPropertyAssignment(identifier.parent) ? `${identifier.text}: ${local}` : local);
|
|
216
|
+
gone.add(identifier);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const visitElement = (element, opening) => {
|
|
220
|
+
const reference = resolve(opening.tagName);
|
|
221
|
+
if (!reference)
|
|
222
|
+
return;
|
|
223
|
+
const name = reference.name;
|
|
224
|
+
const component = opening.tagName.getText(source);
|
|
225
|
+
const props = attributesOf(opening);
|
|
226
|
+
const has = (prop) => props.has(prop);
|
|
227
|
+
const drop = (prop, code) => { const attribute = props.get(prop); if (attribute) {
|
|
228
|
+
change(attribute, code, attribute.getText(source), '');
|
|
229
|
+
removeAttribute(attribute);
|
|
230
|
+
} };
|
|
231
|
+
const sizeValue = literal(props.get('size'));
|
|
232
|
+
if (VARIANT_REMOVED.has(name))
|
|
233
|
+
drop('variant', 'VARIANT_REMOVED');
|
|
234
|
+
if ((name === 'Button' || name === 'IconButton') && has('variant'))
|
|
235
|
+
flag(props.get('variant') ?? opening, 'BUTTON_VARIANT', 'run `starci app codemod button-intent` first: that codemod owns variant', component);
|
|
236
|
+
if (CONTROL_SIZED.has(name) && sizeValue === 'control')
|
|
237
|
+
drop('size', 'SIZE_CONTROL_REMOVED');
|
|
238
|
+
if (CONTROL_IS_LG.has(name) && sizeValue === 'control') {
|
|
239
|
+
const attribute = props.get('size');
|
|
240
|
+
if (attribute) {
|
|
241
|
+
change(attribute, 'SIZE_CONTROL_LG', attribute.getText(source), 'size="lg"');
|
|
242
|
+
edit(attribute.getStart(source), attribute.end, 'size="lg"');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
switch (name) {
|
|
246
|
+
case 'ChatBubble': {
|
|
247
|
+
const skeleton = props.get('isSkeleton');
|
|
248
|
+
const initializer = skeleton?.initializer;
|
|
249
|
+
const isOn = skeleton !== undefined && (initializer === undefined || (ts.isJsxExpression(initializer) && initializer.expression?.kind === ts.SyntaxKind.TrueKeyword));
|
|
250
|
+
const avatar = props.get('avatar');
|
|
251
|
+
if (isOn && avatar) {
|
|
252
|
+
change(avatar, 'CHAT_BUBBLE_SKELETON', avatar.getText(source), 'hasAvatar');
|
|
253
|
+
edit(avatar.getStart(source), avatar.end, 'hasAvatar');
|
|
254
|
+
}
|
|
255
|
+
else if (skeleton && avatar && !(initializer && ts.isJsxExpression(initializer) && initializer.expression?.kind === ts.SyntaxKind.FalseKeyword)) {
|
|
256
|
+
flag(avatar, 'CHAT_BUBBLE_SKELETON', 'isSkeleton is dynamic: a skeleton bubble takes hasAvatar instead of avatar', component);
|
|
257
|
+
}
|
|
258
|
+
if (isOn && hasContent(element))
|
|
259
|
+
flag(element, 'CHAT_BUBBLE_SKELETON_CHILDREN', 'a skeleton bubble takes no children: remove them', component);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'ChatBubbleSkeletons': {
|
|
263
|
+
if (spread(opening)) {
|
|
264
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry count; rewrite by hand', component);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (hasContent(element)) {
|
|
268
|
+
flag(element, 'CHAT_BUBBLE_SKELETON_CHILDREN', 'ChatBubbleSkeletons takes no children', component);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const tag = use('MessageBubble', reference);
|
|
272
|
+
if (tag === null) {
|
|
273
|
+
conflict(element, 'MessageBubble', component);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
const count = props.get('count');
|
|
277
|
+
retag(element, opening, tag, count ? ' isSkeleton' : ' isSkeleton rows={3}');
|
|
278
|
+
if (count)
|
|
279
|
+
renameAttribute(count, 'rows');
|
|
280
|
+
change(element, 'CHAT_BUBBLE_SKELETONS', 'ChatBubbleSkeletons', 'MessageBubble isSkeleton');
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case 'StatusDot': {
|
|
284
|
+
if (!hasContent(element) && !has('children'))
|
|
285
|
+
break;
|
|
286
|
+
if (spread(opening)) {
|
|
287
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry size or aria-label; rewrite by hand', component);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
if (has('children')) {
|
|
291
|
+
flag(element, 'STATUS_DOT_CHILDREN', 'children passed as an attribute; rewrite as Badge isDot by hand', component);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
const tag = use('Badge', reference);
|
|
295
|
+
if (tag === null) {
|
|
296
|
+
conflict(element, 'Badge', component);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
retag(element, opening, tag, ' isDot');
|
|
300
|
+
for (const prop of ['size', 'aria-label'])
|
|
301
|
+
drop(prop, 'STATUS_DOT_BADGE');
|
|
302
|
+
const echo = props.get('echo');
|
|
303
|
+
if (echo)
|
|
304
|
+
renameAttribute(echo, 'isLive');
|
|
305
|
+
change(element, 'STATUS_DOT_BADGE', 'StatusDot', 'Badge isDot');
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case 'ConnectionStatus': {
|
|
309
|
+
if (spread(opening)) {
|
|
310
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry state or label; rewrite by hand', component);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const state = literal(props.get('state'));
|
|
314
|
+
const tone = state === null ? undefined : TONES.get(state);
|
|
315
|
+
if (tone === undefined) {
|
|
316
|
+
flag(element, 'CONNECTION_STATUS_STATE', 'state is not a literal live, reconnecting or lost: map it to a Badge tone by hand', component);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
if (hasContent(element)) {
|
|
320
|
+
flag(element, 'CONNECTION_STATUS_STATE', 'ConnectionStatus takes no children', component);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const label = props.get('label');
|
|
324
|
+
const value = label?.initializer;
|
|
325
|
+
let child = null;
|
|
326
|
+
if (value)
|
|
327
|
+
child = ts.isStringLiteral(value) && !/[{}<>&\n]/.test(value.text) ? value.text : ts.isJsxExpression(value) ? value.getText(source) : `{${value.getText(source)}}`;
|
|
328
|
+
const tag = use('Badge', reference);
|
|
329
|
+
if (tag === null) {
|
|
330
|
+
conflict(element, 'Badge', component);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
const others = opening.attributes.properties.filter(prop => !(ts.isJsxAttribute(prop) && ['state', 'label'].includes(prop.name.getText(source)))).map(prop => ` ${prop.getText(source)}`).join('');
|
|
334
|
+
const head = `<${tag} isDot tone="${tone}"${state === 'lost' ? '' : ' isLive'}${others}`;
|
|
335
|
+
edit(element.getStart(source), element.end, child === null ? `${head} />` : `${head}>${child}</${tag}>`);
|
|
336
|
+
if (ts.isIdentifier(opening.tagName))
|
|
337
|
+
gone.add(opening.tagName);
|
|
338
|
+
if (ts.isJsxElement(element) && ts.isIdentifier(element.closingElement.tagName))
|
|
339
|
+
gone.add(element.closingElement.tagName);
|
|
340
|
+
change(element, 'CONNECTION_STATUS_BADGE', `ConnectionStatus state=${state}`, `Badge isDot tone=${tone}`);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case 'CloseButton': {
|
|
344
|
+
const tag = use('IconButton', reference);
|
|
345
|
+
if (tag === null) {
|
|
346
|
+
conflict(element, 'IconButton', component);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
retag(element, opening, tag, has('intent') ? '' : ' intent="dismiss"');
|
|
350
|
+
change(element, 'CLOSE_BUTTON_ICON_BUTTON', 'CloseButton', 'IconButton intent="dismiss"');
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'TextAction': {
|
|
354
|
+
const href = props.get('href');
|
|
355
|
+
if (!href && !spread(opening))
|
|
356
|
+
break;
|
|
357
|
+
if (spread(opening)) {
|
|
358
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry href or onPress; rewrite by hand', component);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
const target = props.get('target');
|
|
362
|
+
const external = literal(target) === '_blank';
|
|
363
|
+
const blockers = TEXT_ACTION_BLOCKERS.filter(has);
|
|
364
|
+
if (target && !external)
|
|
365
|
+
blockers.push('target');
|
|
366
|
+
if (has('rel') && !external)
|
|
367
|
+
blockers.push('rel');
|
|
368
|
+
const text = ts.isJsxElement(element) && element.children.length > 0 && element.children.every(child => ts.isJsxText(child)) && element.children.some(child => ts.isJsxText(child) && !child.containsOnlyTriviaWhiteSpaces);
|
|
369
|
+
if (blockers.length || !text) {
|
|
370
|
+
flag(element, 'TEXT_ACTION_LINK_PROPS', blockers.length ? `Link does not take ${blockers.join(', ')}: choose the Link kind or keep TextAction as a command` : 'Link takes string children only', component);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
const initializer = href?.initializer;
|
|
374
|
+
const expression = initializer && ts.isJsxExpression(initializer) ? initializer.expression : initializer;
|
|
375
|
+
if (!expression || !(ts.isStringLiteralLike(expression) || ts.isTemplateExpression(expression))) {
|
|
376
|
+
flag(element, 'TEXT_ACTION_DYNAMIC_HREF', 'href is not a string literal and may be undefined: a Link needs a destination, a command needs onPress', component);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
const tag = use('Link', reference);
|
|
380
|
+
if (tag === null) {
|
|
381
|
+
conflict(element, 'Link', component);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
retag(element, opening, tag);
|
|
385
|
+
if (target && external)
|
|
386
|
+
edit(target.getStart(source), target.end, 'kind="external"');
|
|
387
|
+
drop('rel', 'TEXT_ACTION_LINK');
|
|
388
|
+
change(element, 'TEXT_ACTION_LINK', 'TextAction', 'Link');
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case 'EmptyNotice':
|
|
392
|
+
drop('actionVariant', 'EMPTY_NOTICE_ACTION_VARIANT');
|
|
393
|
+
break;
|
|
394
|
+
case 'DragItem': {
|
|
395
|
+
const colour = props.get('leadingColor');
|
|
396
|
+
if (colour)
|
|
397
|
+
flag(colour, 'DRAG_ITEM_LEADING_COLOR', 'DragItem takes tone: "neutral" | "accent" | "success" | "warning" | "danger"; a colour cannot be mapped', component);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'SegmentedControl': {
|
|
401
|
+
if (sizeValue === 'lg')
|
|
402
|
+
drop('size', 'SEGMENTED_SIZE');
|
|
403
|
+
let inForm = false;
|
|
404
|
+
for (let at = element.parent; at && !inForm; at = at.parent)
|
|
405
|
+
inForm = ts.isJsxElement(at) && FORM_TAG.test(at.openingElement.tagName.getText(source));
|
|
406
|
+
if (!inForm)
|
|
407
|
+
flag(opening, 'SEGMENTED_FILTER_OR_VALUE', 'a form value stays a SegmentedControl; a filter becomes FilterButtonGroup: decide which this is', component);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'ButtonGroup':
|
|
411
|
+
if (hasContent(element) || has('children'))
|
|
412
|
+
flag(element, 'BUTTON_GROUP_CHILDREN', `ButtonGroup takes an actions array now: ${BUTTON_GROUP_SHAPE}`, component);
|
|
413
|
+
break;
|
|
414
|
+
case 'TagGroup': {
|
|
415
|
+
const selection = ['selectionMode', 'selectedIds', 'defaultSelectedIds', 'onSelectionChange'].filter(has);
|
|
416
|
+
if (selection.length)
|
|
417
|
+
flag(element, 'TAG_GROUP_SELECTION', `TagGroup lost ${selection.join(', ')}: a selectable group becomes FilterButtonGroup`, component);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'Input': {
|
|
421
|
+
const invalid = props.get('isError');
|
|
422
|
+
if (invalid) {
|
|
423
|
+
renameAttribute(invalid, 'isInvalid');
|
|
424
|
+
change(invalid, 'FIELD_VOCABULARY', 'isError', 'isInvalid');
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case 'OtpInput':
|
|
429
|
+
for (const [from, to] of OTP_RENAMES) {
|
|
430
|
+
const attribute = props.get(from);
|
|
431
|
+
if (attribute) {
|
|
432
|
+
renameAttribute(attribute, to);
|
|
433
|
+
change(attribute, 'FIELD_VOCABULARY', from, to);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
case 'FlowGraph': {
|
|
438
|
+
const layout = props.get('layout');
|
|
439
|
+
if (!layout)
|
|
440
|
+
break;
|
|
441
|
+
const value = literal(layout);
|
|
442
|
+
if (value === null) {
|
|
443
|
+
flag(layout, 'BUBBLE_MAP_LAYOUT', 'layout is not a literal: only layout="orbit" becomes BubbleMap', component);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
if (value !== 'orbit')
|
|
447
|
+
break;
|
|
448
|
+
if (spread(opening)) {
|
|
449
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry props BubbleMap lacks; rewrite by hand', component);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
const invalid = ['direction', 'headers'].filter(has);
|
|
453
|
+
if (invalid.length) {
|
|
454
|
+
flag(element, 'BUBBLE_MAP_PROPS', `BubbleMap does not take ${invalid.join(', ')}: remove them, then rewrite as BubbleMap`, component);
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
const tag = use('BubbleMap', reference);
|
|
458
|
+
if (tag === null) {
|
|
459
|
+
conflict(element, 'BubbleMap', component);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
retag(element, opening, tag);
|
|
463
|
+
drop('layout', 'FLOW_GRAPH_BUBBLE_MAP');
|
|
464
|
+
change(element, 'FLOW_GRAPH_BUBBLE_MAP', 'FlowGraph layout="orbit"', 'BubbleMap');
|
|
465
|
+
flag(props.get('nodes') ?? opening, 'BUBBLE_MAP_NODES', 'FlowGraphNode fields form, mark, caption, diameter, fill and ring now belong to BubbleMapNode: check the nodes', component);
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
case 'SurfaceCopyGroup': {
|
|
469
|
+
const density = props.get('density');
|
|
470
|
+
const value = density ? literal(density) : 'compact';
|
|
471
|
+
if (spread(opening) || [...props.keys()].some(prop => prop !== 'density') || (value !== 'comfortable' && value !== 'compact')) {
|
|
472
|
+
flag(element, 'REMOVED_COMPONENT', 'SurfaceCopyGroup is gone: use Stack gap="inline" for density compact, gap="row" for comfortable', component);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
const tag = use('Stack', reference);
|
|
476
|
+
if (tag === null) {
|
|
477
|
+
conflict(element, 'Stack', component);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
const gap = value === 'comfortable' ? 'row' : 'inline';
|
|
481
|
+
retag(element, opening, tag, density ? '' : ` gap="${gap}"`);
|
|
482
|
+
if (density)
|
|
483
|
+
edit(density.getStart(source), density.end, `gap="${gap}"`);
|
|
484
|
+
change(element, 'SURFACE_COPY_GROUP_STACK', 'SurfaceCopyGroup', `Stack gap="${gap}"`);
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
default:
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const visit = (node) => {
|
|
491
|
+
if (ts.isJsxElement(node))
|
|
492
|
+
visitElement(node, node.openingElement);
|
|
493
|
+
else if (ts.isJsxSelfClosingElement(node))
|
|
494
|
+
visitElement(node, node);
|
|
495
|
+
// Namespace members outside JSX tags: renames and removed exports (a tag is a property access too).
|
|
496
|
+
if ((ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && namespaces.has(node.expression.text)) || (ts.isQualifiedName(node) && namespaces.has(node.left.getText(source)))) {
|
|
497
|
+
const member = ts.isPropertyAccessExpression(node) ? node.name : node.right;
|
|
498
|
+
const to = RENAMES.get(member.text);
|
|
499
|
+
const removed = REMOVED.get(member.text);
|
|
500
|
+
if (to !== undefined) {
|
|
501
|
+
edit(member.getStart(source), member.end, to);
|
|
502
|
+
if (!ts.isJsxClosingElement(node.parent))
|
|
503
|
+
change(node, 'RENAMED_EXPORT', member.text, to);
|
|
504
|
+
}
|
|
505
|
+
if (removed && !ts.isJsxClosingElement(node.parent))
|
|
506
|
+
flag(node, removed.code, removed.reason);
|
|
507
|
+
}
|
|
508
|
+
ts.forEachChild(node, visit);
|
|
509
|
+
};
|
|
510
|
+
visit(source);
|
|
511
|
+
// Imports left for a person, and the SurfaceCopyGroup uses no element rewrite covered.
|
|
512
|
+
for (const binding of bindings.values()) {
|
|
513
|
+
const removed = REMOVED.get(binding.imported);
|
|
514
|
+
if (removed)
|
|
515
|
+
flag(binding.specifier, removed.code, removed.reason);
|
|
516
|
+
if (binding.imported === 'SurfaceCopyGroup') {
|
|
517
|
+
const left = (references.get(binding.local) ?? []).filter(identifier => !gone.has(identifier) && !(ts.isJsxOpeningElement(identifier.parent) || ts.isJsxSelfClosingElement(identifier.parent) || ts.isJsxClosingElement(identifier.parent)));
|
|
518
|
+
if (left.length)
|
|
519
|
+
flag(binding.specifier, 'REMOVED_COMPONENT', 'SurfaceCopyGroup is gone and is used outside a JSX tag: use Stack gap="inline" or gap="row"');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Import declarations: drop the specifiers nothing references any more, add the new ones, spell an aliased rename.
|
|
523
|
+
const dropped = new Set();
|
|
524
|
+
for (const binding of bindings.values()) {
|
|
525
|
+
if (!(RENAMES.has(binding.imported) || REPLACED.has(binding.imported)) || specifierRenames.has(binding.specifier))
|
|
526
|
+
continue;
|
|
527
|
+
const refs = references.get(binding.local) ?? [];
|
|
528
|
+
if (refs.length && refs.every(identifier => gone.has(identifier)))
|
|
529
|
+
dropped.add(binding.specifier);
|
|
530
|
+
}
|
|
531
|
+
for (const declaration of grammarDeclarations) {
|
|
532
|
+
const named = declaration.importClause?.namedBindings;
|
|
533
|
+
const clause = declaration.importClause;
|
|
534
|
+
if (!clause || !named || !ts.isNamedImports(named))
|
|
535
|
+
continue;
|
|
536
|
+
const adds = additions.get(declaration) ?? [];
|
|
537
|
+
if (!adds.length && !named.elements.some(specifier => dropped.has(specifier) || specifierRenames.has(specifier)))
|
|
538
|
+
continue;
|
|
539
|
+
const kept = named.elements.filter(specifier => !dropped.has(specifier)).map(specifier => specifierRenames.get(specifier) ?? specifier.getText(source));
|
|
540
|
+
const list = [...kept, ...adds.map(name => typeOnly.has(name) ? `type ${name}` : name)];
|
|
541
|
+
if (!list.length) {
|
|
542
|
+
if (clause.name)
|
|
543
|
+
edit(clause.name.end, named.end, '');
|
|
544
|
+
else
|
|
545
|
+
edit(declaration.getStart(source), declaration.end + (text.startsWith('\r\n', declaration.end) ? 2 : text.startsWith('\n', declaration.end) ? 1 : 0), '');
|
|
546
|
+
}
|
|
547
|
+
else
|
|
548
|
+
edit(named.getStart(source), named.end, /\n/.test(named.getText(source)) ? `{\n ${list.join(',\n ')},\n}` : `{ ${list.join(', ')} }`);
|
|
549
|
+
}
|
|
550
|
+
edits.sort((first, second) => first.start - second.start || first.end - second.end);
|
|
551
|
+
const unique = [];
|
|
552
|
+
for (const current of edits) {
|
|
553
|
+
const previous = unique.at(-1);
|
|
554
|
+
if (previous && previous.start === current.start && previous.end === current.end && previous.text === current.text)
|
|
555
|
+
continue;
|
|
556
|
+
if (previous && current.start < previous.end) {
|
|
557
|
+
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' }] };
|
|
558
|
+
}
|
|
559
|
+
unique.push(current);
|
|
560
|
+
}
|
|
561
|
+
let output = text;
|
|
562
|
+
for (const current of unique.reverse())
|
|
563
|
+
output = output.slice(0, current.start) + current.text + output.slice(current.end);
|
|
564
|
+
return { text: output, changes, findings };
|
|
565
|
+
}
|
|
566
|
+
function sourceFiles(directory) {
|
|
567
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
568
|
+
const target = path.join(directory, entry.name);
|
|
569
|
+
if (entry.isDirectory())
|
|
570
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
571
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
572
|
+
}).sort();
|
|
573
|
+
}
|
|
574
|
+
/** Preview or apply the safe renames in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
575
|
+
export function migrateSmallRenames({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
576
|
+
if (!ts)
|
|
577
|
+
throw new Error('small-renames needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
578
|
+
const frontend = path.join(root, 'fe');
|
|
579
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
580
|
+
throw new Error('small-renames runs at an app root with a fe/ directory');
|
|
581
|
+
const report = { schema: 'starci/small-renames-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
582
|
+
for (const target of sourceFiles(frontend)) {
|
|
583
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
584
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
585
|
+
const result = transformSmallRenames({ ts, text: original, file });
|
|
586
|
+
report.filesScanned += 1;
|
|
587
|
+
report.changes.push(...result.changes);
|
|
588
|
+
report.findings.push(...result.findings);
|
|
589
|
+
if (result.text !== original) {
|
|
590
|
+
report.filesChanged.push(file);
|
|
591
|
+
if (write)
|
|
592
|
+
fs.writeFileSync(target, result.text);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return report;
|
|
596
|
+
}
|
|
597
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
598
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
599
|
+
export function smallRenamesMarkdown(report) {
|
|
600
|
+
const out = ['# small-renames', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
601
|
+
if (report.findings.length) {
|
|
602
|
+
out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
|
|
603
|
+
for (const finding of report.findings)
|
|
604
|
+
out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
605
|
+
out.push('');
|
|
606
|
+
}
|
|
607
|
+
if (report.changes.length) {
|
|
608
|
+
out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
|
|
609
|
+
for (const item of report.changes)
|
|
610
|
+
out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
|
|
611
|
+
out.push('');
|
|
612
|
+
}
|
|
613
|
+
return `${out.join('\n')}\n`;
|
|
614
|
+
}
|