@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,50 @@
|
|
|
1
|
+
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
|
+
type Ts = typeof import('typescript');
|
|
3
|
+
/** One safe rewrite of the report. */
|
|
4
|
+
export interface PageNavigationChange {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
code: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
}
|
|
12
|
+
/** One case left for a person. */
|
|
13
|
+
export interface PageNavigationFinding {
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
code: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
component?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The report of a migration (`starci app codemod page-navigation`). */
|
|
23
|
+
export interface PageNavigationReport {
|
|
24
|
+
schema: string;
|
|
25
|
+
root: string;
|
|
26
|
+
write: boolean;
|
|
27
|
+
filesScanned: number;
|
|
28
|
+
filesChanged: string[];
|
|
29
|
+
changes: PageNavigationChange[];
|
|
30
|
+
findings: PageNavigationFinding[];
|
|
31
|
+
}
|
|
32
|
+
/** Migrate the page and navigation names with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
33
|
+
export declare function transformPageNavigation({ ts, text, file }: {
|
|
34
|
+
ts: Ts;
|
|
35
|
+
text: string;
|
|
36
|
+
file?: string;
|
|
37
|
+
}): {
|
|
38
|
+
text: string;
|
|
39
|
+
changes: PageNavigationChange[];
|
|
40
|
+
findings: PageNavigationFinding[];
|
|
41
|
+
};
|
|
42
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
43
|
+
export declare function migratePageNavigation({ root, write, ts }: {
|
|
44
|
+
root: string;
|
|
45
|
+
write?: boolean;
|
|
46
|
+
ts?: Ts | null;
|
|
47
|
+
}): PageNavigationReport;
|
|
48
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
49
|
+
export declare function pageNavigationMarkdown(report: PageNavigationReport): string;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
/** Layouts that became a kind of `Grid`: the JSX element is rewritten and the import follows. */
|
|
11
|
+
const GRID_KINDS = new Map([
|
|
12
|
+
['PrimaryRailLayout', { kind: 'main-rail', code: 'PRIMARY_RAIL_LAYOUT_GRID' }],
|
|
13
|
+
['FormGrid', { kind: 'form', code: 'FORM_GRID_GRID' }],
|
|
14
|
+
]);
|
|
15
|
+
/** Exports a person migrates: the finding sits on the import (and on a namespace member use). */
|
|
16
|
+
const REMOVED = new Map([
|
|
17
|
+
['PageHeader', { code: 'PAGE_HEADER_LOOSE', reason: 'PageHeader is Page internals: pass its props as the `header` of the route Page (data, not an element)' }],
|
|
18
|
+
['PageHeaderProps', { code: 'PAGE_HEADER_LOOSE', reason: 'PageHeaderProps is gone: type the header as PageHeaderContent' }],
|
|
19
|
+
['PageContainer', { code: 'PAGE_CONTAINER_LOOSE', reason: 'PageContainer is Page internals: the body of Page is the container (`measure` picks its width); a landing band is a Page section' }],
|
|
20
|
+
['PageContainerProps', { code: 'PAGE_CONTAINER_LOOSE', reason: 'PageContainerProps is gone: Page takes `measure` itself' }],
|
|
21
|
+
['Breadcrumbs', { code: 'BREADCRUMBS_LOOSE', reason: 'Breadcrumbs is Page internals: pass the trail as Page `header.breadcrumbs`' }],
|
|
22
|
+
['BreadcrumbsProps', { code: 'BREADCRUMBS_LOOSE', reason: 'BreadcrumbsProps is gone: type the trail as PageHeaderBreadcrumbs' }],
|
|
23
|
+
['FillViewport', { code: 'FILL_VIEWPORT_PAGE', reason: 'FillViewport is gone: a page that fills the region is `Page chrome="bleed"` (its body is the measured fill box; `minHeight` is its floor)' }],
|
|
24
|
+
['FillViewportProps', { code: 'FILL_VIEWPORT_PAGE', reason: 'FillViewportProps is gone: use PageProps with chrome="bleed"' }],
|
|
25
|
+
['FillViewportMinHeight', { code: 'FILL_VIEWPORT_PAGE', reason: 'FillViewportMinHeight is gone: use PageMinHeight' }],
|
|
26
|
+
['FillViewportBottomInset', { code: 'FILL_VIEWPORT_PAGE', reason: 'FillViewportBottomInset is gone: a bleed body reaches the viewport edge' }],
|
|
27
|
+
['Rail', { code: 'RAIL_GRID', reason: 'Rail is gone: the rail slot of `Grid kind="main-rail"` holds the subordinate content' }],
|
|
28
|
+
['RailProps', { code: 'RAIL_GRID', reason: 'RailProps is gone: the rail is the `rail` slot of Grid kind="main-rail"' }],
|
|
29
|
+
['NavigationFeatureNav', { code: 'NAVIGATION_FEATURE_NAV_TOP_BAR', reason: 'NavigationFeatureNav is gone: use TopBar (identity is `brand`, the compact trigger is `menu`, the feature row is `subnavigation` with `subnavigationLabel`)' }],
|
|
30
|
+
['NavigationFeatureNavProps', { code: 'NAVIGATION_FEATURE_NAV_TOP_BAR', reason: 'NavigationFeatureNavProps is gone: use TopBarProps' }],
|
|
31
|
+
['Subnav', { code: 'REMOVED_COMPONENT', reason: 'Subnav is gone: a section identity is the TopBar `menu`, a second layer is its `subnavigation`' }],
|
|
32
|
+
['SubnavProps', { code: 'REMOVED_COMPONENT', reason: 'SubnavProps is gone with Subnav' }],
|
|
33
|
+
['BottomNav', { code: 'REMOVED_COMPONENT', reason: 'BottomNav is gone: a bottom tab bar is refused (R316); navigate with the Sidebar drawer or TopBar menu' }],
|
|
34
|
+
['BottomNavItem', { code: 'REMOVED_COMPONENT', reason: 'BottomNavItem is gone with BottomNav' }],
|
|
35
|
+
['BottomNavProps', { code: 'REMOVED_COMPONENT', reason: 'BottomNavProps is gone with BottomNav' }],
|
|
36
|
+
['PrimaryRailLayoutProps', { code: 'PRIMARY_RAIL_LAYOUT_GRID', reason: 'PrimaryRailLayoutProps is gone: type the layout as GridProps' }],
|
|
37
|
+
['FormGridProps', { code: 'FORM_GRID_GRID', reason: 'FormGridProps is gone: type the layout as GridProps' }],
|
|
38
|
+
]);
|
|
39
|
+
/** Migrate the page and navigation names with source edits; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
40
|
+
export function transformPageNavigation({ ts, text, file = 'component.tsx' }) {
|
|
41
|
+
const changes = [];
|
|
42
|
+
const findings = [];
|
|
43
|
+
if (!text.includes('@starci/grammar'))
|
|
44
|
+
return { text, changes, findings };
|
|
45
|
+
const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
|
|
46
|
+
if (syntax.length)
|
|
47
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
|
|
48
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
49
|
+
const bindings = new Map();
|
|
50
|
+
const namespaces = new Set();
|
|
51
|
+
const foreign = new Set();
|
|
52
|
+
const grammarDeclarations = [];
|
|
53
|
+
for (const statement of source.statements) {
|
|
54
|
+
if (ts.isImportDeclaration(statement)) {
|
|
55
|
+
const clause = statement.importClause;
|
|
56
|
+
const grammar = ts.isStringLiteral(statement.moduleSpecifier) && GRAMMAR.test(statement.moduleSpecifier.text);
|
|
57
|
+
if (grammar)
|
|
58
|
+
grammarDeclarations.push(statement);
|
|
59
|
+
if (clause?.name)
|
|
60
|
+
foreign.add(clause.name.text);
|
|
61
|
+
const named = clause?.namedBindings;
|
|
62
|
+
if (named && ts.isNamespaceImport(named))
|
|
63
|
+
(grammar ? namespaces : foreign).add(named.name.text);
|
|
64
|
+
if (named && ts.isNamedImports(named)) {
|
|
65
|
+
for (const specifier of named.elements) {
|
|
66
|
+
if (grammar)
|
|
67
|
+
bindings.set(specifier.name.text, { imported: specifier.propertyName?.text ?? specifier.name.text, local: specifier.name.text, declaration: statement, specifier });
|
|
68
|
+
else
|
|
69
|
+
foreign.add(specifier.name.text);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!bindings.size && !namespaces.size)
|
|
75
|
+
return { text, changes, findings };
|
|
76
|
+
const edits = [];
|
|
77
|
+
const gone = new Set();
|
|
78
|
+
const additions = new Map();
|
|
79
|
+
const edit = (start, end, replacement) => { edits.push({ start, end, text: replacement }); };
|
|
80
|
+
const flag = (node, code, reason, component) => {
|
|
81
|
+
findings.push({ file, ...sourceLocation(source, node), code, reason, ...(component === undefined ? {} : { component }), source: node.getText(source).slice(0, 200) });
|
|
82
|
+
};
|
|
83
|
+
const boundTo = (name) => [...bindings.values()].find(binding => binding.imported === name);
|
|
84
|
+
/** The text to write for `Grid` next to a tag: the namespace member, an existing binding, or a new specifier in the same declaration. */
|
|
85
|
+
const gridFor = (binding, namespace) => {
|
|
86
|
+
if (namespace)
|
|
87
|
+
return `${namespace}.Grid`;
|
|
88
|
+
const existing = boundTo('Grid');
|
|
89
|
+
if (existing)
|
|
90
|
+
return existing.local;
|
|
91
|
+
if (foreign.has('Grid') || !binding)
|
|
92
|
+
return null;
|
|
93
|
+
if (!(additions.get(binding.declaration) ?? []).includes('Grid'))
|
|
94
|
+
additions.set(binding.declaration, [...additions.get(binding.declaration) ?? [], 'Grid']);
|
|
95
|
+
return 'Grid';
|
|
96
|
+
};
|
|
97
|
+
// Every reference of a bound name, bar property names and import positions, with the shadowed ones left out.
|
|
98
|
+
const references = new Map();
|
|
99
|
+
const isReference = (identifier) => {
|
|
100
|
+
const parent = identifier.parent;
|
|
101
|
+
if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent))
|
|
102
|
+
return false;
|
|
103
|
+
if ((ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent) || ts.isJsxAttribute(parent)) && parent.name === identifier)
|
|
104
|
+
return false;
|
|
105
|
+
return !(ts.isQualifiedName(parent) && parent.right === identifier);
|
|
106
|
+
};
|
|
107
|
+
const collect = (node) => {
|
|
108
|
+
if (ts.isImportDeclaration(node))
|
|
109
|
+
return;
|
|
110
|
+
if (ts.isIdentifier(node) && bindings.has(node.text) && isReference(node) && !shadowed(ts, node))
|
|
111
|
+
references.set(node.text, [...references.get(node.text) ?? [], node]);
|
|
112
|
+
ts.forEachChild(node, collect);
|
|
113
|
+
};
|
|
114
|
+
collect(source);
|
|
115
|
+
const visitElement = (element, opening) => {
|
|
116
|
+
const tag = opening.tagName;
|
|
117
|
+
let name = null;
|
|
118
|
+
let binding = null;
|
|
119
|
+
let namespace = null;
|
|
120
|
+
if (ts.isIdentifier(tag)) {
|
|
121
|
+
binding = bindings.get(tag.text) ?? null;
|
|
122
|
+
if (binding && !shadowed(ts, tag))
|
|
123
|
+
name = binding.imported;
|
|
124
|
+
else
|
|
125
|
+
binding = null;
|
|
126
|
+
}
|
|
127
|
+
else if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) && !shadowed(ts, tag.expression)) {
|
|
128
|
+
name = tag.name.text;
|
|
129
|
+
namespace = tag.expression.text;
|
|
130
|
+
}
|
|
131
|
+
if (name === null)
|
|
132
|
+
return;
|
|
133
|
+
const grid = GRID_KINDS.get(name);
|
|
134
|
+
if (grid === undefined)
|
|
135
|
+
return;
|
|
136
|
+
const props = opening.attributes.properties;
|
|
137
|
+
if (props.some(ts.isJsxSpreadAttribute)) {
|
|
138
|
+
flag(element, 'SPREAD_PROPS', `${name} takes a spread: rewrite by hand as Grid kind="${grid.kind}"`, name);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (props.some(prop => ts.isJsxAttribute(prop) && prop.name.getText(source) === 'className')) {
|
|
142
|
+
flag(element, 'LAYOUT_CLASS_NAME', `${name} carries a className and Grid takes none: the kind decides the layout; rewrite by hand as Grid kind="${grid.kind}"`, name);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const replacement = gridFor(binding, namespace);
|
|
146
|
+
if (replacement === null) {
|
|
147
|
+
flag(element, 'IMPORT_NAME_CONFLICT', 'Grid is already bound to something that is not the grammar export; rewrite by hand', name);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
edit(tag.getStart(source), tag.end, `${replacement} kind="${grid.kind}"`);
|
|
151
|
+
if (ts.isIdentifier(tag))
|
|
152
|
+
gone.add(tag);
|
|
153
|
+
if (ts.isJsxElement(element)) {
|
|
154
|
+
const closing = element.closingElement.tagName;
|
|
155
|
+
edit(closing.getStart(source), closing.end, replacement);
|
|
156
|
+
if (ts.isIdentifier(closing))
|
|
157
|
+
gone.add(closing);
|
|
158
|
+
}
|
|
159
|
+
changes.push({ file, ...sourceLocation(source, element), code: grid.code, from: name, to: `Grid kind="${grid.kind}"` });
|
|
160
|
+
};
|
|
161
|
+
const visit = (node) => {
|
|
162
|
+
if (ts.isJsxElement(node))
|
|
163
|
+
visitElement(node, node.openingElement);
|
|
164
|
+
else if (ts.isJsxSelfClosingElement(node))
|
|
165
|
+
visitElement(node, node);
|
|
166
|
+
// Namespace members outside a rewritten tag: removed exports (a tag is a property access too).
|
|
167
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && namespaces.has(node.expression.text) && !ts.isJsxClosingElement(node.parent)) {
|
|
168
|
+
const removed = REMOVED.get(node.name.text);
|
|
169
|
+
const rewritten = GRID_KINDS.has(node.name.text) && (ts.isJsxOpeningElement(node.parent) || ts.isJsxSelfClosingElement(node.parent)) && edits.some(item => item.start === node.getStart(source));
|
|
170
|
+
if (removed && !rewritten)
|
|
171
|
+
flag(node, removed.code, removed.reason);
|
|
172
|
+
}
|
|
173
|
+
ts.forEachChild(node, visit);
|
|
174
|
+
};
|
|
175
|
+
visit(source);
|
|
176
|
+
// Imports left for a person.
|
|
177
|
+
for (const binding of bindings.values()) {
|
|
178
|
+
const removed = REMOVED.get(binding.imported);
|
|
179
|
+
if (removed)
|
|
180
|
+
flag(binding.specifier, removed.code, removed.reason);
|
|
181
|
+
if (GRID_KINDS.has(binding.imported)) {
|
|
182
|
+
const left = (references.get(binding.local) ?? []).filter(identifier => !gone.has(identifier));
|
|
183
|
+
if (left.length)
|
|
184
|
+
flag(binding.specifier, GRID_KINDS.get(binding.imported)?.code ?? 'REMOVED_COMPONENT', `${binding.imported} is used outside a rewritten tag: use Grid kind="${GRID_KINDS.get(binding.imported)?.kind}" by hand`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Import declarations: drop the layouts nothing references any more, add Grid.
|
|
188
|
+
const dropped = new Set();
|
|
189
|
+
for (const binding of bindings.values()) {
|
|
190
|
+
if (!GRID_KINDS.has(binding.imported))
|
|
191
|
+
continue;
|
|
192
|
+
const refs = references.get(binding.local) ?? [];
|
|
193
|
+
if (refs.length && refs.every(identifier => gone.has(identifier)))
|
|
194
|
+
dropped.add(binding.specifier);
|
|
195
|
+
}
|
|
196
|
+
for (const declaration of grammarDeclarations) {
|
|
197
|
+
const clause = declaration.importClause;
|
|
198
|
+
const named = clause?.namedBindings;
|
|
199
|
+
if (!clause || !named || !ts.isNamedImports(named))
|
|
200
|
+
continue;
|
|
201
|
+
const adds = additions.get(declaration) ?? [];
|
|
202
|
+
if (!adds.length && !named.elements.some(specifier => dropped.has(specifier)))
|
|
203
|
+
continue;
|
|
204
|
+
const list = [...named.elements.filter(specifier => !dropped.has(specifier)).map(specifier => specifier.getText(source)), ...adds];
|
|
205
|
+
if (!list.length) {
|
|
206
|
+
if (clause.name)
|
|
207
|
+
edit(clause.name.end, named.end, '');
|
|
208
|
+
else
|
|
209
|
+
edit(declaration.getStart(source), declaration.end + (text.startsWith('\r\n', declaration.end) ? 2 : text.startsWith('\n', declaration.end) ? 1 : 0), '');
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
edit(named.getStart(source), named.end, /\n/.test(named.getText(source)) ? `{\n ${list.join(',\n ')},\n}` : `{ ${list.join(', ')} }`);
|
|
213
|
+
}
|
|
214
|
+
edits.sort((first, second) => first.start - second.start || first.end - second.end);
|
|
215
|
+
const unique = [];
|
|
216
|
+
for (const current of edits) {
|
|
217
|
+
const previous = unique.at(-1);
|
|
218
|
+
if (previous && previous.start === current.start && previous.end === current.end && previous.text === current.text)
|
|
219
|
+
continue;
|
|
220
|
+
if (previous && current.start < previous.end) {
|
|
221
|
+
return { text, changes: [], findings: [...findings, { file, line: 1, column: 1, code: 'EDIT_CONFLICT', reason: 'two rewrites overlap: nothing in this file was changed; migrate by hand' }] };
|
|
222
|
+
}
|
|
223
|
+
unique.push(current);
|
|
224
|
+
}
|
|
225
|
+
let output = text;
|
|
226
|
+
for (const current of unique.reverse())
|
|
227
|
+
output = output.slice(0, current.start) + current.text + output.slice(current.end);
|
|
228
|
+
return { text: output, changes, findings };
|
|
229
|
+
}
|
|
230
|
+
function sourceFiles(directory) {
|
|
231
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
232
|
+
const target = path.join(directory, entry.name);
|
|
233
|
+
if (entry.isDirectory())
|
|
234
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
235
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
236
|
+
}).sort();
|
|
237
|
+
}
|
|
238
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
239
|
+
export function migratePageNavigation({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
240
|
+
if (!ts)
|
|
241
|
+
throw new Error('page-navigation needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
242
|
+
const frontend = path.join(root, 'fe');
|
|
243
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
244
|
+
throw new Error('page-navigation runs at an app root with a fe/ directory');
|
|
245
|
+
const report = { schema: 'starci/page-navigation-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
246
|
+
for (const target of sourceFiles(frontend)) {
|
|
247
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
248
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
249
|
+
const result = transformPageNavigation({ ts, text: original, file });
|
|
250
|
+
report.filesScanned += 1;
|
|
251
|
+
report.changes.push(...result.changes);
|
|
252
|
+
report.findings.push(...result.findings);
|
|
253
|
+
if (result.text !== original) {
|
|
254
|
+
report.filesChanged.push(file);
|
|
255
|
+
if (write)
|
|
256
|
+
fs.writeFileSync(target, result.text);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return report;
|
|
260
|
+
}
|
|
261
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
262
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
263
|
+
export function pageNavigationMarkdown(report) {
|
|
264
|
+
const out = ['# page-navigation', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
265
|
+
if (report.findings.length) {
|
|
266
|
+
out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
|
|
267
|
+
for (const finding of report.findings)
|
|
268
|
+
out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
269
|
+
out.push('');
|
|
270
|
+
}
|
|
271
|
+
if (report.changes.length) {
|
|
272
|
+
out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
|
|
273
|
+
for (const item of report.changes)
|
|
274
|
+
out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
|
|
275
|
+
out.push('');
|
|
276
|
+
}
|
|
277
|
+
return `${out.join('\n')}\n`;
|
|
278
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
|
+
type Ts = typeof import('typescript');
|
|
3
|
+
/** One safe rewrite of the report. */
|
|
4
|
+
export interface SectionFrameChange {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
code: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
}
|
|
12
|
+
/** One case left for a person. */
|
|
13
|
+
export interface SectionFrameFinding {
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
code: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
component?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The report of a migration (`starci app codemod section-frame`). */
|
|
23
|
+
export interface SectionFrameReport {
|
|
24
|
+
schema: string;
|
|
25
|
+
root: string;
|
|
26
|
+
write: boolean;
|
|
27
|
+
filesScanned: number;
|
|
28
|
+
filesChanged: string[];
|
|
29
|
+
changes: SectionFrameChange[];
|
|
30
|
+
findings: SectionFrameFinding[];
|
|
31
|
+
}
|
|
32
|
+
/** Migrate Region, SectionHeader, the placement providers and `Text weight`; a case with no mechanical rewrite keeps its source and gains a finding. */
|
|
33
|
+
export declare function transformSectionFrame({ ts, text, file }: {
|
|
34
|
+
ts: Ts;
|
|
35
|
+
text: string;
|
|
36
|
+
file?: string;
|
|
37
|
+
}): {
|
|
38
|
+
text: string;
|
|
39
|
+
changes: SectionFrameChange[];
|
|
40
|
+
findings: SectionFrameFinding[];
|
|
41
|
+
};
|
|
42
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
43
|
+
export declare function migrateSectionFrame({ root, write, ts }: {
|
|
44
|
+
root: string;
|
|
45
|
+
write?: boolean;
|
|
46
|
+
ts?: Ts | null;
|
|
47
|
+
}): SectionFrameReport;
|
|
48
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
49
|
+
export declare function sectionFrameMarkdown(report: SectionFrameReport): string;
|
|
50
|
+
export {};
|