@astryxdesign/cli 0.0.15 → 0.1.0-canary.08d4cf4

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.
Files changed (141) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +118 -76
  3. package/bin/astryx.mjs +22 -7
  4. package/docs/getting-started.doc.mjs +15 -15
  5. package/docs/icons.doc.mjs +1 -1
  6. package/docs/migration.doc.mjs +6 -6
  7. package/docs/shape.doc.mjs +1 -1
  8. package/docs/styling-libraries.doc.mjs +3 -3
  9. package/docs/styling.doc.mjs +4 -5
  10. package/docs/theme.doc.dense.mjs +2 -2
  11. package/docs/theme.doc.mjs +53 -19
  12. package/docs/theme.doc.zh.mjs +2 -2
  13. package/docs/working-with-ai.doc.mjs +4 -4
  14. package/package.json +10 -14
  15. package/src/api/doctor.mjs +4 -4
  16. package/src/api/search.mjs +207 -13
  17. package/src/api/template.mjs +2 -1
  18. package/src/codemods/__tests__/registry.test.mjs +1 -0
  19. package/src/codemods/registry.mjs +1 -0
  20. package/src/codemods/runner.mjs +105 -51
  21. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
  22. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
  23. package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
  24. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
  25. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
  26. package/src/commands/agent-docs.mjs +119 -66
  27. package/src/commands/agent-docs.path-safety.test.mjs +1 -1
  28. package/src/commands/agent-docs.test.mjs +87 -31
  29. package/src/commands/build-theme.import-path.test.mjs +1 -1
  30. package/src/commands/build-theme.path-safety.test.mjs +1 -1
  31. package/src/commands/build-theme.prose.test.mjs +1 -1
  32. package/src/commands/build.mjs +196 -0
  33. package/src/commands/component-package.test.mjs +1 -1
  34. package/src/commands/component.test.mjs +1 -1
  35. package/src/commands/docs.test.mjs +1 -1
  36. package/src/commands/doctor.test.mjs +4 -4
  37. package/src/commands/external-showcase.test.mjs +1 -1
  38. package/src/commands/init.mjs +12 -4
  39. package/src/commands/interactive-guard.test.mjs +1 -1
  40. package/src/commands/json-contract.test.mjs +10 -3
  41. package/src/commands/swizzle-gap-safety.test.mjs +1 -1
  42. package/src/commands/swizzle.path-safety.test.mjs +1 -1
  43. package/src/commands/template.path-safety.test.mjs +1 -1
  44. package/src/commands/template.test.mjs +1 -1
  45. package/src/commands/upgrade.mjs +353 -169
  46. package/src/commands/upgrade.test.mjs +41 -27
  47. package/src/index.mjs +1 -0
  48. package/src/lib/config.mjs +12 -0
  49. package/src/lib/config.test.mjs +42 -0
  50. package/src/lib/error-codes.mjs +3 -0
  51. package/src/types/error-codes.d.ts +1 -0
  52. package/src/utils/interactive.mjs +1 -1
  53. package/src/utils/interactive.test.mjs +2 -0
  54. package/src/utils/package-manager.mjs +1 -1
  55. package/src/utils/package-manager.test.mjs +1 -1
  56. package/src/utils/path-safety.test.mjs +1 -1
  57. package/src/utils/paths.test.mjs +8 -8
  58. package/src/utils/update-check.mjs +4 -26
  59. package/src/utils/update-check.test.mjs +2 -64
  60. package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
  61. package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
  62. package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
  63. package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
  64. package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
  65. package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
  66. package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
  67. package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
  68. package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
  69. package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
  70. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
  71. package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
  72. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
  73. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
  74. package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
  75. package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
  76. package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
  77. package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
  78. package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
  79. package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
  80. package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
  81. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
  82. package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
  83. package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
  84. package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
  85. package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
  86. package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
  87. package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
  88. package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
  89. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
  90. package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
  91. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
  92. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +2 -12
  93. package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
  94. package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
  95. package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
  96. package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
  97. package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
  98. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
  99. package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
  100. package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
  101. package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
  102. package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
  103. package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
  104. package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
  105. package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
  106. package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
  107. package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
  108. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
  109. package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
  110. package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
  111. package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
  112. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
  113. package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
  114. package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
  115. package/templates/pages/ai-chat/page.tsx +71 -64
  116. package/templates/pages/ai-chat-landing/page.tsx +8 -12
  117. package/templates/pages/centered-hero/page.tsx +13 -15
  118. package/templates/pages/classic-gallery/page.tsx +27 -34
  119. package/templates/pages/detail-page/page.tsx +18 -18
  120. package/templates/pages/documentation/page.tsx +42 -58
  121. package/templates/pages/documentation-design/page.tsx +82 -60
  122. package/templates/pages/documentation-technical/page.tsx +101 -60
  123. package/templates/pages/editor/page.tsx +42 -54
  124. package/templates/pages/file-explorer/page.tsx +13 -16
  125. package/templates/pages/form-two-column/page.tsx +22 -43
  126. package/templates/pages/gallery-hero/page.tsx +13 -15
  127. package/templates/pages/ide/page.tsx +188 -264
  128. package/templates/pages/library/page.tsx +16 -23
  129. package/templates/pages/login/page.tsx +14 -18
  130. package/templates/pages/login-card/page.tsx +14 -18
  131. package/templates/pages/login-split/page.tsx +50 -48
  132. package/templates/pages/login-sso/page.tsx +9 -13
  133. package/templates/pages/mixed-gallery/page.tsx +51 -45
  134. package/templates/pages/payment-form/page.tsx +56 -70
  135. package/templates/pages/product-detail/page.tsx +27 -33
  136. package/templates/pages/product-gallery/page.tsx +7 -13
  137. package/templates/pages/settings-dialog/page.tsx +35 -43
  138. package/templates/pages/settings-sidebar/page.tsx +39 -47
  139. package/templates/pages/side-gallery/page.tsx +6 -9
  140. package/templates/pages/table-grouped/page.tsx +11 -15
  141. package/templates/pages/theme-showcase/page.tsx +33 -37
