@astryxdesign/cli 0.1.2 → 0.1.3

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 (238) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +49 -55
  3. package/docs/layout.doc.dense.mjs +90 -0
  4. package/docs/layout.doc.mjs +160 -0
  5. package/docs/principles.doc.dense.mjs +2 -2
  6. package/docs/principles.doc.mjs +4 -0
  7. package/docs/principles.doc.zh.mjs +2 -2
  8. package/docs/theme.doc.mjs +2 -2
  9. package/package.json +13 -1
  10. package/src/api/blog.mjs +192 -0
  11. package/src/api/blog.test.mjs +144 -0
  12. package/src/api/component.mjs +253 -12
  13. package/src/api/discover.mjs +14 -7
  14. package/src/api/doctor.mjs +10 -25
  15. package/src/api/index.mjs +1 -0
  16. package/src/api/layout.mjs +11 -10
  17. package/src/api/layout.test.mjs +4 -1
  18. package/src/api/template-integration.test.mjs +225 -0
  19. package/src/api/template.mjs +242 -84
  20. package/src/api/validate-integration.mjs +370 -0
  21. package/src/api/validate-integration.test.mjs +222 -0
  22. package/src/codemod.mjs +93 -0
  23. package/src/codemod.test.mjs +134 -0
  24. package/src/codemods/__tests__/registry.test.mjs +1 -0
  25. package/src/codemods/__tests__/runner.test.mjs +103 -0
  26. package/src/codemods/integration-discovery.mjs +168 -0
  27. package/src/codemods/integration-discovery.test.mjs +234 -0
  28. package/src/codemods/integration-runner.mjs +109 -0
  29. package/src/codemods/registry.mjs +1 -0
  30. package/src/codemods/run-codemod.mjs +207 -0
  31. package/src/codemods/runner.mjs +73 -96
  32. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  33. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
  34. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
  35. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -0
  36. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  37. package/src/codemods/transforms/v0.1.0/index.mjs +43 -6
  38. package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
  39. package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
  40. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  41. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  42. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  43. package/src/commands/agent-docs.mjs +4 -1
  44. package/src/commands/blog.mjs +60 -0
  45. package/src/commands/build-theme.import-path.test.mjs +2 -12
  46. package/src/commands/build-theme.mjs +117 -0
  47. package/src/commands/build-theme.prose.test.mjs +5 -15
  48. package/src/commands/build-theme.watch.test.mjs +149 -0
  49. package/src/commands/component/index.mjs +44 -10
  50. package/src/commands/component-ownership.test.mjs +227 -0
  51. package/src/commands/discover.mjs +5 -16
  52. package/src/commands/doctor.test.mjs +3 -3
  53. package/src/commands/ensure-core-built.mjs +120 -0
  54. package/src/commands/json-contract.test.mjs +0 -32
  55. package/src/commands/swizzle.mjs +224 -235
  56. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  57. package/src/commands/swizzle.routing.test.mjs +279 -0
  58. package/src/commands/template.mjs +33 -37
  59. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  60. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  61. package/src/commands/upgrade.mjs +349 -170
  62. package/src/commands/validate-integration.mjs +110 -0
  63. package/src/commands/validate-integration.test.mjs +124 -0
  64. package/src/config.mjs +7 -20
  65. package/src/config.test.mjs +89 -13
  66. package/src/index.mjs +8 -3
  67. package/src/integration.mjs +19 -0
  68. package/src/lib/component-discovery.mjs +177 -0
  69. package/src/lib/config-cache.mjs +91 -0
  70. package/src/lib/config-cache.test.mjs +83 -0
  71. package/src/lib/config-schema.mjs +41 -55
  72. package/src/lib/error-codes.mjs +15 -12
  73. package/src/lib/integration-warnings.mjs +62 -0
  74. package/src/lib/integration-warnings.test.mjs +102 -0
  75. package/src/lib/integrations.mjs +92 -115
  76. package/src/lib/integrations.test.mjs +88 -107
  77. package/src/lib/manifest.mjs +5 -2
  78. package/src/lib/module-loader.mjs +80 -0
  79. package/src/lib/module-loader.test.mjs +106 -0
  80. package/src/lib/project.mjs +502 -0
  81. package/src/lib/project.test.mjs +308 -0
  82. package/src/lib/site.mjs +20 -0
  83. package/src/template.mjs +73 -0
  84. package/src/template.test.mjs +127 -0
  85. package/src/types/api.d.ts +2 -6
  86. package/src/types/base.d.ts +3 -9
  87. package/src/types/codemod.d.ts +81 -0
  88. package/src/types/component.d.ts +27 -2
  89. package/src/types/config.d.ts +56 -85
  90. package/src/types/error-codes.d.ts +5 -3
  91. package/src/types/index.d.ts +0 -1
  92. package/src/types/integration.d.ts +29 -0
  93. package/src/types/swizzle.d.ts +9 -2
  94. package/src/types/template-api.d.ts +54 -0
  95. package/src/types/template.d.ts +10 -7
  96. package/src/types/upgrade.d.ts +29 -0
  97. package/src/types/validate-integration.d.ts +24 -0
  98. package/src/utils/github.mjs +0 -237
  99. package/src/utils/interactive.mjs +2 -2
  100. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
  101. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
  102. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
  103. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
  104. package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
  105. package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
  106. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
  107. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
  108. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
  109. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
  110. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
  111. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
  112. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
  113. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
  114. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
  115. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
  116. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
  117. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
  118. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
  119. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
  120. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
  121. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
  122. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
  123. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
  124. package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
  125. package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
  126. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  127. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  128. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  129. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  130. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  131. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  132. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
  133. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
  134. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
  135. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
  136. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
  137. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
  138. package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
  139. package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
  140. package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
  141. package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
  142. package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
  143. package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
  144. package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
  145. package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
  146. package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
  147. package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
  148. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
  149. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
  150. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  151. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  152. package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
  153. package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
  154. package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
  155. package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
  156. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
  157. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
  158. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
  159. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
  160. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
  161. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
  162. package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
  163. package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
  164. package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
  165. package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
  166. package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
  167. package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
  168. package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
  169. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
  170. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
  171. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
  172. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
  173. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
  174. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
  175. package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
  176. package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
  177. package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
  178. package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
  179. package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
  180. package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
  181. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
  182. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
  183. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
  184. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
  185. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
  186. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
  187. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
  188. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
  189. package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
  190. package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
  191. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
  192. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
  193. package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
  194. package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
  195. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  196. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  197. package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
  198. package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
  199. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
  200. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
  201. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
  202. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
  203. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
  204. package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
  205. package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
  206. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
  207. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
  208. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
  209. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
  210. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
  211. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
  212. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
  213. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
  214. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
  215. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
  216. package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
  217. package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
  218. package/templates/pages/kanban-board/page.tsx +729 -0
  219. package/templates/pages/kanban-board/template.doc.mjs +12 -0
  220. package/templates/pages/shell-nav/page.tsx +321 -0
  221. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  222. package/templates/pages/shell-side-nav/page.tsx +241 -0
  223. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  224. package/templates/pages/shell-top-nav/page.tsx +224 -0
  225. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  226. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  227. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  228. package/src/commands/gap-report.mjs +0 -464
  229. package/src/commands/gap-report.test.mjs +0 -168
  230. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  231. package/src/lib/config.mjs +0 -113
  232. package/src/lib/config.test.mjs +0 -91
  233. package/src/types/gap-report.d.ts +0 -29
  234. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
  235. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
  236. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
  237. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
  238. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -0,0 +1,225 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration-provided template discovery (same-stem + type-driven).
