@starci/hfs 4.16.4 → 4.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -3
- package/README.md +4 -4
- package/codemod/loading-states.d.mts +50 -0
- package/codemod/loading-states.mjs +384 -0
- package/codemod/main.d.mts +1 -1
- package/codemod/main.mjs +46 -5
- package/codemod/page-navigation.d.mts +50 -0
- package/codemod/page-navigation.mjs +278 -0
- package/codemod/section-frame.d.mts +50 -0
- package/codemod/section-frame.mjs +483 -0
- package/codemod/small-renames.d.mts +54 -0
- package/codemod/small-renames.mjs +614 -0
- package/codemod/surface-card-kinds.d.mts +49 -0
- package/codemod/surface-card-kinds.mjs +404 -0
- package/codemod/theme-tokens.d.mts +45 -0
- package/codemod/theme-tokens.mjs +114 -0
- package/codemod/tier-binding.d.mts +50 -0
- package/codemod/tier-binding.mjs +214 -0
- package/package.json +1 -1
- package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +343 -26
- package/runtime/knowledge/hfs/slots.yaml +12 -5
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +23 -13
- package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
- package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
- package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
- package/runtime/scripts/hfs/architecture/index.mjs +6 -0
- package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
- package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
- package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
- package/scaffold/add-fe-layout.mjs +3 -3
- package/scaffold/add-fe-overlay.mjs +4 -4
- package/scaffold/add-fe-page.mjs +1 -1
- package/scaffold/add-fe-unit.d.mts +2 -1
- package/scaffold/add-fe-unit.mjs +14 -12
- package/scaffold/add.d.mts +1 -1
- package/scaffold/add.mjs +1 -1
- package/src/main.mjs +4 -4
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
- package/templates/fe/units/block/component.tsx.tpl +2 -3
- package/templates/fe/units/branch/index.tsx.tpl +4 -3
- package/templates/fe/units/composite/index.tsx.tpl +3 -3
- package/templates/fe/units/frame/index.tsx.tpl +10 -0
- package/templates/fe/units/layout/index.tsx.tpl +2 -2
- package/templates/fe/units/leaf/index.tsx.tpl +3 -3
- package/templates/fe/units/page/component.tsx.tpl +2 -2
- package/templates/fe/units/page/index.tsx.tpl +2 -2
- package/templates/fe/units/page/stories.tsx.tpl +2 -2
- package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
- package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
- package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
- package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
- /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
type Ts = typeof import('typescript');
|
|
2
|
+
/** One safe rewrite of the report. */
|
|
3
|
+
export interface SurfaceCardKindsChange {
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
code: string;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
}
|
|
11
|
+
/** One case left for a person. */
|
|
12
|
+
export interface SurfaceCardKindsFinding {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
column: number;
|
|
16
|
+
code: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
component?: string;
|
|
19
|
+
source?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The report of a migration (`starci app codemod surface-card-kinds`). */
|
|
22
|
+
export interface SurfaceCardKindsReport {
|
|
23
|
+
schema: string;
|
|
24
|
+
root: string;
|
|
25
|
+
write: boolean;
|
|
26
|
+
filesScanned: number;
|
|
27
|
+
filesChanged: string[];
|
|
28
|
+
changes: SurfaceCardKindsChange[];
|
|
29
|
+
findings: SurfaceCardKindsFinding[];
|
|
30
|
+
}
|
|
31
|
+
/** Migrate the list, stat-strip and dropped-prop uses of the card with source edits; every other case gains a finding. */
|
|
32
|
+
export declare function transformSurfaceCardKinds({ ts, text, file }: {
|
|
33
|
+
ts: Ts;
|
|
34
|
+
text: string;
|
|
35
|
+
file?: string;
|
|
36
|
+
}): {
|
|
37
|
+
text: string;
|
|
38
|
+
changes: SurfaceCardKindsChange[];
|
|
39
|
+
findings: SurfaceCardKindsFinding[];
|
|
40
|
+
};
|
|
41
|
+
/** Preview or apply the safe card rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
42
|
+
export declare function migrateSurfaceCardKinds({ root, write, ts }: {
|
|
43
|
+
root: string;
|
|
44
|
+
write?: boolean;
|
|
45
|
+
ts?: Ts | null;
|
|
46
|
+
}): SurfaceCardKindsReport;
|
|
47
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
48
|
+
export declare function surfaceCardKindsMarkdown(report: SurfaceCardKindsReport): string;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,404 @@
|
|
|
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
|
+
/** Props of `SurfaceListCard` that have no counterpart on `SurfaceCard kind="list"`. */
|
|
10
|
+
const LIST_DROPPED = new Set(['depth', 'labelHidden', 'isVerdict', 'isScrollable']);
|
|
11
|
+
/** Props of `ListRow` a mechanical rewrite can carry into a list item. */
|
|
12
|
+
const ROW_PROPS = new Set(['title', 'description', 'state', 'verdict', 'key']);
|
|
13
|
+
/** Props of the card that are gone: dropped when they only restate the default, reported otherwise. */
|
|
14
|
+
const CARD_GONE = new Map([
|
|
15
|
+
['composition', { code: 'JOINED_TO_BANDS', reason: 'composition="joined" is gone: a joined card is kind="bands" (body, figures or media faces, then at most one collection); each one needs a look', defaultValue: 'single' }],
|
|
16
|
+
['placement', { code: 'PANEL_CARD', reason: 'placement="panel" is gone: a docked panel is a Pane of a PaneGrid, not a card', defaultValue: 'floating' }],
|
|
17
|
+
['scroll', { code: 'CARD_SCROLL', reason: 'scroll is gone: a card sizes to its content; a region that scrolls is a Pane', defaultValue: 'page' }],
|
|
18
|
+
['isScrollable', { code: 'CARD_SCROLL', reason: 'isScrollable is gone: a card sizes to its content; a region that scrolls is a Pane' }],
|
|
19
|
+
]);
|
|
20
|
+
const REMOVED = new Map([
|
|
21
|
+
['SurfaceListCard', 'SurfaceListCard is gone: a list is SurfaceCard kind="list"'],
|
|
22
|
+
['SurfaceAccordionCard', 'SurfaceAccordionCard is gone: an accordion is SurfaceCard kind="accordion" with title, meta and body per item'],
|
|
23
|
+
['StaticStateRow', 'StaticStateRow is gone: a row is an item of SurfaceCard kind="list"'],
|
|
24
|
+
['ListRow', 'ListRow is internal: a row is an item of SurfaceCard kind="list"'],
|
|
25
|
+
]);
|
|
26
|
+
/** Migrate the list, stat-strip and dropped-prop uses of the card with source edits; every other case gains a finding. */
|
|
27
|
+
export function transformSurfaceCardKinds({ ts, text, file = 'component.tsx' }) {
|
|
28
|
+
const changes = [];
|
|
29
|
+
const findings = [];
|
|
30
|
+
if (!text.includes('@starci/grammar'))
|
|
31
|
+
return { text, changes, findings };
|
|
32
|
+
const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
|
|
33
|
+
if (syntax.length)
|
|
34
|
+
return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
|
|
35
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
36
|
+
const bindings = new Map();
|
|
37
|
+
const namespaces = new Set();
|
|
38
|
+
const declarations = [];
|
|
39
|
+
for (const statement of source.statements) {
|
|
40
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !GRAMMAR.test(statement.moduleSpecifier.text))
|
|
41
|
+
continue;
|
|
42
|
+
declarations.push(statement);
|
|
43
|
+
const named = statement.importClause?.namedBindings;
|
|
44
|
+
if (named && ts.isNamespaceImport(named))
|
|
45
|
+
namespaces.add(named.name.text);
|
|
46
|
+
if (named && ts.isNamedImports(named))
|
|
47
|
+
for (const specifier of named.elements)
|
|
48
|
+
bindings.set(specifier.name.text, { imported: specifier.propertyName?.text ?? specifier.name.text, specifier, declaration: statement });
|
|
49
|
+
}
|
|
50
|
+
if (!bindings.size && !namespaces.size)
|
|
51
|
+
return { text, changes, findings };
|
|
52
|
+
const edits = [];
|
|
53
|
+
const edit = (start, end, replacement) => { edits.push({ start, end, text: replacement }); };
|
|
54
|
+
const flag = (node, code, reason, component) => {
|
|
55
|
+
findings.push({ file, ...sourceLocation(source, node), code, reason, ...(component === undefined ? {} : { component }), source: node.getText(source).slice(0, 200) });
|
|
56
|
+
};
|
|
57
|
+
const change = (node, code, from, to) => { changes.push({ file, ...sourceLocation(source, node), code, from, to }); };
|
|
58
|
+
const gone = new Set();
|
|
59
|
+
let needsCard = false;
|
|
60
|
+
const exportOf = (tag) => {
|
|
61
|
+
if (ts.isIdentifier(tag))
|
|
62
|
+
return bindings.get(tag.text)?.imported ?? null;
|
|
63
|
+
return ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) ? tag.name.text : null;
|
|
64
|
+
};
|
|
65
|
+
const cardLocal = [...bindings.values()].find(binding => binding.imported === 'SurfaceCard');
|
|
66
|
+
const cardTag = (tag) => ts.isPropertyAccessExpression(tag) ? `${tag.expression.getText(source)}.SurfaceCard` : cardLocal ? cardLocal.specifier.name.text : 'SurfaceCard';
|
|
67
|
+
const attributes = (opening) => new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map((prop) => [prop.name.getText(source), prop]));
|
|
68
|
+
const spread = (opening) => opening.attributes.properties.some(ts.isJsxSpreadAttribute);
|
|
69
|
+
const literal = (attribute) => {
|
|
70
|
+
const value = attribute?.initializer;
|
|
71
|
+
if (value && ts.isStringLiteralLike(value))
|
|
72
|
+
return value.text;
|
|
73
|
+
return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
|
|
74
|
+
};
|
|
75
|
+
/** The expression text of a prop value: a string literal as written, `{x}` as `x`. */
|
|
76
|
+
const valueText = (attribute) => {
|
|
77
|
+
const value = attribute.initializer;
|
|
78
|
+
if (!value)
|
|
79
|
+
return null;
|
|
80
|
+
if (ts.isJsxExpression(value))
|
|
81
|
+
return value.expression ? value.expression.getText(source) : null;
|
|
82
|
+
return value.getText(source);
|
|
83
|
+
};
|
|
84
|
+
const meaningful = (element) => ts.isJsxElement(element) ? element.children.filter(child => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces)) : [];
|
|
85
|
+
/** A row element as the text of its list item, or the reason it cannot be one. */
|
|
86
|
+
const rowItem = (element) => {
|
|
87
|
+
if (!(ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element)))
|
|
88
|
+
return { reason: 'a row is not a ListRow or StaticStateRow element' };
|
|
89
|
+
const opening = ts.isJsxElement(element) ? element.openingElement : element;
|
|
90
|
+
const name = exportOf(opening.tagName);
|
|
91
|
+
if (spread(opening))
|
|
92
|
+
return { reason: 'a row has spread props' };
|
|
93
|
+
const props = attributes(opening);
|
|
94
|
+
if (name === 'ListRow') {
|
|
95
|
+
if (ts.isJsxElement(element) && meaningful(element).length)
|
|
96
|
+
return { reason: 'a ListRow has children (a detail region): move them to the item `detail`' };
|
|
97
|
+
const other = [...props.keys()].filter(prop => !ROW_PROPS.has(prop));
|
|
98
|
+
if (other.length)
|
|
99
|
+
return { reason: `a ListRow carries ${other.join(', ')}: rewrite the leading, badges and actions as item data by hand` };
|
|
100
|
+
const key = props.get('key');
|
|
101
|
+
const id = key ? valueText(key) : null;
|
|
102
|
+
const title = props.get('title');
|
|
103
|
+
const title_ = title ? valueText(title) : null;
|
|
104
|
+
if (id === null || title_ === null)
|
|
105
|
+
return { reason: 'a ListRow without a key or a title cannot become an item' };
|
|
106
|
+
const parts = [`id: ${id}`, `title: ${title_}`];
|
|
107
|
+
for (const prop of ['description', 'state', 'verdict']) {
|
|
108
|
+
const attribute = props.get(prop);
|
|
109
|
+
const value = attribute ? valueText(attribute) : null;
|
|
110
|
+
if (value !== null)
|
|
111
|
+
parts.push(`${prop}: ${value}`);
|
|
112
|
+
}
|
|
113
|
+
return { item: `{ ${parts.join(', ')} }` };
|
|
114
|
+
}
|
|
115
|
+
if (name === 'StaticStateRow') {
|
|
116
|
+
const item = props.get('item')?.initializer;
|
|
117
|
+
const expression = item && ts.isJsxExpression(item) ? item.expression : undefined;
|
|
118
|
+
if (!expression || !ts.isObjectLiteralExpression(expression))
|
|
119
|
+
return { reason: 'a StaticStateRow item is not an object literal' };
|
|
120
|
+
const parts = [];
|
|
121
|
+
for (const property of expression.properties) {
|
|
122
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property))
|
|
123
|
+
return { reason: 'a StaticStateRow item has a spread or a method' };
|
|
124
|
+
const key = property.name.getText(source);
|
|
125
|
+
const value = ts.isPropertyAssignment(property) ? property.initializer.getText(source) : key;
|
|
126
|
+
parts.push(`${key === 'label' ? 'title' : key}: ${value}`);
|
|
127
|
+
}
|
|
128
|
+
return { item: `{ ${parts.join(', ')} }` };
|
|
129
|
+
}
|
|
130
|
+
return { reason: 'a row is not a ListRow or StaticStateRow element' };
|
|
131
|
+
};
|
|
132
|
+
const convertList = (element, opening) => {
|
|
133
|
+
const component = opening.tagName.getText(source);
|
|
134
|
+
if (spread(opening)) {
|
|
135
|
+
flag(element, 'SPREAD_PROPS', 'spread props may carry removed props; rewrite by hand', component);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const rows = meaningful(element);
|
|
139
|
+
let itemsText;
|
|
140
|
+
let rowEdit = null;
|
|
141
|
+
if (rows.length === 0)
|
|
142
|
+
itemsText = '[]';
|
|
143
|
+
else if (rows.every(row => ts.isJsxElement(row) || ts.isJsxSelfClosingElement(row))) {
|
|
144
|
+
const items = rows.map(row => rowItem(row));
|
|
145
|
+
const bad = items.find((entry) => 'reason' in entry);
|
|
146
|
+
if (bad) {
|
|
147
|
+
flag(element, 'LIST_ROW_SHAPE', bad.reason, component);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
itemsText = `[${items.map(entry => 'item' in entry ? entry.item : '').join(', ')}]`;
|
|
151
|
+
}
|
|
152
|
+
else if (rows.length === 1 && rows[0] && ts.isJsxExpression(rows[0]) && rows[0].expression) {
|
|
153
|
+
const expression = rows[0].expression;
|
|
154
|
+
const call = ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && expression.expression.name.text === 'map' ? expression : null;
|
|
155
|
+
const callback = call?.arguments[0];
|
|
156
|
+
if (!call || !callback || !(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
|
|
157
|
+
flag(element, 'LIST_CHILDREN', 'the rows are not written as direct rows or one .map', component);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let returned = ts.isArrowFunction(callback) && !ts.isBlock(callback.body) ? callback.body : undefined;
|
|
161
|
+
let wrapped = false;
|
|
162
|
+
if (returned && ts.isParenthesizedExpression(returned)) {
|
|
163
|
+
returned = returned.expression;
|
|
164
|
+
wrapped = true;
|
|
165
|
+
}
|
|
166
|
+
const only = ts.isBlock(callback.body) && callback.body.statements.length === 1 ? callback.body.statements[0] : undefined;
|
|
167
|
+
if (!returned && only && ts.isReturnStatement(only)) {
|
|
168
|
+
returned = only.expression;
|
|
169
|
+
wrapped = true;
|
|
170
|
+
}
|
|
171
|
+
const entry = returned ? rowItem(returned) : { reason: 'the .map callback does not return one row' };
|
|
172
|
+
if ('reason' in entry) {
|
|
173
|
+
flag(element, 'LIST_ROW_SHAPE', entry.reason, component);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!returned)
|
|
177
|
+
return;
|
|
178
|
+
const base = expression.getStart(source);
|
|
179
|
+
rowEdit = { start: returned.getStart(source) - base, end: returned.end - base, text: wrapped ? entry.item : `(${entry.item})` };
|
|
180
|
+
itemsText = expression.getText(source);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
flag(element, 'LIST_CHILDREN', 'the rows are not written as direct rows or one .map', component);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const kept = [];
|
|
187
|
+
let hasEmpty = false;
|
|
188
|
+
for (const prop of opening.attributes.properties) {
|
|
189
|
+
if (!ts.isJsxAttribute(prop))
|
|
190
|
+
continue;
|
|
191
|
+
const name = prop.name.getText(source);
|
|
192
|
+
if (LIST_DROPPED.has(name)) {
|
|
193
|
+
change(prop, 'LIST_PROP_DROPPED', prop.getText(source), '');
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (name === 'empty')
|
|
197
|
+
hasEmpty = true;
|
|
198
|
+
kept.push(name === 'isLoading' ? prop.getText(source).replace('isLoading', 'isSkeleton') : prop.getText(source));
|
|
199
|
+
}
|
|
200
|
+
if (!hasEmpty) {
|
|
201
|
+
kept.push('empty={null}');
|
|
202
|
+
flag(element, 'LIST_EMPTY', 'a list answers for its empty state: `empty={null}` was added as a placeholder; give it an EmptyNotice', component);
|
|
203
|
+
}
|
|
204
|
+
let itemsAttribute = `items={${itemsText}}`;
|
|
205
|
+
if (rowEdit)
|
|
206
|
+
itemsAttribute = `items={${itemsText.slice(0, rowEdit.start)}${rowEdit.text}${itemsText.slice(rowEdit.end)}}`;
|
|
207
|
+
const tag = cardTag(opening.tagName);
|
|
208
|
+
needsCard = true;
|
|
209
|
+
edit(element.getStart(source), element.end, `<${tag} kind="list" ${kept.join(' ')} ${itemsAttribute} />`);
|
|
210
|
+
const markGone = (node) => {
|
|
211
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxClosingElement(node)) && ts.isIdentifier(node.tagName))
|
|
212
|
+
gone.add(node.tagName);
|
|
213
|
+
ts.forEachChild(node, markGone);
|
|
214
|
+
};
|
|
215
|
+
markGone(element);
|
|
216
|
+
change(element, 'SURFACE_LIST_CARD_KIND', 'SurfaceListCard', 'SurfaceCard kind="list"');
|
|
217
|
+
};
|
|
218
|
+
const convertCard = (element, opening) => {
|
|
219
|
+
const component = opening.tagName.getText(source);
|
|
220
|
+
const props = attributes(opening);
|
|
221
|
+
// Stat strip as the card's one child becomes kind="figures".
|
|
222
|
+
const children = meaningful(element);
|
|
223
|
+
const only = children.length === 1 ? children[0] : undefined;
|
|
224
|
+
if (only && (ts.isJsxElement(only) || ts.isJsxSelfClosingElement(only))) {
|
|
225
|
+
const inner = ts.isJsxElement(only) ? only.openingElement : only;
|
|
226
|
+
if (exportOf(inner.tagName) === 'DescriptionList' && literal(attributes(inner).get('layout')) === 'stat-strip') {
|
|
227
|
+
const innerProps = attributes(inner);
|
|
228
|
+
const itemsInitializer = innerProps.get('items')?.initializer;
|
|
229
|
+
const array = itemsInitializer && ts.isJsxExpression(itemsInitializer) ? itemsInitializer.expression : undefined;
|
|
230
|
+
const elements = array && ts.isArrayLiteralExpression(array) ? [...array.elements] : undefined;
|
|
231
|
+
const objects = elements?.filter(ts.isObjectLiteralExpression);
|
|
232
|
+
if (!objects || objects.length !== elements?.length || spread(opening) || spread(inner) || props.has('composition')) {
|
|
233
|
+
flag(element, 'FIGURES_BY_HAND', 'the stat strip is not an array literal of object literals in a card of its own: move it to SurfaceCard kind="figures" by hand', component);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const renamed = objects.map(object => `{ ${object.properties.map(property => {
|
|
237
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property))
|
|
238
|
+
return property.getText(source);
|
|
239
|
+
const key = property.name.getText(source);
|
|
240
|
+
return key === 'description' && ts.isPropertyAssignment(property) ? `value: ${property.initializer.getText(source)}` : property.getText(source);
|
|
241
|
+
}).join(', ')} }`);
|
|
242
|
+
const kept = opening.attributes.properties.filter(ts.isJsxAttribute).filter(prop => !CARD_GONE.has(prop.name.getText(source))).map(prop => prop.getText(source));
|
|
243
|
+
const loading = innerProps.get('isLoading');
|
|
244
|
+
if (loading)
|
|
245
|
+
kept.push(loading.getText(source).replace('isLoading', 'isSkeleton'));
|
|
246
|
+
if (!props.has('label') && !props.has('ariaLabel'))
|
|
247
|
+
flag(element, 'FIGURES_NEED_NAME', 'a figures card needs a label or an ariaLabel', component);
|
|
248
|
+
edit(element.getStart(source), element.end, `<${component} kind="figures" ${kept.join(' ')} items={[${renamed.join(', ')}]} />`);
|
|
249
|
+
for (const tagName of [inner.tagName])
|
|
250
|
+
if (ts.isIdentifier(tagName))
|
|
251
|
+
gone.add(tagName);
|
|
252
|
+
change(element, 'STAT_STRIP_FIGURES', 'SurfaceCard > DescriptionList layout="stat-strip"', 'SurfaceCard kind="figures"');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const [name, gonePart] of CARD_GONE) {
|
|
257
|
+
const attribute = props.get(name);
|
|
258
|
+
if (!attribute)
|
|
259
|
+
continue;
|
|
260
|
+
if (gonePart.defaultValue !== undefined && literal(attribute) === gonePart.defaultValue) {
|
|
261
|
+
change(attribute, 'CARD_PROP_DEFAULT', attribute.getText(source), '');
|
|
262
|
+
edit(attribute.getFullStart(), attribute.end, '');
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
flag(attribute, gonePart.code, gonePart.reason, component);
|
|
266
|
+
}
|
|
267
|
+
const height = props.get('height');
|
|
268
|
+
if (height && literal(height) === 'auto') {
|
|
269
|
+
change(height, 'CARD_PROP_DEFAULT', height.getText(source), '');
|
|
270
|
+
edit(height.getFullStart(), height.end, '');
|
|
271
|
+
}
|
|
272
|
+
// A card inside a card is a design decision.
|
|
273
|
+
for (let at = element.parent; at; at = at.parent) {
|
|
274
|
+
if ((ts.isJsxElement(at) || ts.isJsxSelfClosingElement(at)) && exportOf((ts.isJsxElement(at) ? at.openingElement : at).tagName) === 'SurfaceCard') {
|
|
275
|
+
flag(element, 'NESTED_CARD', 'a surface never stands inside another surface: make the inner card a body band of kind="bands", or lift it out', component);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const visit = (node) => {
|
|
281
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
282
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
283
|
+
const name = exportOf(opening.tagName);
|
|
284
|
+
if (name === 'SurfaceListCard')
|
|
285
|
+
convertList(node, opening);
|
|
286
|
+
else if (name === 'SurfaceCard')
|
|
287
|
+
convertCard(node, opening);
|
|
288
|
+
else if (name === 'SurfaceAccordionCard')
|
|
289
|
+
flag(node, 'ACCORDION_BY_HAND', REMOVED.get('SurfaceAccordionCard') ?? '', opening.tagName.getText(source));
|
|
290
|
+
else if (name === 'Grid' && literal(attributes(opening).get('kind')) === 'cards') {
|
|
291
|
+
const labelled = ts.isJsxElement(node) ? node.children.filter(child => (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) && exportOf((ts.isJsxElement(child) ? child.openingElement : child).tagName) === 'SurfaceCard' && attributes(ts.isJsxElement(child) ? child.openingElement : child).has('label')) : [];
|
|
292
|
+
if (labelled.length >= 2)
|
|
293
|
+
flag(node, 'LABEL_IN_GRID', 'labelled cards repeated in a Grid of cards are tiles: use SurfaceCard kind="tile" (name, mark, description, badge, action), or one kind="list" card when the peers are compared', opening.tagName.getText(source));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
ts.forEachChild(node, visit);
|
|
297
|
+
};
|
|
298
|
+
visit(source);
|
|
299
|
+
// Imports: add SurfaceCard when a rewrite needs it, drop what nothing uses, report what is still used.
|
|
300
|
+
const stillUsed = new Map();
|
|
301
|
+
const scan = (node) => {
|
|
302
|
+
if (ts.isImportDeclaration(node))
|
|
303
|
+
return;
|
|
304
|
+
if (ts.isIdentifier(node) && bindings.has(node.text) && !gone.has(node)) {
|
|
305
|
+
const parent = node.parent;
|
|
306
|
+
const isName = (ts.isPropertyAccessExpression(parent) && parent.name === node) || (ts.isJsxAttribute(parent) && parent.name === node);
|
|
307
|
+
if (!isName && !stillUsed.has(node.text))
|
|
308
|
+
stillUsed.set(node.text, node);
|
|
309
|
+
}
|
|
310
|
+
ts.forEachChild(node, scan);
|
|
311
|
+
};
|
|
312
|
+
scan(source);
|
|
313
|
+
const dropped = new Set();
|
|
314
|
+
for (const [local, binding] of bindings) {
|
|
315
|
+
if (!REMOVED.has(binding.imported))
|
|
316
|
+
continue;
|
|
317
|
+
if (stillUsed.has(local))
|
|
318
|
+
flag(binding.specifier, 'REMOVED_COMPONENT', REMOVED.get(binding.imported) ?? '');
|
|
319
|
+
else
|
|
320
|
+
dropped.add(binding.specifier);
|
|
321
|
+
}
|
|
322
|
+
const addCard = needsCard && !cardLocal;
|
|
323
|
+
let added = false;
|
|
324
|
+
for (const declaration of declarations) {
|
|
325
|
+
const clause = declaration.importClause;
|
|
326
|
+
const named = clause?.namedBindings;
|
|
327
|
+
if (!clause || !named || !ts.isNamedImports(named))
|
|
328
|
+
continue;
|
|
329
|
+
const insert = addCard && !added;
|
|
330
|
+
if (!insert && !named.elements.some(specifier => dropped.has(specifier)))
|
|
331
|
+
continue;
|
|
332
|
+
added = added || insert;
|
|
333
|
+
const kept = named.elements.filter(specifier => !dropped.has(specifier)).map(specifier => specifier.getText(source));
|
|
334
|
+
const list = [...kept, ...(insert ? ['SurfaceCard'] : [])];
|
|
335
|
+
if (!list.length)
|
|
336
|
+
edit(declaration.getStart(source), declaration.end + (text.startsWith('\r\n', declaration.end) ? 2 : text.startsWith('\n', declaration.end) ? 1 : 0), '');
|
|
337
|
+
else
|
|
338
|
+
edit(named.getStart(source), named.end, /\n/.test(named.getText(source)) ? `{\n ${list.join(',\n ')},\n}` : `{ ${list.join(', ')} }`);
|
|
339
|
+
}
|
|
340
|
+
edits.sort((first, second) => first.start - second.start || first.end - second.end);
|
|
341
|
+
const unique = [];
|
|
342
|
+
for (const current of edits) {
|
|
343
|
+
const previous = unique.at(-1);
|
|
344
|
+
if (previous && previous.start === current.start && previous.end === current.end && previous.text === current.text)
|
|
345
|
+
continue;
|
|
346
|
+
if (previous && current.start < previous.end) {
|
|
347
|
+
return { text, changes: [], findings: [...findings, { file, line: 1, column: 1, code: 'EDIT_CONFLICT', reason: 'two rewrites overlap (a card inside a rewritten card): nothing in this file was changed; migrate by hand' }] };
|
|
348
|
+
}
|
|
349
|
+
unique.push(current);
|
|
350
|
+
}
|
|
351
|
+
let output = text;
|
|
352
|
+
for (const current of unique.reverse())
|
|
353
|
+
output = output.slice(0, current.start) + current.text + output.slice(current.end);
|
|
354
|
+
return { text: output, changes, findings };
|
|
355
|
+
}
|
|
356
|
+
function sourceFiles(directory) {
|
|
357
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
358
|
+
const target = path.join(directory, entry.name);
|
|
359
|
+
if (entry.isDirectory())
|
|
360
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
361
|
+
return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
|
|
362
|
+
}).sort();
|
|
363
|
+
}
|
|
364
|
+
/** Preview or apply the safe card rewrites in one app's FE tree; every unresolved case is retained in the returned report. */
|
|
365
|
+
export function migrateSurfaceCardKinds({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
366
|
+
if (!ts)
|
|
367
|
+
throw new Error('surface-card-kinds needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
368
|
+
const frontend = path.join(root, 'fe');
|
|
369
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
370
|
+
throw new Error('surface-card-kinds runs at an app root with a fe/ directory');
|
|
371
|
+
const report = { schema: 'starci/surface-card-kinds-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
372
|
+
for (const target of sourceFiles(frontend)) {
|
|
373
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
374
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
375
|
+
const result = transformSurfaceCardKinds({ ts, text: original, file });
|
|
376
|
+
report.filesScanned += 1;
|
|
377
|
+
report.changes.push(...result.changes);
|
|
378
|
+
report.findings.push(...result.findings);
|
|
379
|
+
if (result.text !== original) {
|
|
380
|
+
report.filesChanged.push(file);
|
|
381
|
+
if (write)
|
|
382
|
+
fs.writeFileSync(target, result.text);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return report;
|
|
386
|
+
}
|
|
387
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
388
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
389
|
+
export function surfaceCardKindsMarkdown(report) {
|
|
390
|
+
const out = ['# surface-card-kinds', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
391
|
+
if (report.findings.length) {
|
|
392
|
+
out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
|
|
393
|
+
for (const finding of report.findings)
|
|
394
|
+
out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
|
|
395
|
+
out.push('');
|
|
396
|
+
}
|
|
397
|
+
if (report.changes.length) {
|
|
398
|
+
out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
|
|
399
|
+
for (const item of report.changes)
|
|
400
|
+
out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
|
|
401
|
+
out.push('');
|
|
402
|
+
}
|
|
403
|
+
return `${out.join('\n')}\n`;
|
|
404
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** One rewrite of the report: a token name or a selector moved onto HeroUI's theme. */
|
|
2
|
+
export interface ThemeChange {
|
|
3
|
+
file: string;
|
|
4
|
+
line: number;
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
}
|
|
8
|
+
/** One thing the codemod leaves for a person. */
|
|
9
|
+
export interface ThemeFinding {
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
code: 'THEME_PRIVATE_TOKEN' | 'THEME_SELECTOR' | 'THEME_ROOT_PROP';
|
|
13
|
+
text: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
/** The report of a migration (`starci app codemod theme-tokens`). */
|
|
17
|
+
export interface ThemeReport {
|
|
18
|
+
schema: string;
|
|
19
|
+
root: string;
|
|
20
|
+
write: boolean;
|
|
21
|
+
filesScanned: number;
|
|
22
|
+
filesChanged: string[];
|
|
23
|
+
changes: ThemeChange[];
|
|
24
|
+
findings: ThemeFinding[];
|
|
25
|
+
}
|
|
26
|
+
/** Core's private colour names and the HeroUI token each one became (the grammar reads HeroUI's, grammar audit 2026-11 section 8). */
|
|
27
|
+
export declare const CORE_TOKENS: Readonly<Record<string, string>>;
|
|
28
|
+
/** Offset Pop's private names: its ink is `--foreground`, its pink `--accent`, its yellow, mint and critical the status tones. */
|
|
29
|
+
export declare const OFFSET_POP_TOKENS: Readonly<Record<string, string>>;
|
|
30
|
+
/** One stylesheet or component file rewritten; `changes` and `findings` carry 1-based lines. */
|
|
31
|
+
export declare function transformThemeTokens({ text, file }: {
|
|
32
|
+
text: string;
|
|
33
|
+
file?: string;
|
|
34
|
+
}): {
|
|
35
|
+
text: string;
|
|
36
|
+
changes: ThemeChange[];
|
|
37
|
+
findings: ThemeFinding[];
|
|
38
|
+
};
|
|
39
|
+
/** Rewrites an app's stylesheets onto HeroUI's tokens (`write` false is a dry run) and reports what a person finishes. */
|
|
40
|
+
export declare function migrateThemeTokens({ root, write }: {
|
|
41
|
+
root: string;
|
|
42
|
+
write?: boolean;
|
|
43
|
+
}): ThemeReport;
|
|
44
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
45
|
+
export declare function themeTokensMarkdown(report: ThemeReport): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
4
|
+
/** Core's private colour names and the HeroUI token each one became (the grammar reads HeroUI's, grammar audit 2026-11 section 8). */
|
|
5
|
+
export const CORE_TOKENS = Object.freeze({
|
|
6
|
+
accent: 'accent', 'accent-foreground': 'accent-foreground', 'accent-text': 'accent-soft-foreground', 'accent-soft': 'accent-soft', focus: 'focus',
|
|
7
|
+
canvas: 'background', surface: 'surface', 'surface-secondary': 'surface-secondary', 'surface-tertiary': 'surface-tertiary', foreground: 'foreground',
|
|
8
|
+
muted: 'muted', border: 'border', separator: 'separator', success: 'success', 'success-foreground': 'success-foreground', warning: 'warning',
|
|
9
|
+
'warning-foreground': 'warning-foreground', danger: 'danger', 'danger-foreground': 'danger-foreground', info: 'info', 'info-foreground': 'info-foreground',
|
|
10
|
+
'surface-shadow': 'surface-shadow', 'control-radius': 'radius', 'terminal-background': 'terminal-background', 'terminal-foreground': 'terminal-foreground',
|
|
11
|
+
'terminal-title-foreground': 'terminal-title-foreground',
|
|
12
|
+
});
|
|
13
|
+
/** Offset Pop's private names: its ink is `--foreground`, its pink `--accent`, its yellow, mint and critical the status tones. */
|
|
14
|
+
export const OFFSET_POP_TOKENS = Object.freeze({
|
|
15
|
+
...CORE_TOKENS, ink: 'foreground', pink: 'accent', blush: 'accent-soft', yellow: 'warning', mint: 'success', critical: 'danger', 'surface-shadow': 'surface-shadow',
|
|
16
|
+
});
|
|
17
|
+
const FAMILY_ROOT = String.raw `(?:\.grammar-common-root)?\[data-grammar-family=["'][a-z][a-z0-9-]*["']\]`;
|
|
18
|
+
const MODE = (name) => String.raw `\[data-grammar-theme=["']${name}["']\]`;
|
|
19
|
+
const FAMILY_DARK = new RegExp(`^(?:${FAMILY_ROOT}${MODE('dark')}|\\.dark\\s+${FAMILY_ROOT}|${FAMILY_ROOT}\\.dark)$`);
|
|
20
|
+
const FAMILY_LIGHT = new RegExp(`^${FAMILY_ROOT}(?:${MODE('light')}|${MODE('system')})?$`);
|
|
21
|
+
/** Tokens the grammar still reads from the app: Offset Pop's offset colour, the bare-mark hooks, and the sticky-band geometry hosts write. */
|
|
22
|
+
const KEPT = /^--(?:offset-pop-shadow-ink|starci-core-(?:mark-(?:danger|warning)|(?:band|rail|subnav)[a-z-]*))$/;
|
|
23
|
+
const PRIVATE = /--(starci-core|offset-pop)-([a-z][a-z0-9-]*)/g;
|
|
24
|
+
/** The mapped name of one private token, or null when HeroUI has no token for it. */
|
|
25
|
+
const mapped = (prefix, name) => (prefix === 'starci-core' ? CORE_TOKENS : OFFSET_POP_TOKENS)[name] ?? null;
|
|
26
|
+
/** One stylesheet or component file rewritten; `changes` and `findings` carry 1-based lines. */
|
|
27
|
+
export function transformThemeTokens({ text, file = '' }) {
|
|
28
|
+
const changes = [];
|
|
29
|
+
const findings = [];
|
|
30
|
+
const isCss = file.endsWith('.css') || file === '';
|
|
31
|
+
const lines = text.split('\n').map((line, index) => {
|
|
32
|
+
const number = index + 1;
|
|
33
|
+
let out = line;
|
|
34
|
+
if (isCss) {
|
|
35
|
+
// A selector line: each selector of the list is either a scope that is now `:root` or `.dark`, or left for a person.
|
|
36
|
+
if (/\{\s*$/.test(line) && !/^\s*(?:@|\/\*|\*)/.test(line) && /data-grammar-(?:family|theme)/.test(line)) {
|
|
37
|
+
const open = line.lastIndexOf('{');
|
|
38
|
+
const parts = line.slice(0, open).split(',').map((part) => part.trim());
|
|
39
|
+
const rewritten = parts.map((part) => (FAMILY_DARK.test(part) ? '.dark' : FAMILY_LIGHT.test(part) ? ':root' : part));
|
|
40
|
+
for (const [at, part] of parts.entries()) {
|
|
41
|
+
if (rewritten[at] !== part)
|
|
42
|
+
changes.push({ file, line: number, from: part, to: rewritten[at] ?? part });
|
|
43
|
+
else if (/data-grammar-(?:family|theme)/.test(part))
|
|
44
|
+
findings.push({ file, line: number, code: 'THEME_SELECTOR', text: part, reason: 'a scope inside the page (a nested root, a part of a family) is not a theme scope: set HeroUI tokens on the element\'s own rule, or on :root and .dark' });
|
|
45
|
+
}
|
|
46
|
+
out = `${line.slice(0, line.length - line.trimStart().length)}${[...new Set(rewritten)].join(', ')} {`;
|
|
47
|
+
}
|
|
48
|
+
out = out.replace(PRIVATE, (token, prefix, name) => {
|
|
49
|
+
const to = mapped(prefix, name);
|
|
50
|
+
if (to === null) {
|
|
51
|
+
if (!KEPT.test(token))
|
|
52
|
+
findings.push({ file, line: number, code: 'THEME_PRIVATE_TOKEN', text: token, reason: 'the grammar defines no such token any more; set HeroUI\'s --radius, --surface-shadow or the colour token it follows, or drop the line' });
|
|
53
|
+
return token;
|
|
54
|
+
}
|
|
55
|
+
changes.push({ file, line: number, from: token, to: `--${to}` });
|
|
56
|
+
return `--${to}`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const root = /<(?:Core|OffsetPop)?GrammarRoot\b[^>]*\btheme=/.exec(line);
|
|
61
|
+
if (root !== null)
|
|
62
|
+
findings.push({ file, line: number, code: 'THEME_ROOT_PROP', text: root[0], reason: 'GrammarRoot theme sets nothing now: remove it and put `.dark` on html (the app owns the mode)' });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
});
|
|
66
|
+
return { text: lines.join('\n'), changes, findings };
|
|
67
|
+
}
|
|
68
|
+
function sourceFiles(dir) {
|
|
69
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
70
|
+
const full = path.join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory())
|
|
72
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(full);
|
|
73
|
+
return /\.(?:css|tsx)$/.test(entry.name) ? [full] : [];
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Rewrites an app's stylesheets onto HeroUI's tokens (`write` false is a dry run) and reports what a person finishes. */
|
|
77
|
+
export function migrateThemeTokens({ root, write = false }) {
|
|
78
|
+
const frontend = path.join(root, 'fe');
|
|
79
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
80
|
+
throw new Error('theme-tokens runs at an app root with a fe/ directory');
|
|
81
|
+
const report = { schema: 'starci/theme-tokens-codemod@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
|
|
82
|
+
for (const target of sourceFiles(frontend)) {
|
|
83
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
84
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
85
|
+
const result = transformThemeTokens({ text: original, file });
|
|
86
|
+
report.filesScanned += 1;
|
|
87
|
+
report.changes.push(...result.changes);
|
|
88
|
+
report.findings.push(...result.findings);
|
|
89
|
+
if (result.text !== original) {
|
|
90
|
+
report.filesChanged.push(file);
|
|
91
|
+
if (write)
|
|
92
|
+
fs.writeFileSync(target, result.text);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return report;
|
|
96
|
+
}
|
|
97
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
98
|
+
/** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
99
|
+
export function themeTokensMarkdown(report) {
|
|
100
|
+
const out = [`# theme-tokens`, '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
|
|
101
|
+
if (report.findings.length) {
|
|
102
|
+
out.push('## Left for a person', '', '| file | line | code | text | reason |', '|---|---|---|---|---|');
|
|
103
|
+
for (const finding of report.findings)
|
|
104
|
+
out.push(`| ${cell(finding.file)} | ${finding.line} | ${finding.code} | \`${cell(finding.text)}\` | ${cell(finding.reason)} |`);
|
|
105
|
+
out.push('');
|
|
106
|
+
}
|
|
107
|
+
if (report.changes.length) {
|
|
108
|
+
out.push('## Rewrites', '', '| file | line | from | to |', '|---|---|---|---|');
|
|
109
|
+
for (const change of report.changes)
|
|
110
|
+
out.push(`| ${cell(change.file)} | ${change.line} | \`${cell(change.from)}\` | \`${cell(change.to)}\` |`);
|
|
111
|
+
out.push('');
|
|
112
|
+
}
|
|
113
|
+
return `${out.join('\n')}\n`;
|
|
114
|
+
}
|