@astryxdesign/cli 0.1.0-canary.f94dd07 → 0.1.1-canary.a514b99
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 +68 -0
- package/README.md +117 -75
- package/bin/astryx.mjs +22 -7
- package/docs/icons.doc.mjs +1 -1
- package/docs/migration.doc.mjs +2 -2
- package/docs/shape.doc.mjs +1 -1
- package/docs/styling.doc.mjs +2 -2
- package/docs/theme.doc.dense.mjs +2 -2
- package/docs/theme.doc.mjs +14 -0
- package/docs/theme.doc.zh.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +4 -4
- package/package.json +8 -8
- package/src/api/search.mjs +207 -13
- package/src/api/template.mjs +2 -1
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/runner.mjs +105 -51
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
- package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
- package/src/commands/agent-docs.mjs +92 -56
- package/src/commands/agent-docs.path-safety.test.mjs +1 -1
- package/src/commands/agent-docs.test.mjs +66 -10
- package/src/commands/build-theme.import-path.test.mjs +1 -1
- package/src/commands/build-theme.path-safety.test.mjs +1 -1
- package/src/commands/build-theme.prose.test.mjs +1 -1
- package/src/commands/build.mjs +196 -0
- package/src/commands/component-package.test.mjs +1 -1
- package/src/commands/component.test.mjs +1 -1
- package/src/commands/docs.test.mjs +1 -1
- package/src/commands/doctor.test.mjs +1 -1
- package/src/commands/external-showcase.test.mjs +1 -1
- package/src/commands/init.mjs +9 -1
- package/src/commands/interactive-guard.test.mjs +1 -1
- package/src/commands/json-contract.test.mjs +10 -3
- package/src/commands/swizzle-gap-safety.test.mjs +1 -1
- package/src/commands/swizzle.path-safety.test.mjs +1 -1
- package/src/commands/template.path-safety.test.mjs +1 -1
- package/src/commands/template.test.mjs +1 -1
- package/src/commands/upgrade.mjs +353 -169
- package/src/commands/upgrade.test.mjs +41 -27
- package/src/index.mjs +1 -0
- package/src/lib/config.mjs +12 -0
- package/src/lib/config.test.mjs +42 -0
- package/src/lib/error-codes.mjs +3 -0
- package/src/types/error-codes.d.ts +1 -0
- package/src/utils/interactive.mjs +1 -1
- package/src/utils/interactive.test.mjs +2 -0
- package/src/utils/package-manager.test.mjs +1 -1
- package/src/utils/path-safety.test.mjs +1 -1
- package/src/utils/paths.test.mjs +8 -8
- package/src/utils/update-check.mjs +4 -26
- package/src/utils/update-check.test.mjs +2 -64
- package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
- package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
- package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
- package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
- package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
- package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
- package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
- package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
- package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
- package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
- package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
- package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
- package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
- package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
- package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
- package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
- package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
- package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
- package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
- package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
- package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
- package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
- package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
- package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
- package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
- package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
- package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +2 -12
- package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
- package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
- package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
- package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
- package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
- package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
- package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
- package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
- package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
- package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
- package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
- package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
- package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
- package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
- package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
- package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
- package/templates/pages/ai-chat/page.tsx +71 -64
- package/templates/pages/ai-chat-landing/page.tsx +8 -12
- package/templates/pages/centered-hero/page.tsx +13 -15
- package/templates/pages/classic-gallery/page.tsx +27 -34
- package/templates/pages/detail-page/page.tsx +18 -18
- package/templates/pages/documentation/page.tsx +11 -14
- package/templates/pages/documentation-design/page.tsx +10 -13
- package/templates/pages/documentation-technical/page.tsx +15 -16
- package/templates/pages/editor/page.tsx +42 -54
- package/templates/pages/file-explorer/page.tsx +13 -16
- package/templates/pages/form-two-column/page.tsx +13 -17
- package/templates/pages/gallery-hero/page.tsx +13 -15
- package/templates/pages/ide/page.tsx +32 -39
- package/templates/pages/library/page.tsx +16 -23
- package/templates/pages/login/page.tsx +14 -18
- package/templates/pages/login-card/page.tsx +14 -18
- package/templates/pages/login-split/page.tsx +50 -48
- package/templates/pages/login-sso/page.tsx +9 -13
- package/templates/pages/mixed-gallery/page.tsx +51 -45
- package/templates/pages/payment-form/page.tsx +56 -70
- package/templates/pages/product-detail/page.tsx +27 -33
- package/templates/pages/product-gallery/page.tsx +7 -13
- package/templates/pages/settings-dialog/page.tsx +35 -43
- package/templates/pages/settings-sidebar/page.tsx +39 -47
- package/templates/pages/side-gallery/page.tsx +6 -9
- package/templates/pages/table-grouped/page.tsx +11 -15
- package/templates/pages/theme-showcase/page.tsx +33 -37
package/src/api/search.mjs
CHANGED
|
@@ -41,14 +41,184 @@ import {
|
|
|
41
41
|
} from '../lib/component-discovery.mjs';
|
|
42
42
|
import {discoverHooks, findHookDoc} from '../lib/hook-discovery.mjs';
|
|
43
43
|
import {levenshteinDistance} from '../lib/string-utils.mjs';
|
|
44
|
-
import {discoverTemplates} from './template.mjs';
|
|
44
|
+
import {discoverTemplates, extractComponents} from './template.mjs';
|
|
45
45
|
import {AstryxError} from './error.mjs';
|
|
46
46
|
|
|
47
47
|
const DOCS_DIR = path.join(CLI_ROOT, 'docs');
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Synonym / intent map: product-language terms an agent is likely to type,
|
|
51
|
+
* expanded to the catalog's vocabulary so oblique queries still rank. Keys and
|
|
52
|
+
* values are matched bidirectionally (typing any value also pulls in the key
|
|
53
|
+
* and its siblings). Lowercase, single words or short phrases.
|
|
54
|
+
*/
|
|
55
|
+
const SYNONYMS = {
|
|
56
|
+
dashboard: ['overview', 'analytics', 'kpi', 'kpis', 'metrics', 'stats', 'reporting', 'insights', 'control'],
|
|
57
|
+
login: ['signin', 'auth', 'authentication', 'sso', 'credentials', 'account'],
|
|
58
|
+
signup: ['register', 'registration', 'onboarding'],
|
|
59
|
+
payment: ['checkout', 'billing', 'card', 'pay', 'purchase', 'order'],
|
|
60
|
+
pricing: ['plans', 'plan', 'tiers', 'tier', 'subscription', 'subscriptions'],
|
|
61
|
+
chat: ['messaging', 'message', 'messages', 'conversation', 'inbox', 'dm'],
|
|
62
|
+
settings: ['preferences', 'config', 'configuration', 'account'],
|
|
63
|
+
calendar: ['schedule', 'scheduling', 'events', 'event', 'month', 'agenda'],
|
|
64
|
+
table: ['list', 'rows', 'records', 'grid', 'spreadsheet', 'datatable'],
|
|
65
|
+
gallery: ['photos', 'photo', 'images', 'image', 'pictures'],
|
|
66
|
+
hero: ['banner', 'splash', 'headline', 'landing'],
|
|
67
|
+
form: ['fields', 'input', 'inputs', 'survey'],
|
|
68
|
+
profile: ['bio', 'avatar', 'user'],
|
|
69
|
+
documentation: ['docs', 'reference', 'guide', 'api'],
|
|
70
|
+
navigation: ['nav', 'menu', 'sidebar'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Flatten into a token -> Set(expansions) lookup (bidirectional).
|
|
74
|
+
const SYNONYM_INDEX = (() => {
|
|
75
|
+
const idx = new Map();
|
|
76
|
+
const add = (a, b) => {
|
|
77
|
+
if (!idx.has(a)) idx.set(a, new Set());
|
|
78
|
+
idx.get(a).add(b);
|
|
79
|
+
};
|
|
80
|
+
for (const [key, vals] of Object.entries(SYNONYMS)) {
|
|
81
|
+
for (const v of vals) {
|
|
82
|
+
add(key, v);
|
|
83
|
+
add(v, key);
|
|
84
|
+
for (const v2 of vals) if (v2 !== v) add(v, v2);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return idx;
|
|
88
|
+
})();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Light stemmer: strips common English suffixes so "charts"/"charting" and
|
|
92
|
+
* "chart" share a root. Deliberately crude (no Porter) — good enough to bridge
|
|
93
|
+
* plural/gerund gaps without a dependency.
|
|
94
|
+
* @param {string} w
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function stem(w) {
|
|
98
|
+
let s = w;
|
|
99
|
+
for (const suf of ['ing', 'ed', 'ies', 'es', 's']) {
|
|
100
|
+
if (s.length > suf.length + 2 && s.endsWith(suf)) {
|
|
101
|
+
s = suf === 'ies' ? s.slice(0, -3) + 'y' : s.slice(0, -suf.length);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
49
109
|
/** Valid domain filters for `--type`. */
|
|
50
110
|
export const SEARCH_DOMAINS = ['component', 'hook', 'doc', 'template'];
|
|
51
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Filler words stripped from multi-word queries so natural-language phrasing
|
|
114
|
+
* ("a page where you can see business stats") ranks on its content words.
|
|
115
|
+
*/
|
|
116
|
+
const STOPWORDS = new Set([
|
|
117
|
+
'a', 'an', 'the', 'of', 'for', 'to', 'with', 'and', 'or', 'in', 'on', 'at',
|
|
118
|
+
'by', 'that', 'this', 'my', 'your', 'our', 'their', 'is', 'are', 'be', 'it',
|
|
119
|
+
'its', 'as', 'from', 'page', 'screen', 'app', 'application', 'view', 'where',
|
|
120
|
+
'you', 'can', 'some', 'like', 'just', 'basically', 'kinda', 'want', 'wants',
|
|
121
|
+
'need', 'needs', 'something', 'thing', 'things', 'build', 'make', 'create',
|
|
122
|
+
'i', 'me', 'we', 'us', 'so', 'up', 'out', 'over', 'side', 'one', 'big',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Split a query into meaningful content tokens (lowercased, stopwords + very
|
|
127
|
+
* short words removed). Empty for single-word queries (callers fall back to
|
|
128
|
+
* whole-phrase scoring).
|
|
129
|
+
* @param {string} term - Already-lowercased query.
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function tokenizeQuery(term) {
|
|
133
|
+
return term
|
|
134
|
+
.split(/\s+/)
|
|
135
|
+
// Strip only leading/trailing punctuation; keep joined identifiers intact
|
|
136
|
+
// (e.g. "foo_bar" stays one token) so gibberish stays gibberish.
|
|
137
|
+
.map(t => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
|
|
138
|
+
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Score a candidate against a query, handling multi-word natural language.
|
|
143
|
+
* Tries the whole phrase (so exact/near matches still win) AND a per-token
|
|
144
|
+
* pass (so "data table with filters" matches `table-page` via table+filter),
|
|
145
|
+
* and returns whichever is stronger.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} term - Lowercased full query.
|
|
148
|
+
* @param {string[]} tokens - Content tokens from tokenizeQuery(term).
|
|
149
|
+
* @param {object} candidate
|
|
150
|
+
* @returns {{score: number, reason: string} | null}
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* Minimum per-token score (in the multi-word pass) to count as a real match.
|
|
154
|
+
* 50 = a genuine name/keyword/description hit; below that is loose Levenshtein
|
|
155
|
+
* fuzz that would otherwise turn gibberish queries into noise.
|
|
156
|
+
*/
|
|
157
|
+
const MIN_TOKEN_SCORE = 50;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Best score for a token against a candidate, fanning out through synonyms
|
|
161
|
+
* (synonym hits are discounted so a direct hit always wins).
|
|
162
|
+
* @returns {{score: number, reason: string} | null}
|
|
163
|
+
*/
|
|
164
|
+
function bestForToken(tok, candidate) {
|
|
165
|
+
let best = scoreCandidate(tok, candidate);
|
|
166
|
+
const syns = SYNONYM_INDEX.get(tok);
|
|
167
|
+
if (syns) {
|
|
168
|
+
for (const s of syns) {
|
|
169
|
+
const h = scoreCandidate(s, candidate);
|
|
170
|
+
if (h) {
|
|
171
|
+
const score = Math.round(h.score * 0.85);
|
|
172
|
+
if (!best || score > best.score) best = {score, reason: `${h.reason} (~${tok})`};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return best;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function scoreQuery(term, tokens, candidate) {
|
|
180
|
+
const full = scoreCandidate(term, candidate);
|
|
181
|
+
|
|
182
|
+
// 0–1 content tokens: keep whole-phrase fuzzy matching (typo tolerance for
|
|
183
|
+
// single words), but if stopwords left exactly one DIFFERENT token (e.g.
|
|
184
|
+
// "pricing page" → "pricing"), score that token too and take the stronger.
|
|
185
|
+
if (tokens.length <= 1) {
|
|
186
|
+
const single = tokens.length === 1 ? bestForToken(tokens[0], candidate) : null;
|
|
187
|
+
if (full && (!single || full.score >= single.score)) return full;
|
|
188
|
+
return single;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Multi-word natural language: score each content token, counting only
|
|
192
|
+
// strong hits, then reward coverage so candidates matching more terms win.
|
|
193
|
+
let sum = 0;
|
|
194
|
+
let matched = 0;
|
|
195
|
+
const hitTerms = [];
|
|
196
|
+
for (const tok of tokens) {
|
|
197
|
+
const h = bestForToken(tok, candidate);
|
|
198
|
+
if (h && h.score >= MIN_TOKEN_SCORE) {
|
|
199
|
+
sum += h.score;
|
|
200
|
+
matched++;
|
|
201
|
+
hitTerms.push(tok);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (matched === 0) return full;
|
|
205
|
+
|
|
206
|
+
// Reward the AVERAGE strength of the concepts that matched (not divided by
|
|
207
|
+
// total query length — that penalizes verbose / low-fidelity prompts), plus
|
|
208
|
+
// a bonus per additional matched concept and a coverage term. A candidate
|
|
209
|
+
// that matches several of the query's concepts beats one matching a single
|
|
210
|
+
// incidental word.
|
|
211
|
+
const avgMatched = sum / matched;
|
|
212
|
+
const coverage = matched / tokens.length;
|
|
213
|
+
const tokenScore = Math.round(avgMatched + Math.min(matched - 1, 3) * 12 + coverage * 15);
|
|
214
|
+
|
|
215
|
+
if (full && full.score >= tokenScore) return full;
|
|
216
|
+
return {
|
|
217
|
+
score: tokenScore,
|
|
218
|
+
reason: `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
52
222
|
/**
|
|
53
223
|
* Score a single candidate against the search term across name, keywords,
|
|
54
224
|
* and prose signals. Returns the best (highest) score plus a human reason,
|
|
@@ -107,10 +277,13 @@ export function scoreCandidate(term, {name, keywords = [], description = '', pro
|
|
|
107
277
|
else if (dist === 2) consider(30, `keyword "${kw}" (distance ${dist})`);
|
|
108
278
|
}
|
|
109
279
|
|
|
110
|
-
// ── Prose / description signals (whole
|
|
280
|
+
// ── Prose / description signals (stem-tolerant whole word) ──────
|
|
281
|
+
// Match the term's stem as a whole word, tolerating plural/gerund suffixes
|
|
282
|
+
// so "chart" matches "charts" and "filter" matches "filtering".
|
|
111
283
|
if (term.length >= 3) {
|
|
112
|
-
const
|
|
113
|
-
const
|
|
284
|
+
const root = stem(term);
|
|
285
|
+
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
286
|
+
const re = new RegExp(`\\b${escaped}(s|es|ing|ed|ies)?\\b`);
|
|
114
287
|
if (description && re.test(description.toLowerCase())) {
|
|
115
288
|
consider(50, `description mentions "${term}"`);
|
|
116
289
|
} else {
|
|
@@ -240,14 +413,30 @@ async function gatherTemplates(cwd) {
|
|
|
240
413
|
} catch {
|
|
241
414
|
return [];
|
|
242
415
|
}
|
|
243
|
-
return templates.map(t =>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
416
|
+
return templates.map(t => {
|
|
417
|
+
// Blocks ship componentsUsed; page templates don't, so derive them from the
|
|
418
|
+
// source. Category words (e.g. "Dashboard - Analytics") are strong intent
|
|
419
|
+
// signal for pages, which otherwise only index on name + description.
|
|
420
|
+
let keywords = Array.isArray(t.componentsUsed) ? [...t.componentsUsed] : [];
|
|
421
|
+
if (t.type === 'page') {
|
|
422
|
+
if (t.filePath) {
|
|
423
|
+
try {
|
|
424
|
+
keywords = keywords.concat(extractComponents(t.filePath));
|
|
425
|
+
} catch {
|
|
426
|
+
// Best-effort: skip keyword enrichment if the source can't be read.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (t.category) keywords = keywords.concat(t.category.split(/[^A-Za-z0-9]+/).filter(Boolean));
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
domain: 'template',
|
|
433
|
+
name: t.dirName,
|
|
434
|
+
keywords,
|
|
435
|
+
description: t.description || '',
|
|
436
|
+
_displayName: t.name,
|
|
437
|
+
_kind: t.type, // 'page' | 'block'
|
|
438
|
+
};
|
|
439
|
+
});
|
|
251
440
|
}
|
|
252
441
|
|
|
253
442
|
/**
|
|
@@ -325,6 +514,7 @@ export async function search(query, options = {}) {
|
|
|
325
514
|
}
|
|
326
515
|
|
|
327
516
|
const term = String(query).trim().toLowerCase();
|
|
517
|
+
const tokens = tokenizeQuery(term);
|
|
328
518
|
|
|
329
519
|
const coreDir = findCoreDir(cwd);
|
|
330
520
|
if (!coreDir) {
|
|
@@ -342,9 +532,13 @@ export async function search(query, options = {}) {
|
|
|
342
532
|
|
|
343
533
|
const all = [...components, ...hooks, ...docTopics, ...templates];
|
|
344
534
|
|
|
535
|
+
// Score every candidate on its own merits. The consumer groups results by
|
|
536
|
+
// role (page / block / component) and takes the top of each, so there's no
|
|
537
|
+
// cross-role competition to engineer — a target page only needs to be the
|
|
538
|
+
// strongest PAGE, not outrank every component.
|
|
345
539
|
const scored = [];
|
|
346
540
|
for (const candidate of all) {
|
|
347
|
-
const hit =
|
|
541
|
+
const hit = scoreQuery(term, tokens, candidate);
|
|
348
542
|
if (hit) scored.push(toResult(candidate, hit.score, hit.reason));
|
|
349
543
|
}
|
|
350
544
|
|
package/src/api/template.mjs
CHANGED
|
@@ -95,6 +95,7 @@ async function discoverPages() {
|
|
|
95
95
|
dirName: dir.name,
|
|
96
96
|
name: doc?.name || dir.name,
|
|
97
97
|
description: doc?.description || '',
|
|
98
|
+
category: doc?.category || '',
|
|
98
99
|
isReady: doc?.isReady ?? true,
|
|
99
100
|
scaffold: doc?.scaffold ?? false,
|
|
100
101
|
filePath: path.join(dirPath, 'page.tsx'),
|
|
@@ -245,7 +246,7 @@ const UBIQUITOUS = new Set([
|
|
|
245
246
|
'StackItem', 'Icon',
|
|
246
247
|
]);
|
|
247
248
|
|
|
248
|
-
function extractComponents(pagePath) {
|
|
249
|
+
export function extractComponents(pagePath) {
|
|
249
250
|
const src = fs.readFileSync(pagePath, 'utf-8');
|
|
250
251
|
// Match JSX opening tags, e.g. `<Section` or the legacy `<XDSSection`.
|
|
251
252
|
// Templates author bare component names post un-prefix migration
|
|
@@ -17,6 +17,7 @@ const registry = new Map([
|
|
|
17
17
|
['0.0.13', () => import('./transforms/v0.0.13/index.mjs')],
|
|
18
18
|
['0.0.14', () => import('./transforms/v0.0.14/index.mjs')],
|
|
19
19
|
['0.0.15', () => import('./transforms/v0.0.15/index.mjs')],
|
|
20
|
+
['0.1.0', () => import('./transforms/v0.1.0/index.mjs')],
|
|
20
21
|
]);
|
|
21
22
|
|
|
22
23
|
// Re-export from the shared utility so registry callers and other consumers
|
package/src/codemods/runner.mjs
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import * as p from '@clack/prompts';
|
|
14
|
-
import {getRunPrefix} from '../utils/package-manager.mjs';
|
|
15
14
|
import {humanLog} from '../lib/json.mjs';
|
|
16
15
|
|
|
17
16
|
// Known corruption patterns that indicate a broken transform.
|
|
@@ -87,39 +86,6 @@ function findSourceFiles(dir) {
|
|
|
87
86
|
return results.sort();
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
/**
|
|
91
|
-
* Detect the project's formatter and run it on changed files.
|
|
92
|
-
* Tries prettier, then biome. Silently skips if none found.
|
|
93
|
-
*
|
|
94
|
-
* @param {string[]} files - Absolute paths to files that were modified
|
|
95
|
-
* @param {boolean} [silent] - Suppress human-facing output
|
|
96
|
-
*/
|
|
97
|
-
async function formatChangedFiles(files, silent = false) {
|
|
98
|
-
if (files.length === 0) return;
|
|
99
|
-
|
|
100
|
-
const {execSync} = await import('node:child_process');
|
|
101
|
-
const fileArgs = files.map(f => `"${f}"`).join(' ');
|
|
102
|
-
const prefix = getRunPrefix();
|
|
103
|
-
|
|
104
|
-
const formatters = [
|
|
105
|
-
{name: 'prettier', cmd: `${prefix} prettier --write ${fileArgs}`},
|
|
106
|
-
{name: 'biome', cmd: `${prefix} biome format --write ${fileArgs}`},
|
|
107
|
-
];
|
|
108
|
-
|
|
109
|
-
for (const {name, cmd} of formatters) {
|
|
110
|
-
try {
|
|
111
|
-
execSync(cmd, {stdio: 'pipe', timeout: 30000});
|
|
112
|
-
if (!silent)
|
|
113
|
-
p.log.info(
|
|
114
|
-
`Formatted ${files.length} file${files.length === 1 ? '' : 's'} with ${name}`,
|
|
115
|
-
);
|
|
116
|
-
return;
|
|
117
|
-
} catch {
|
|
118
|
-
// Formatter not available or failed — try next
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
89
|
/**
|
|
124
90
|
* Validate transform output before writing to disk.
|
|
125
91
|
*
|
|
@@ -162,6 +128,86 @@ export function validateOutput(result, source, j, {parse = true} = {}) {
|
|
|
162
128
|
return {valid: true};
|
|
163
129
|
}
|
|
164
130
|
|
|
131
|
+
function isConfigCodemod(transformEntry) {
|
|
132
|
+
return transformEntry.meta?.codemodType === 'config';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const CONFIG_CODEMOD_PATHS = new Set([
|
|
136
|
+
'package.json',
|
|
137
|
+
'astryx.config.mjs',
|
|
138
|
+
'xds.config.mjs',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
function resolveConfigPath(relativePath) {
|
|
142
|
+
if (!CONFIG_CODEMOD_PATHS.has(relativePath)) {
|
|
143
|
+
throw new Error(`unsupported config codemod path: ${relativePath}`);
|
|
144
|
+
}
|
|
145
|
+
return path.resolve(process.cwd(), relativePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readOptionalConfigFile(relativePath) {
|
|
149
|
+
const fullPath = resolveConfigPath(relativePath);
|
|
150
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
151
|
+
return {path: relativePath, source: fs.readFileSync(fullPath, 'utf-8')};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getConfigCodemodContext() {
|
|
155
|
+
return {
|
|
156
|
+
packageJson: readOptionalConfigFile('package.json'),
|
|
157
|
+
astryxConfig: readOptionalConfigFile('astryx.config.mjs'),
|
|
158
|
+
xdsConfig: readOptionalConfigFile('xds.config.mjs'),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runConfigCodemod(transformEntry, {apply, log}) {
|
|
163
|
+
const {transform} = transformEntry;
|
|
164
|
+
const api = {config: getConfigCodemodContext()};
|
|
165
|
+
const result = await transform({path: process.cwd(), source: ''}, api);
|
|
166
|
+
const errors = result?.errors ?? [];
|
|
167
|
+
if (errors.length > 0) {
|
|
168
|
+
for (const error of errors) {
|
|
169
|
+
log.error(` ✗ ${error.file ?? 'config'} — ${error.error}`);
|
|
170
|
+
}
|
|
171
|
+
return {filesChanged: 0, errors};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const changes = result?.changes ?? [];
|
|
175
|
+
if (changes.length === 0) return {filesChanged: 0, errors: []};
|
|
176
|
+
|
|
177
|
+
for (const change of changes) {
|
|
178
|
+
const fullPath = resolveConfigPath(change.path);
|
|
179
|
+
if (change.delete) {
|
|
180
|
+
if (apply) fs.rmSync(fullPath, {force: true});
|
|
181
|
+
log[apply ? 'success' : 'warn'](
|
|
182
|
+
` ${apply ? '✓' : '~'} ${change.path} (delete${apply ? '' : ', dry run'})`,
|
|
183
|
+
);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (apply) {
|
|
188
|
+
fs.writeFileSync(fullPath, change.source, 'utf-8');
|
|
189
|
+
}
|
|
190
|
+
log[apply ? 'success' : 'warn'](
|
|
191
|
+
` ${apply ? '✓' : '~'} ${change.path}${apply ? '' : ' (would change)'}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (changes.length > 0) {
|
|
196
|
+
const verb = apply ? 'Updated' : 'Would update';
|
|
197
|
+
log.info(
|
|
198
|
+
` ${verb} ${changes.length} config file${changes.length === 1 ? '' : 's'}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
filesChanged: changes.length,
|
|
204
|
+
writtenFiles: changes
|
|
205
|
+
.filter(change => !change.delete && path.extname(change.path) !== '.json')
|
|
206
|
+
.map(change => resolveConfigPath(change.path)),
|
|
207
|
+
errors: [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
165
211
|
/**
|
|
166
212
|
* Run codemods against source files.
|
|
167
213
|
*
|
|
@@ -197,18 +243,12 @@ export async function runCodemods(
|
|
|
197
243
|
|
|
198
244
|
if (files.length === 0) {
|
|
199
245
|
log.warn('No source files found.');
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
errors: [],
|
|
205
|
-
writtenFiles: [],
|
|
206
|
-
skippedOptional: [],
|
|
207
|
-
};
|
|
246
|
+
} else {
|
|
247
|
+
log.info(
|
|
248
|
+
`Found ${files.length} source file${files.length === 1 ? '' : 's'}`,
|
|
249
|
+
);
|
|
208
250
|
}
|
|
209
251
|
|
|
210
|
-
log.info(`Found ${files.length} source file${files.length === 1 ? '' : 's'}`);
|
|
211
|
-
|
|
212
252
|
// Dynamically import jscodeshift
|
|
213
253
|
const jscodeshift = (await import('jscodeshift')).default;
|
|
214
254
|
|
|
@@ -239,6 +279,24 @@ export async function runCodemods(
|
|
|
239
279
|
|
|
240
280
|
log.info(` ${meta.title}`);
|
|
241
281
|
|
|
282
|
+
if (isConfigCodemod(transformEntry)) {
|
|
283
|
+
const result = await runConfigCodemod(transformEntry, {apply, log});
|
|
284
|
+
if (result.errors.length > 0) {
|
|
285
|
+
for (const error of result.errors) {
|
|
286
|
+
errors.push({
|
|
287
|
+
file: error.file ?? 'config',
|
|
288
|
+
codemod: name,
|
|
289
|
+
error: error.error,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else if (result.filesChanged > 0) {
|
|
293
|
+
totalFilesChanged += result.filesChanged;
|
|
294
|
+
totalTransformsApplied += result.filesChanged;
|
|
295
|
+
writtenFiles.push(...(result.writtenFiles ?? []));
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
242
300
|
let filesChanged = 0;
|
|
243
301
|
|
|
244
302
|
for (const filePath of files) {
|
|
@@ -323,12 +381,6 @@ export async function runCodemods(
|
|
|
323
381
|
}
|
|
324
382
|
}
|
|
325
383
|
|
|
326
|
-
// Post-codemod formatting: run the project's formatter on changed files
|
|
327
|
-
// so codemods don't introduce style drift (jscodeshift may change quotes, etc.)
|
|
328
|
-
if (apply && writtenFiles.length > 0) {
|
|
329
|
-
await formatChangedFiles(writtenFiles, silent);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
384
|
if (totalValidationBlocked > 0) {
|
|
333
385
|
log.warn(
|
|
334
386
|
`${totalValidationBlocked} file${totalValidationBlocked === 1 ? ' was' : 's were'} blocked by validation — no changes written to ${totalValidationBlocked === 1 ? 'that file' : 'those files'}.`,
|
|
@@ -366,7 +418,9 @@ export async function runCodemods(
|
|
|
366
418
|
if (meta.description) {
|
|
367
419
|
log.info(` ${meta.description}`);
|
|
368
420
|
}
|
|
369
|
-
log.info(
|
|
421
|
+
log.info(
|
|
422
|
+
` Run: astryx upgrade --codemod ${name} --path <dir> --apply`,
|
|
423
|
+
);
|
|
370
424
|
}
|
|
371
425
|
}
|
|
372
426
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect} from 'vitest';
|
|
4
|
+
|
|
5
|
+
async function applyConfigCodemod(files) {
|
|
6
|
+
const {default: transform} =
|
|
7
|
+
await import('../migrate-xds-config-surfaces.mjs');
|
|
8
|
+
const config = {
|
|
9
|
+
packageJson: files['package.json']
|
|
10
|
+
? {path: 'package.json', source: files['package.json']}
|
|
11
|
+
: null,
|
|
12
|
+
astryxConfig: files['astryx.config.mjs']
|
|
13
|
+
? {path: 'astryx.config.mjs', source: files['astryx.config.mjs']}
|
|
14
|
+
: null,
|
|
15
|
+
xdsConfig: files['xds.config.mjs']
|
|
16
|
+
? {path: 'xds.config.mjs', source: files['xds.config.mjs']}
|
|
17
|
+
: null,
|
|
18
|
+
};
|
|
19
|
+
return transform({path: process.cwd(), source: ''}, {config});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('migrate-xds-config-surfaces', () => {
|
|
23
|
+
it('extracts package.json xds config into astryx.config.mjs and removes the package key in place', async () => {
|
|
24
|
+
const input = `{
|
|
25
|
+
"dependencies": {"@xds/core":"^0.0.15"},
|
|
26
|
+
"xds" : {"theme":"neutral","docs":"./src"}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const result = await applyConfigCodemod({'package.json': input});
|
|
31
|
+
expect(result.errors ?? []).toEqual([]);
|
|
32
|
+
expect(result.changes).toEqual([
|
|
33
|
+
{
|
|
34
|
+
path: 'package.json',
|
|
35
|
+
source: `{
|
|
36
|
+
"dependencies": {"@xds/core":"^0.0.15"}
|
|
37
|
+
}
|
|
38
|
+
`,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
path: 'astryx.config.mjs',
|
|
42
|
+
source: `export default {
|
|
43
|
+
"theme": "neutral",
|
|
44
|
+
"docs": "./src"
|
|
45
|
+
};
|
|
46
|
+
`,
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('extracts package.json astryx config into astryx.config.mjs too', async () => {
|
|
52
|
+
const result = await applyConfigCodemod({
|
|
53
|
+
'package.json': `{"astryx":{"theme":"neutral"}}\n`,
|
|
54
|
+
});
|
|
55
|
+
expect(result.changes).toEqual([
|
|
56
|
+
{path: 'package.json', source: `{}\n`},
|
|
57
|
+
{
|
|
58
|
+
path: 'astryx.config.mjs',
|
|
59
|
+
source: `export default {
|
|
60
|
+
"theme": "neutral"
|
|
61
|
+
};
|
|
62
|
+
`,
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('bails when package.json contains both xds and astryx config keys', async () => {
|
|
68
|
+
const result = await applyConfigCodemod({
|
|
69
|
+
'package.json': `{"xds":{"theme":"default"},"astryx":{"theme":"neutral"}}\n`,
|
|
70
|
+
});
|
|
71
|
+
expect(result.errors?.[0]?.error).toMatch(/both xds and astryx/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not rewrite dependency keys in package.json', async () => {
|
|
75
|
+
const input = `{"dependencies":{"@xds/core":"^0.0.15"}}\n`;
|
|
76
|
+
const result = await applyConfigCodemod({'package.json': input});
|
|
77
|
+
expect(result.changes).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('bails when package config and astryx.config.mjs both exist', async () => {
|
|
81
|
+
const result = await applyConfigCodemod({
|
|
82
|
+
'package.json': `{"xds":{"theme":"neutral"}}\n`,
|
|
83
|
+
'astryx.config.mjs': `export default {};\n`,
|
|
84
|
+
});
|
|
85
|
+
expect(result.errors?.[0]?.error).toMatch(/migrate the config manually/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('bails when package config and xds.config.mjs both exist', async () => {
|
|
89
|
+
const result = await applyConfigCodemod({
|
|
90
|
+
'package.json': `{"xds":{"theme":"neutral"}}\n`,
|
|
91
|
+
'xds.config.mjs': `export default {};\n`,
|
|
92
|
+
});
|
|
93
|
+
expect(result.errors?.[0]?.error).toMatch(/migrate the config manually/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renames xds.config.mjs to astryx.config.mjs when no package config exists', async () => {
|
|
97
|
+
const result = await applyConfigCodemod({
|
|
98
|
+
'xds.config.mjs': "export default { theme: 'neutral' };\n",
|
|
99
|
+
});
|
|
100
|
+
expect(result.changes).toEqual([
|
|
101
|
+
{
|
|
102
|
+
path: 'astryx.config.mjs',
|
|
103
|
+
source: "export default { theme: 'neutral' };\n",
|
|
104
|
+
},
|
|
105
|
+
{path: 'xds.config.mjs', delete: true},
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('bails when xds.config.mjs and astryx.config.mjs both exist', async () => {
|
|
110
|
+
const result = await applyConfigCodemod({
|
|
111
|
+
'xds.config.mjs': `export default {};\n`,
|
|
112
|
+
'astryx.config.mjs': `export default {};\n`,
|
|
113
|
+
});
|
|
114
|
+
expect(result.errors?.[0]?.error).toMatch(/migrate the config manually/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect} from 'vitest';
|
|
4
|
+
|
|
5
|
+
async function applyTransform(source, filePath = 'test.tsx') {
|
|
6
|
+
const {default: transform} =
|
|
7
|
+
await import('../migrate-xds-module-specifiers.mjs');
|
|
8
|
+
const jscodeshift = (await import('jscodeshift')).default;
|
|
9
|
+
const j = jscodeshift.withParser('tsx');
|
|
10
|
+
const api = {jscodeshift: j, stats: () => {}, report: () => {}};
|
|
11
|
+
const file = {source, path: filePath};
|
|
12
|
+
const result = transform(file, api);
|
|
13
|
+
return result ?? source;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('migrate-xds-module-specifiers', () => {
|
|
17
|
+
it('renames import and export source paths including subpaths', async () => {
|
|
18
|
+
const input = [
|
|
19
|
+
"import {Button} from '@xds/core/Button';",
|
|
20
|
+
"import '@xds/theme-default/theme.css';",
|
|
21
|
+
"export {LabThing} from '@xds/lab/Thing';",
|
|
22
|
+
"export * from '@xds/core/theme';",
|
|
23
|
+
].join('\n');
|
|
24
|
+
|
|
25
|
+
const output = await applyTransform(input);
|
|
26
|
+
expect(output).toContain('@astryxdesign/core/Button');
|
|
27
|
+
expect(output).toContain('@astryxdesign/theme-neutral/theme.css');
|
|
28
|
+
expect(output).toContain('@astryxdesign/lab/Thing');
|
|
29
|
+
expect(output).toContain('@astryxdesign/core/theme');
|
|
30
|
+
expect(output).not.toContain('@xds/');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renames dynamic import and require string literals', async () => {
|
|
34
|
+
const output = await applyTransform(
|
|
35
|
+
[
|
|
36
|
+
"const mod = await import('@xds/core/theme');",
|
|
37
|
+
"const core = require('@xds/core');",
|
|
38
|
+
].join('\n'),
|
|
39
|
+
'test.ts',
|
|
40
|
+
);
|
|
41
|
+
expect(output).toContain('@astryxdesign/core/theme');
|
|
42
|
+
expect(output).toContain('@astryxdesign/core');
|
|
43
|
+
expect(output).not.toContain('@xds/');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not rewrite ordinary string literals', async () => {
|
|
47
|
+
const input = "const packageName = '@xds/core';";
|
|
48
|
+
const output = await applyTransform(input, 'test.ts');
|
|
49
|
+
expect(output).toBe(input);
|
|
50
|
+
});
|
|
51
|
+
});
|