5
+ *
6
+ * These tests stand up a temp consumer project with an astryx.config and an
7
+ * installed integration package that contributes templates, then exercise the
8
+ * public `template()` API to verify discovery, package/type scoping, ambiguity
9
+ * errors, and copy-to-dir naming.
10
+ */
11
+
12
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import {template} from './template.mjs';
16
+
17
+ let tmpDir;
18
+ let originalCwd;
19
+
20
+ /** Absolute path to the CLI package so integrations can import /template. */
21
+ const CLI_PKG = path.resolve(import.meta.dirname, '..', '..');
22
+
23
+ function makeConsumer() {
24
+ const dir = fs.mkdtempSync(
25
+ path.join(process.cwd(), '.astryx-template-it-'),
26
+ );
27
+ fs.writeFileSync(
28
+ path.join(dir, 'package.json'),
29
+ JSON.stringify({name: 'consumer'}),
30
+ );
31
+ fs.writeFileSync(
32
+ path.join(dir, 'astryx.config.mjs'),
33
+ `export default { integrations: ['@acme/widgets'] };\n`,
34
+ );
35
+ return dir;
36
+ }
37
+
38
+ /**
39
+ * Install an @acme/widgets integration package that declares a templates root.
40
+ * @returns the package dir.
41
+ */
42
+ function installWidgets(consumerDir) {
43
+ const pkgDir = path.join(consumerDir, 'node_modules', '@acme', 'widgets');
44
+ fs.mkdirSync(pkgDir, {recursive: true});
45
+ fs.writeFileSync(
46
+ path.join(pkgDir, 'package.json'),
47
+ JSON.stringify({name: '@acme/widgets', version: '2.0.0'}),
48
+ );
49
+ fs.writeFileSync(
50
+ path.join(pkgDir, 'astryx.integration.mjs'),
51
+ `export default { templates: './templates' };\n`,
52
+ );
53
+ fs.mkdirSync(path.join(pkgDir, 'templates'));
54
+ return pkgDir;
55
+ }
56
+
57
+ /** Write a template doc + same-stem source under the templates root. */
58
+ function writeTemplate(pkgDir, id, {kind, body, withSource = true}) {
59
+ const docPath = path.join(pkgDir, 'templates', `${id}.doc.mjs`);
60
+ fs.mkdirSync(path.dirname(docPath), {recursive: true});
61
+ const create = kind === 'page' ? 'createPageTemplate' : 'createBlockTemplate';
62
+ fs.writeFileSync(
63
+ docPath,
64
+ body ??
65
+ `import {${create}} from '${CLI_PKG}/src/template.mjs';\n` +
66
+ `export default ${create}({name: '${id} name', description: '${id} desc'});\n`,
67
+ );
68
+ if (withSource) {
69
+ fs.writeFileSync(
70
+ path.join(pkgDir, 'templates', `${id}.tsx`),
71
+ `export default function ${id.replace(/[^a-zA-Z0-9]/g, '')}() { return null; }\n`,
72
+ );
73
+ }
74
+ }
75
+
76
+ beforeEach(() => {
77
+ originalCwd = process.cwd();
78
+ tmpDir = makeConsumer();
79
+ process.chdir(tmpDir);
80
+ });
81
+
82
+ afterEach(() => {
83
+ process.chdir(originalCwd);
84
+ fs.rmSync(tmpDir, {recursive: true, force: true});
85
+ });
86
+
87
+ describe('integration template discovery', () => {
88
+ it('discovers and lists an integration template with package + type', async () => {
89
+ const pkgDir = installWidgets(tmpDir);
90
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
91
+
92
+ const result = await template(undefined, {list: true, cwd: tmpDir});
93
+ expect(result.type).toBe('template.list');
94
+ const entry = result.data.find(t => t.id === 'pricing');
95
+ expect(entry).toBeTruthy();
96
+ expect(entry.type).toBe('page');
97
+ expect(entry.package).toBe('@acme/widgets');
98
+ expect(entry.name).toBe('pricing name');
99
+ expect(entry.description).toBe('pricing desc');
100
+ });
101
+
102
+ it('lists nested-id templates (kebab path under root)', async () => {
103
+ const pkgDir = installWidgets(tmpDir);
104
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
105
+
106
+ const result = await template(undefined, {list: true, cwd: tmpDir});
107
+ const entry = result.data.find(t => t.id === 'marketing/hero');
108
+ expect(entry).toBeTruthy();
109
+ expect(entry.type).toBe('block');
110
+ expect(entry.package).toBe('@acme/widgets');
111
+ });
112
+
113
+ it('always reports core templates under @astryxdesign/core', async () => {
114
+ const result = await template(undefined, {list: true, cwd: tmpDir});
115
+ const core = result.data.filter(t => t.package === '@astryxdesign/core');
116
+ expect(core.length).toBeGreaterThan(0);
117
+ });
118
+
119
+ it('--package narrows the listing', async () => {
120
+ const pkgDir = installWidgets(tmpDir);
121
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
122
+
123
+ const result = await template(undefined, {
124
+ list: true,
125
+ package: '@acme/widgets',
126
+ cwd: tmpDir,
127
+ });
128
+ expect(result.data.length).toBe(1);
129
+ expect(result.data[0].id).toBe('pricing');
130
+ });
131
+
132
+ it('skips a template whose same-stem source is missing', async () => {
133
+ const pkgDir = installWidgets(tmpDir);
134
+ writeTemplate(pkgDir, 'orphan', {kind: 'page', withSource: false});
135
+
136
+ const result = await template(undefined, {list: true, cwd: tmpDir});
137
+ expect(result.data.find(t => t.id === 'orphan')).toBeUndefined();
138
+ });
139
+
140
+ it('skips a raw doc that is missing a type', async () => {
141
+ const pkgDir = installWidgets(tmpDir);
142
+ writeTemplate(pkgDir, 'untyped', {
143
+ kind: 'page',
144
+ body: `export default {name: 'Untyped', description: 'no type'};\n`,
145
+ });
146
+
147
+ const result = await template(undefined, {list: true, cwd: tmpDir});
148
+ expect(result.data.find(t => t.id === 'untyped')).toBeUndefined();
149
+ });
150
+
151
+ it('errors with candidates when an id is ambiguous across type/package', async () => {
152
+ const pkgDir = installWidgets(tmpDir);
153
+ // Same id "hero" as both a page and a block within the integration.
154
+ writeTemplate(pkgDir, 'hero', {kind: 'page'});
155
+ // Add a sibling block doc with the same stem in a different file is not
156
+ // possible (same file). Instead install a second package with a "hero".
157
+ const pkg2 = path.join(tmpDir, 'node_modules', '@acme', 'extra');
158
+ fs.mkdirSync(path.join(pkg2, 'templates'), {recursive: true});
159
+ fs.writeFileSync(
160
+ path.join(pkg2, 'package.json'),
161
+ JSON.stringify({name: '@acme/extra', version: '1.0.0'}),
162
+ );
163
+ fs.writeFileSync(
164
+ path.join(pkg2, 'astryx.integration.mjs'),
165
+ `export default { templates: './templates' };\n`,
166
+ );
167
+ fs.writeFileSync(
168
+ path.join(pkg2, 'templates', 'hero.doc.mjs'),
169
+ `import {createBlockTemplate} from '${CLI_PKG}/src/template.mjs';\n` +
170
+ `export default createBlockTemplate({name: 'Hero block', description: 'b'});\n`,
171
+ );
172
+ fs.writeFileSync(
173
+ path.join(pkg2, 'templates', 'hero.tsx'),
174
+ `export default function Hero() { return null; }\n`,
175
+ );
176
+ fs.writeFileSync(
177
+ path.join(tmpDir, 'astryx.config.mjs'),
178
+ `export default { integrations: ['@acme/widgets', '@acme/extra'] };\n`,
179
+ );
180
+
181
+ await expect(
182
+ template('hero', {targetPath: './out', cwd: tmpDir}),
183
+ ).rejects.toMatchObject({code: 'ERR_AMBIGUOUS_TEMPLATE'});
184
+ });
185
+
186
+ it('--type and --package narrow an ambiguous id to a single match', async () => {
187
+ const pkgDir = installWidgets(tmpDir);
188
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
189
+
190
+ const result = await template('pricing', {
191
+ type: 'page',
192
+ package: '@acme/widgets',
193
+ show: true,
194
+ cwd: tmpDir,
195
+ });
196
+ expect(result.type).toBe('template.show');
197
+ expect(result.data.type).toBe('page');
198
+ });
199
+
200
+ it('copies a page template into a directory as page.tsx', async () => {
201
+ const pkgDir = installWidgets(tmpDir);
202
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
203
+
204
+ const result = await template('pricing', {
205
+ targetPath: './dest',
206
+ cwd: tmpDir,
207
+ });
208
+ expect(result.type).toBe('template.copy');
209
+ expect(result.data.fileName).toBe('page.tsx');
210
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'page.tsx'))).toBe(true);
211
+ });
212
+
213
+ it('copies a block template into a directory as <id-basename>.tsx', async () => {
214
+ const pkgDir = installWidgets(tmpDir);
215
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
216
+
217
+ const result = await template('marketing/hero', {
218
+ targetPath: './dest',
219
+ cwd: tmpDir,
220
+ });
221
+ expect(result.type).toBe('template.copy');
222
+ expect(result.data.fileName).toBe('hero.tsx');
223
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'hero.tsx'))).toBe(true);
224
+ });
225
+ });
@@ -6,6 +6,8 @@
6
6
 
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import {loadModuleWithSchema} from '../lib/module-loader.mjs';
10
+ import {TemplateEnvelopeSchema} from '../template.mjs';
9
11
  import {CLI_ROOT, discoverExternalPackages} from '../utils/paths.mjs';