@@ -14,16 +14,22 @@ export const docs = {
14
14
  title: 'Quick Start',
15
15
  category: 'guide',
16
16
  content: [
17
+ {
18
+ type: 'code',
19
+ lang: 'bash',
20
+ label: 'Install a theme package',
21
+ code: 'npm install @astryxdesign/theme-neutral',
22
+ },
17
23
  {
18
24
  type: 'code',
19
25
  lang: 'tsx',
20
26
  label: 'Basic theme setup (runtime injection)',
21
27
  code: `import {Theme} from '@astryxdesign/core';
22
- import {defaultTheme} from '@astryxdesign/theme-default';
28
+ import {neutralTheme} from '@astryxdesign/theme-neutral';
23
29
 
24
30
  function App() {
25
31
  return (
26
- <Theme theme={defaultTheme}>
32
+ <Theme theme={neutralTheme}>
27
33
  <YourApp />
28
34
  </Theme>
29
35
  );
@@ -34,17 +40,21 @@ function App() {
34
40
  lang: 'tsx',
35
41
  label: 'Optimized setup (pre-built CSS)',
36
42
  code: `import {Theme} from '@astryxdesign/core';
37
- import {defaultTheme} from '@astryxdesign/theme-default/built';
38
- import '@astryxdesign/theme-default/theme.css';
43
+ import {neutralTheme} from '@astryxdesign/theme-neutral/built';
44
+ import '@astryxdesign/theme-neutral/theme.css';
39
45
 
40
46
  function App() {
41
47
  return (
42
- <Theme theme={defaultTheme}>
48
+ <Theme theme={neutralTheme}>
43
49
  <YourApp />
44
50
  </Theme>
45
51
  );
46
52
  }`,
47
53
  },
54
+ {
55
+ type: 'prose',
56
+ text: 'Each theme ships as its own npm package. Install the one you want, then wrap your app in `<Theme>` — the same pattern works for every theme; just swap the package and import name.',
57
+ },
48
58
  {
49
59
  type: 'prose',
50
60
  text: 'The default import uses runtime style injection, which works everywhere with no build step. The `/built` import skips injection and relies on the pre-compiled CSS file for better performance and SSR support.',
@@ -55,24 +65,48 @@ function App() {
55
65
  title: 'Available Themes',
56
66
  category: 'guide',
57
67
  content: [
68
+ {
69
+ type: 'prose',
70
+ text: 'Install the theme package you want with `npm install @astryxdesign/theme-{name}`, then import its theme object as shown below.',
71
+ },
58
72
  {
59
73
  type: 'table',
60
74
  headers: ['Theme', 'Import', 'Description'],
61
75
  rows: [
62
- [
63
- 'Default',
64
- "import {defaultTheme} from '@astryxdesign/theme-default'",
65
- 'Blue accent, system fonts, light/dark',
66
- ],
67
76
  [
68
77
  'Neutral',
69
78
  "import {neutralTheme} from '@astryxdesign/theme-neutral'",
70
- 'Grayscale, shadcn-inspired',
79
+ 'Muted, minimal aesthetic with system fonts. A good starting point.',
80
+ ],
81
+ [
82
+ 'Butter',
83
+ "import {butterTheme} from '@astryxdesign/theme-butter'",
84
+ 'Golden, buttery surfaces with blue accents; Sarina + Outfit type.',
85
+ ],
86
+ [
87
+ 'Chocolate',
88
+ "import {chocolateTheme} from '@astryxdesign/theme-chocolate'",
89
+ 'Warm brown tones and cozy beige; Fraunces + Albert Sans type.',
90
+ ],
91
+ [
92
+ 'Gothic',
93
+ "import {gothicTheme} from '@astryxdesign/theme-gothic'",
94
+ 'Dark-only atmospheric theme; deep blue-gray surfaces, distressed display type.',
95
+ ],
96
+ [
97
+ 'Matcha',
98
+ "import {matchaTheme} from '@astryxdesign/theme-matcha'",
99
+ 'Earthy green theme with Figtree typography.',
100
+ ],
101
+ [
102
+ 'Stone',
103
+ "import {stoneTheme} from '@astryxdesign/theme-stone'",
104
+ 'Warm stone and slate tones; Montserrat + Figtree type.',
71
105
  ],
72
106
  [
73
- 'Brutalist',
74
- "import {brutalistTheme} from '@astryxdesign/theme-brutalist'",
75
- 'Zero radius, monospace, heavy borders',
107
+ 'Y2K',
108
+ "import {y2kTheme} from '@astryxdesign/theme-y2k'",
109
+ 'Playful Y2K pop; periwinkle body, holographic accents, Poppins + Crimson Text.',
76
110
  ],
77
111
  ],
78
112
  },
@@ -194,14 +228,14 @@ const myTheme = defineTheme({
194
228
  {
195
229
  type: 'code',
196
230
  lang: 'tsx',
197
- label: 'Extending the default theme',
231
+ label: 'Extending the neutral theme',
198
232
  code: `import {defineTheme} from '@astryxdesign/core/theme';
199
- import {defaultTheme} from '@astryxdesign/theme-default';
233
+ import {neutralTheme} from '@astryxdesign/theme-neutral';
200
234
  import {myIcons} from './icons';
201
235
 
202
236
  const brandTheme = defineTheme({
203
237
  name: 'brand',
204
- extends: defaultTheme,
238
+ extends: neutralTheme,
205
239
  icons: myIcons,
206
240
  tokens: {
207
241
  '--color-accent': ['#7B61FF', '#9B85FF'],
@@ -525,9 +559,9 @@ const pandaOrEmotionTheme = {
525
559
  lang: 'ts',
526
560
  label: 'Resolve token values without a hook',
527
561
  code: `import {resolveThemeTokens} from '@astryxdesign/core/theme/tokens';
528
- import {defaultTheme} from '@astryxdesign/theme-default';
562
+ import {neutralTheme} from '@astryxdesign/theme-neutral';
529
563
 
530
- const lightTokens = resolveThemeTokens(defaultTheme, {mode: 'light'});
564
+ const lightTokens = resolveThemeTokens(neutralTheme, {mode: 'light'});
531
565
  const chartTheme = {
532
566
  textColor: lightTokens['--color-text-primary'],
533
567
  seriesColor: lightTokens['--color-data-categorical-blue'],
@@ -5,8 +5,8 @@
5
5
  export const docsZh = {
6
6
  description: 'Theme 提供者、自定义主题、亮/暗模式和组件样式覆盖。',
7
7
  sections: [
8
- { title: '快速开始', content: [null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
- { title: '可用主题', content: [null, { type: 'prose', text: '@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
8
+ { title: '快速开始', content: [null, null, null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
+ { title: '可用主题', content: [null, null, { type: 'prose', text: '已发布主题:neutral(推荐起点)、butter、chocolate、gothic(仅暗色)、matcha、stone、y2k。@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
10
10
  { title: 'Theme 属性', content: [null] },
11
11
  { title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
12
12
  { title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
@@ -34,7 +34,7 @@ export const docs = {
34
34
  type: 'code',
35
35
  lang: 'text',
36
36
  label: 'Paste this into your AI',
37
- code: 'Install @astryxdesign/cli and run `npx astryx agent-docs` to set up your XDS context. Read the generated file.',
37
+ code: 'Install @astryxdesign/cli and run `npx astryx agent-docs` to set up your Astryx context. Read the generated file.',
38
38
  },
39
39
  {
40
40
  type: 'prose',
@@ -103,7 +103,7 @@ npx astryx agent-docs --agent-docs-path ~/.cursor/rules/xds.mdc`,
103
103
  type: 'code',
104
104
  lang: 'text',
105
105
  label: 'Paste this into your AI',
106
- code: `Before writing any XDS code, check your knowledge:
106
+ code: `Before writing any Astryx code, check your knowledge:
107
107
 
108
108
  1. What is the correct import path for Button?
109
109
  2. How do you make an Dialog non-dismissible?
@@ -165,7 +165,7 @@ npx astryx docs tokens --dense`,
165
165
  content: [
166
166
  {
167
167
  type: 'prose',
168
- text: 'XDS ships a Model Context Protocol (MCP) server that any MCP-compatible AI tool can connect to. Instead of manually pasting CLI output, the AI can query the XDS design system directly, searching for components, reading full documentation, and pulling code examples on demand.',
168
+ text: 'Astryx ships a Model Context Protocol (MCP) server that any MCP-compatible AI tool can connect to. Instead of manually pasting CLI output, the AI can query the Astryx design system directly, searching for components, reading full documentation, and pulling code examples on demand.',
169
169
  },
170
170
  {
171
171
  type: 'prose',
@@ -183,7 +183,7 @@ npx astryx docs tokens --dense`,
183
183
  "mcpServers": {
184
184
  "xds": {
185
185
  "type": "url",
186
- "url": "https://astryx.meta.com/mcp"
186
+ "url": "https://astryx.atmeta.com/mcp"
187
187
  }
188
188
  }
189
189
  }`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.0.15",
3
+ "version": "0.1.0-canary.08d4cf4",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -15,7 +15,7 @@
15
15
  "url": "https://github.com/facebook/astryx/issues"
16
16
  },
17
17
  "keywords": [
18
- "xds",
18
+ "astryx",
19
19
  "cli",
20
20
  "design-system",
21
21
  "init",
@@ -50,13 +50,13 @@
50
50
  "dependencies": {
51
51
  "@clack/prompts": "^1.5.1",
52
52
  "commander": "^12.1.0",
53
- "jiti": "^2.7.0"
53
+ "jiti": "^2.7.0",
54
+ "jscodeshift": "^17.3.0"
54
55
  },
55
56
  "peerDependencies": {
56
- "@astryxdesign/core": "*",
57
- "@astryxdesign/lab": "*",
58
- "@astryxdesign/theme-default": "*",
59
- "@astryxdesign/theme-neutral": "*"
57
+ "@astryxdesign/core": "0.1.0-canary.08d4cf4",
58
+ "@astryxdesign/lab": "0.1.0-canary.08d4cf4",
59
+ "@astryxdesign/theme-neutral": "0.1.0-canary.08d4cf4"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "@astryxdesign/core": {
@@ -65,18 +65,14 @@
65
65
  "@astryxdesign/lab": {
66
66
  "optional": true
67
67
  },
68
- "@astryxdesign/theme-default": {
69
- "optional": true
70
- },
71
68
  "@astryxdesign/theme-neutral": {
72
69
  "optional": true
73
70
  }
74
71
  },
75
72
  "devDependencies": {
76
- "@astryxdesign/core": "*",
77
- "@astryxdesign/lab": "*",
78
- "@astryxdesign/theme-default": "*",
79
- "@astryxdesign/theme-neutral": "*"
73
+ "@astryxdesign/core": "0.1.0-canary.08d4cf4",
74
+ "@astryxdesign/lab": "0.1.0-canary.08d4cf4",
75
+ "@astryxdesign/theme-neutral": "0.1.0-canary.08d4cf4"
80
76
  },
81
77
  "scripts": {
82
78
  "astryx": "node bin/astryx.mjs",
@@ -240,7 +240,7 @@ export function checkThemes(ctx) {
240
240
  label: 'Theme packages',
241
241
  status: 'warn',
242
242
  message: 'No @astryxdesign/theme-* packages are installed.',
243
- fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-default`, then import its CSS or set xds.theme.',
243
+ fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set xds.theme.',
244
244
  };
245
245
  }
246
246
 
@@ -355,8 +355,8 @@ export function checkAgentDocs(ctx) {
355
355
  try {
356
356
  const content = fs.readFileSync(path.join(ctx.cwd, rel), 'utf-8');
357
357
  return (
358
- content.includes('<!-- XDS:START -->') &&
359
- content.includes('<!-- XDS:END -->')
358
+ (content.includes('<!-- ASTRYX:START -->') || content.includes('<!-- XDS:START -->')) &&
359
+ (content.includes('<!-- ASTRYX:END -->') || content.includes('<!-- XDS:END -->'))
360
360
  );
361
361
  } catch {
362
362
  return false;
@@ -368,7 +368,7 @@ export function checkAgentDocs(ctx) {
368
368
  id: 'agent-docs',
369
369
  label: 'AI agent docs',
370
370
  status: 'warn',
371
- message: `Agent docs present (${present.join(', ')}) but no XDS section markers found.`,
371
+ message: `Agent docs present (${present.join(', ')}) but no Astryx section markers found.`,
372
372
  fix: 'Add the XDS section to your agent docs with `astryx init --features agents`.',
373
373
  };
374
374
  }
@@ -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-word boundary) ───────────
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 escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
- const re = new RegExp(`\\b${escaped}\\b`);
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
- domain: 'template',
245
- name: t.dirName,
246
- keywords: Array.isArray(t.componentsUsed) ? t.componentsUsed : [],
247
- description: t.description || '',
248
- _displayName: t.name,
249
- _kind: t.type, // 'page' | 'block'
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 = scoreCandidate(term, candidate);
541
+ const hit = scoreQuery(term, tokens, candidate);
348
542
  if (hit) scored.push(toResult(candidate, hit.score, hit.reason));
349
543
  }
350
544
 
@@ -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
@@ -16,6 +16,7 @@ describe('registry', () => {
16
16
  '0.0.13',
17
17
  '0.0.14',
18
18
  '0.0.15',
19
+ '0.1.0',
19
20
  ]);
20
21
  });
21
22
  });
@@ -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