10
12
  import {
11
13
  assertWithin,
@@ -14,7 +16,28 @@ import {
14
16
  } from '../utils/path-safety.mjs';
15
17
  import {AstryxError} from './error.mjs';
16
18
  import {ERROR_CODES} from '../lib/error-codes.mjs';
17
- import {loadConfig} from '../lib/config.mjs';
19
+ import {Project} from '../lib/project.mjs';
20
+
21
+ /** Identity used for core (built-in) templates in package-scoped listings. */
22
+ const CORE_PACKAGE = '@astryxdesign/core';
23
+
24
+ /** Doc-file basename suffixes for integration templates, in precedence order. */
25
+ const DOC_SUFFIXES = ['.doc.ts', '.doc.mjs', '.doc.js'];
26
+
27
+ /**
28
+ * Load an integration template doc module and validate it against the template
29
+ * envelope at the load boundary. Default export only — `.ts` via jiti,
30
+ * `.mjs`/`.js` via dynamic import. Throws (caught by discovery) if the default
31
+ * export is missing or fails {@link TemplateEnvelopeSchema}. NOTE: the built-in
32
+ * core templates use `export const doc = {...}` and are loaded by a different
33
+ * function ({@link loadDocModule}) — this path is for INTEGRATION templates.
34
+ *
35
+ * @param {string} file
36
+ * @param {string} [label]
37
+ */
38
+ async function loadIntegrationDoc(file, label) {
39
+ return loadModuleWithSchema(file, TemplateEnvelopeSchema, {label});
40
+ }
18
41
 
19
42
  const TEMPLATES_DIR = path.join(CLI_ROOT, 'templates');
20
43
  const PAGES_DIR = path.join(TEMPLATES_DIR, 'pages');
@@ -140,17 +163,13 @@ async function discoverBlocks() {
140
163
  }
141
164
 
142
165
  /**
143
- * Discover blocks from external packages that declare `xds.blocks`.
144
- * Same shape as discoverBlocks() output.
166
+ * Discover blocks from external packages that declare `astryx.blocks` in
167
+ * their package.json. Same shape as discoverBlocks() output.
145
168
  *
146
169
  * @param {string} [cwd]
147
170
  */
148
171
  async function discoverExternalBlocks(cwd = process.cwd()) {
149
- const config = await loadConfig(cwd);
150
- const integrationPackages = (config.loadedIntegrations ?? [])
151
- .map(integration => integration.package)
152
- .filter(Boolean);
153
- const externals = [...discoverExternalPackages(cwd), ...integrationPackages];
172
+ const externals = discoverExternalPackages(cwd);
154
173
  const blocks = [];
155
174
 
156
175
  for (const ext of externals) {
@@ -194,12 +213,184 @@ async function discoverAllBlocks(cwd = process.cwd()) {
194
213
  return [...core, ...external];
195
214
  }
196
215
 
197
- async function discoverAll() {
198
- const [pages, blocks] = await Promise.all([
216
+ async function discoverAll(cwd = process.cwd()) {
217
+ const [pages, blocks, integration] = await Promise.all([
218
+ discoverPages(),
219
+ discoverAllBlocks(cwd),
220
+ discoverIntegrationTemplates(cwd),
221
+ ]);
222
+ return [...pages, ...blocks, ...integration.templates].sort((a, b) =>
223
+ a.name.localeCompare(b.name),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Like {@link discoverAll} but also returns integration-template discovery
229
+ * errors (missing same-stem source, missing `type`, load failure). Use this
230
+ * when the caller wants to warn about malformed integration templates.
231
+ *
232
+ * @param {string} [cwd]
233
+ * @returns {Promise<{templates: object[], errors: {package: string, template?: string, message: string}[]}>}
234
+ */
235
+ async function discoverAllWithErrors(cwd = process.cwd()) {
236
+ const [pages, blocks, integration] = await Promise.all([
199
237
  discoverPages(),
200
- discoverAllBlocks(),
238
+ discoverAllBlocks(cwd),
239
+ discoverIntegrationTemplates(cwd),
201
240
  ]);
202
- return [...pages, ...blocks].sort((a, b) => a.name.localeCompare(b.name));
241
+ const templates = [...pages, ...blocks, ...integration.templates].sort(
242
+ (a, b) => a.name.localeCompare(b.name),
243
+ );
244
+ return {templates, errors: integration.errors};
245
+ }
246
+
247
+ export {discoverAllWithErrors};
248
+
249
+ /**
250
+ * Recursively collect integration template doc files under `root`.
251
+ * Returns absolute paths to files ending in one of DOC_SUFFIXES.
252
+ *
253
+ * @param {string} root
254
+ * @returns {string[]}
255
+ */
256
+ function findIntegrationDocFiles(root) {
257
+ const results = [];
258
+ if (!fs.existsSync(root)) return results;
259
+ const walk = dir => {
260
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
261
+ const full = path.join(dir, entry.name);
262
+ if (entry.isDirectory()) {
263
+ walk(full);
264
+ } else if (DOC_SUFFIXES.some(suffix => entry.name.endsWith(suffix))) {
265
+ results.push(full);
266
+ }
267
+ }
268
+ };
269
+ walk(root);
270
+ return results;
271
+ }
272
+
273
+ /**
274
+ * The doc-file suffix present on `file`, or null if none matches.
275
+ * @param {string} file
276
+ */
277
+ function matchedDocSuffix(file) {
278
+ return DOC_SUFFIXES.find(suffix => file.endsWith(suffix)) ?? null;
279
+ }
280
+
281
+ /**
282
+ * Discover templates contributed by configured integrations.
283
+ *
284
+ * For each integration with a resolved `templates` root, every
285
+ * `<id>.doc.{ts,mjs,js}` file is a template whose id is its path relative to
286
+ * the templates root with the `.doc.*` suffix stripped (kebab-case, may be
287
+ * nested). The doc's `type` (page|block) decides scaffolding — there is no
288
+ * `/pages` vs `/blocks` requirement. A same-stem sibling source file
289
+ * (`<id>.tsx`) is required; a doc missing its source, or missing `type`, is
290
+ * an integration error and that template is skipped (recorded in `errors`).
291
+ *
292
+ * @param {string} [cwd]
293
+ * @returns {Promise<{templates: object[], errors: {package: string, template?: string, message: string}[]}>}
294
+ */
295
+ async function discoverIntegrationTemplates(cwd = process.cwd()) {
296
+ const templates = [];
297
+ const errors = [];
298
+
299
+ let loadedIntegrations;
300
+ try {
301
+ const project = await Project.load(cwd);
302
+ loadedIntegrations = project.loadedIntegrations;
303
+ } catch {
304
+ // Config load failures are surfaced elsewhere (discover/doctor); here we
305
+ // simply contribute no integration templates.
306
+ return {templates, errors};
307
+ }
308
+
309
+ for (const integration of loadedIntegrations) {
310
+ const result = await discoverIntegrationTemplatesForOne(integration);
311
+ templates.push(...result.templates);
312
+ errors.push(...result.errors);
313
+ }
314
+
315
+ return {templates, errors};
316
+ }
317
+
318
+ /**
319
+ * Discover the templates contributed by a SINGLE integration. Same per-template
320
+ * rules as {@link discoverIntegrationTemplates} (same-stem source required,
321
+ * page|block type required); broken templates are recorded in `errors` rather
322
+ * than thrown. Exposed for `validate-integration`.
323
+ *
324
+ * @param {{name?: string, __spec?: string, templates?: string}} integration
325
+ * @returns {Promise<{templates: object[], errors: {package: string, template?: string, message: string}[]}>}
326
+ */
327
+ export async function discoverIntegrationTemplatesForOne(integration) {
328
+ const templates = [];
329
+ const errors = [];
330
+
331
+ const root = integration?.templates;
332
+ const pkgLabel = integration?.name ?? integration?.__spec ?? 'integration';
333
+ if (!root || !fs.existsSync(root)) return {templates, errors};
334
+
335
+ for (const docPath of findIntegrationDocFiles(root)) {
336
+ const suffix = matchedDocSuffix(docPath);
337
+ const id = path
338
+ .relative(root, docPath)
339
+ .slice(0, -suffix.length)
340
+ .split(path.sep)
341
+ .join('/');
342
+
343
+ const sourcePath = docPath.slice(0, -suffix.length) + '.tsx';
344
+ if (!fs.existsSync(sourcePath)) {
345
+ errors.push({
346
+ package: pkgLabel,
347
+ template: id,
348
+ message: `Template "${id}" is missing its same-stem source file ${path.basename(sourcePath)}.`,
349
+ });
350
+ continue;
351
+ }
352
+
353
+ let doc;
354
+ try {
355
+ doc = await loadIntegrationDoc(docPath, `Template "${id}"`);
356
+ } catch (err) {
357
+ errors.push({
358
+ package: pkgLabel,
359
+ template: id,
360
+ message: `Template "${id}" failed to load: ${err.message}`,
361
+ });
362
+ continue;
363
+ }
364
+
365
+ // loadIntegrationDoc validates against the envelope (incl. a 'page'|'block'
366
+ // type) at the load boundary, so a valid doc always has a type. This guard
367
+ // stays as defense-in-depth.
368
+ const type = doc?.type;
369
+ if (type !== 'page' && type !== 'block') {
370
+ errors.push({
371
+ package: pkgLabel,
372
+ template: id,
373
+ message: `Template "${id}" is missing a "type" of "page" or "block". Author it with createPageTemplate/createBlockTemplate.`,
374
+ });
375
+ continue;
376
+ }
377
+
378
+ templates.push({
379
+ type,
380
+ dirName: id,
381
+ name: doc?.name || id,
382
+ description: doc?.description || '',
383
+ category: doc?.category || '',
384
+ isReady: true,
385
+ scaffold: false,
386
+ componentsUsed: doc?.componentsUsed ?? [],
387
+ filePath: sourcePath,
388
+ docPath,
389
+ package: pkgLabel,
390
+ });
391
+ }
392
+
393
+ return {templates, errors};
203
394
  }
204
395
 
205
396
  export async function findRelatedBlocks(componentName, cwd) {
@@ -491,71 +682,6 @@ function extractSkeleton(source) {
491
682
  return out.filter(l => l.trim()).join('\n');
492
683
  }
493
684
 
494
- /**
495
- * Fetch a template by ID using the `template.get` hook in astryx.config.mjs.
496
- * @param {string} id
497
- * @param {object} [options]
498
- * @param {string} [options.cwd]
499
- * @returns {Promise<{type: 'template.get', data: {id: string, source: string}}>}
500
- */
501
- export async function getTemplateById(id, options = {}) {
502
- const {cwd = process.cwd()} = options;
503
- const config = await loadConfig(cwd);
504
-
505
- const getter = config.template?.get;
506
- if (typeof getter !== 'function') {
507
- throw new AstryxError(
508
- 'Template fetching by ID is not configured.\n' +
509
- 'Add a template.get function to astryx.config.mjs:\n\n' +
510
- ' export default {\n' +
511
- ' template: {\n' +
512
- ' get: async (id) => { /* return template source string */ },\n' +
513
- ' },\n' +
514
- ' };',
515
- undefined,
516
- ERROR_CODES.ERR_TEMPLATE_CONFIG,
517
- );
518
- }
519
-
520
- let source;
521
- try {
522
- source = await getter(id);
523
- } catch (err) {
524
- const detail = err instanceof Error ? err.message : String(err);
525
- throw new AstryxError(
526
- `template.get("${id}") threw an error: ${detail}`,
527
- undefined,
528
- ERROR_CODES.ERR_TEMPLATE_GET,
529
- );
530
- }
531
-
532
- if (source == null) {
533
- throw new AstryxError(
534
- `template.get("${id}") returned ${source} — no template found for that ID`,
535
- undefined,
536
- ERROR_CODES.ERR_TEMPLATE_GET,
537
- );
538
- }
539
-
540
- if (typeof source !== 'string') {
541
- throw new AstryxError(
542
- `template.get("${id}") must return a string, got ${typeof source}`,
543
- undefined,
544
- ERROR_CODES.ERR_TEMPLATE_GET,
545
- );
546
- }
547
-
548
- if (source.trim() === '') {
549
- throw new AstryxError(
550
- `template.get("${id}") returned an empty string`,
551
- undefined,
552
- ERROR_CODES.ERR_TEMPLATE_GET,
553
- );
554
- }
555
-
556
- return {type: 'template.get', data: {id, source}};
557
- }
558
-
559
685
  /**
560
686
  * @param {string} [name]
561
687
  * @param {object} [options]
@@ -563,7 +689,8 @@ export async function getTemplateById(id, options = {}) {
563
689
  * @param {boolean} [options.list]
564
690
  * @param {boolean} [options.skeleton]
565
691
  * @param {boolean} [options.show]
566
- * @param {'page'|'block'} [options.type] - Filter list views by template kind.
692
+ * @param {'page'|'block'} [options.type] - Filter list views / narrow lookups by template kind.
693
+ * @param {string} [options.package] - Narrow lookups to a specific package (id-only matches across packages are ambiguous).
567
694
  * @param {string} [options.cwd]
568
695
  * @returns {Promise<{type: string, data: unknown}>}
569
696
  */
@@ -574,34 +701,65 @@ export async function template(name, options = {}) {
574
701
  show = false,
575
702
  targetPath,
576
703
  type,
704
+ package: packageFilter,
577
705
  cwd = process.cwd(),
578
706
  } = options;
579
- const templates = await discoverAll();
707
+ const templates = await discoverAll(cwd);
708
+
709
+ /**
710
+ * Identity for a template in package-scoped views. Core (built-in)
711
+ * templates have no `package` field; report them under @astryxdesign/core.
712
+ * @param {{package?: string}} t
713
+ */
714
+ const pkgOf = t => t.package ?? CORE_PACKAGE;
580
715
 
581
716
  if (list || (!name && !skeleton)) {
582
717
  let filtered = templates;
583
- if (type) filtered = templates.filter(t => t.type === type);
718
+ if (type) filtered = filtered.filter(t => t.type === type);
719
+ if (packageFilter) filtered = filtered.filter(t => pkgOf(t) === packageFilter);
584
720
  return {
585
721
  type: 'template.list',
586
722
  data: filtered.map(t => ({
587
- name: t.dirName,
723
+ id: t.dirName,
724
+ name: t.name,
725
+ // `displayName` retained for back-compat with existing consumers.
588
726
  displayName: t.name,
589
727
  description: t.description,
728
+ type: t.type,
729
+ package: pkgOf(t),
730
+ category: t.category || undefined,
731
+ componentsUsed: t.componentsUsed ?? undefined,
590
732
  isReady: t.isReady,
591
733
  scaffold: t.scaffold ?? false,
592
- type: t.type,
593
734
  })),
594
735
  };
595
736
  }
596
737
 
597
- const match = templates.find(t => t.dirName === name);
598
- if (name && !match) {
738
+ // Resolve `name` to a single template. The same id can appear across types
739
+ // and/or packages (e.g. a core "hero" page and an integration "hero"
740
+ // block); narrow with --type / --package.
741
+ let candidates = templates.filter(t => t.dirName === name);
742
+ if (type) candidates = candidates.filter(t => t.type === type);
743
+ if (packageFilter) candidates = candidates.filter(t => pkgOf(t) === packageFilter);
744
+
745
+ if (name && candidates.length === 0) {
599
746
  throw new AstryxError(
600
747
  `Unknown template "${name}"`,
601
748
  templates.map(t => ({name: t.dirName, reason: `${t.type} template`})),
602
749
  ERROR_CODES.ERR_UNKNOWN_TEMPLATE,
603
750
  );
604
751
  }
752
+ if (name && candidates.length > 1) {
753
+ throw new AstryxError(
754
+ `Template "${name}" is ambiguous — narrow it with --type and/or --package.`,
755
+ candidates.map(t => ({
756
+ name: t.dirName,
757
+ reason: `${t.type} template in ${pkgOf(t)}`,
758
+ })),
759
+ ERROR_CODES.ERR_AMBIGUOUS_TEMPLATE,
760
+ );
761
+ }
762
+ const match = candidates[0];
605
763
 
606
764
  if (skeleton) {
607
765
  if (!match) {