@astryxdesign/cli 0.1.1 → 0.1.2-canary.043f3fd

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 (185) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +31 -8
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +87 -28
  7. package/src/api/doctor.mjs +7 -22
  8. package/src/api/index.mjs +1 -0
  9. package/src/api/layout.mjs +302 -0
  10. package/src/api/layout.test.mjs +241 -0
  11. package/src/api/template-integration.test.mjs +225 -0
  12. package/src/api/template.mjs +421 -122
  13. package/src/api/template.test.mjs +2 -0
  14. package/src/api/theme-add.mjs +182 -0
  15. package/src/api/validate-integration.mjs +370 -0
  16. package/src/api/validate-integration.test.mjs +222 -0
  17. package/src/codemod.mjs +93 -0
  18. package/src/codemod.test.mjs +134 -0
  19. package/src/codemods/__tests__/registry.test.mjs +2 -0
  20. package/src/codemods/__tests__/runner.test.mjs +103 -0
  21. package/src/codemods/integration-discovery.mjs +168 -0
  22. package/src/codemods/integration-discovery.test.mjs +234 -0
  23. package/src/codemods/integration-runner.mjs +109 -0
  24. package/src/codemods/registry.mjs +2 -0
  25. package/src/codemods/run-codemod.mjs +207 -0
  26. package/src/codemods/runner.mjs +73 -96
  27. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  28. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
  29. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  30. package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
  31. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  32. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  33. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  34. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  35. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  36. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  37. package/src/commands/build-theme.import-path.test.mjs +22 -2
  38. package/src/commands/build-theme.mjs +185 -16
  39. package/src/commands/component/index.mjs +44 -10
  40. package/src/commands/component-ownership.test.mjs +227 -0
  41. package/src/commands/discover.mjs +5 -16
  42. package/src/commands/doctor.test.mjs +3 -3
  43. package/src/commands/init.mjs +34 -8
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/json-contract.test.mjs +0 -32
  46. package/src/commands/layout.mjs +139 -0
  47. package/src/commands/swizzle.mjs +241 -224
  48. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  49. package/src/commands/swizzle.routing.test.mjs +279 -0
  50. package/src/commands/template.mjs +33 -37
  51. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  52. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  53. package/src/commands/upgrade.mjs +349 -239
  54. package/src/commands/validate-integration.mjs +110 -0
  55. package/src/commands/validate-integration.test.mjs +124 -0
  56. package/src/config.mjs +18 -0
  57. package/src/config.test.mjs +100 -0
  58. package/src/index.mjs +13 -3
  59. package/src/integration.mjs +19 -0
  60. package/src/lib/component-discovery.mjs +177 -0
  61. package/src/lib/config-cache.mjs +91 -0
  62. package/src/lib/config-cache.test.mjs +83 -0
  63. package/src/lib/config-schema.mjs +105 -0
  64. package/src/lib/error-codes.mjs +18 -12
  65. package/src/lib/integration-warnings.mjs +62 -0
  66. package/src/lib/integration-warnings.test.mjs +102 -0
  67. package/src/lib/integrations.mjs +132 -0
  68. package/src/lib/integrations.test.mjs +135 -0
  69. package/src/lib/levenshtein.mjs +29 -0
  70. package/src/lib/manifest.mjs +15 -2
  71. package/src/lib/module-loader.mjs +80 -0
  72. package/src/lib/module-loader.test.mjs +106 -0
  73. package/src/lib/package-scanner.mjs +31 -7
  74. package/src/lib/project.mjs +502 -0
  75. package/src/lib/project.test.mjs +308 -0
  76. package/src/lib/string-utils.mjs +5 -14
  77. package/src/lib/xle/browser.d.ts +100 -0
  78. package/src/lib/xle/browser.mjs +120 -0
  79. package/src/lib/xle/expand.mjs +622 -0
  80. package/src/lib/xle/parse.mjs +581 -0
  81. package/src/lib/xle/print.mjs +174 -0
  82. package/src/lib/xle/registry-core.mjs +170 -0
  83. package/src/lib/xle/registry.mjs +237 -0
  84. package/src/lib/xle/splice.mjs +137 -0
  85. package/src/lib/xle/validate.mjs +356 -0
  86. package/src/lib/xle/xle.test.mjs +333 -0
  87. package/src/template.mjs +73 -0
  88. package/src/template.test.mjs +127 -0
  89. package/src/types/api.d.ts +2 -6
  90. package/src/types/base.d.ts +3 -9
  91. package/src/types/codemod.d.ts +81 -0
  92. package/src/types/component.d.ts +27 -2
  93. package/src/types/config.d.ts +70 -0
  94. package/src/types/error-codes.d.ts +5 -3
  95. package/src/types/index.d.ts +0 -1
  96. package/src/types/integration.d.ts +29 -0
  97. package/src/types/swizzle.d.ts +9 -2
  98. package/src/types/template-api.d.ts +54 -0
  99. package/src/types/template.d.ts +10 -7
  100. package/src/types/upgrade.d.ts +29 -0
  101. package/src/types/validate-integration.d.ts +24 -0
  102. package/src/utils/github.mjs +0 -252
  103. package/src/utils/interactive.mjs +2 -2
  104. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  105. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  106. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  107. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  108. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  109. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  110. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  111. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  112. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  113. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  114. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  115. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  116. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  117. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  118. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  119. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  120. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  121. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  122. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  123. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  124. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  125. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  126. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  127. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  128. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  129. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  130. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  131. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  132. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  133. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  134. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  135. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  136. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  137. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  138. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  139. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  140. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  141. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  142. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  143. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  144. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  145. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  146. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  147. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  148. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  149. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  150. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  151. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  152. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  153. package/templates/pages/shell-nav/page.tsx +321 -0
  154. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  155. package/templates/pages/shell-side-nav/page.tsx +242 -0
  156. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  157. package/templates/pages/shell-top-nav/page.tsx +224 -0
  158. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  159. package/templates/pages/theme-showcase/page.tsx +8 -19
  160. package/templates/themes/butter/butterTheme.ts +916 -0
  161. package/templates/themes/butter/icons.tsx +77 -0
  162. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  163. package/templates/themes/chocolate/icons.tsx +77 -0
  164. package/templates/themes/gothic/gothicTheme.ts +657 -0
  165. package/templates/themes/gothic/icons.tsx +77 -0
  166. package/templates/themes/manifest.json +90 -0
  167. package/templates/themes/matcha/icons.tsx +67 -0
  168. package/templates/themes/matcha/matchaTheme.ts +247 -0
  169. package/templates/themes/neutral/icons.tsx +77 -0
  170. package/templates/themes/neutral/neutralTheme.ts +603 -0
  171. package/templates/themes/stone/icons.tsx +77 -0
  172. package/templates/themes/stone/stoneTheme.ts +652 -0
  173. package/templates/themes/y2k/icons.tsx +67 -0
  174. package/templates/themes/y2k/y2kTheme.ts +617 -0
  175. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  176. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  177. package/src/commands/gap-report.mjs +0 -456
  178. package/src/commands/gap-report.test.mjs +0 -163
  179. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  180. package/src/lib/config.mjs +0 -86
  181. package/src/lib/config.test.mjs +0 -42
  182. package/src/types/gap-report.d.ts +0 -29
  183. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  184. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
  185. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -6,11 +6,38 @@
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
- import {assertWithin, isFilePathArg, PathSafetyError} from '../utils/path-safety.mjs';
12
+ import {
13
+ assertWithin,
14
+ isFilePathArg,
15
+ PathSafetyError,
16
+ } from '../utils/path-safety.mjs';
11
17
  import {AstryxError} from './error.mjs';
12
18
  import {ERROR_CODES} from '../lib/error-codes.mjs';
13
- 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
+ }
14
41
 
15
42
  const TEMPLATES_DIR = path.join(CLI_ROOT, 'templates');
16
43
  const PAGES_DIR = path.join(TEMPLATES_DIR, 'pages');
@@ -23,7 +50,7 @@ const BLOCKS_DIR = path.join(TEMPLATES_DIR, 'blocks');
23
50
  * or not. Mirrors apps/docsite/public/template-assets/placeholder.svg.
24
51
  */
25
52
  const PLACEHOLDER_IMAGE =
26
- "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E";
53
+ 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E';
27
54
 
28
55
  /**
29
56
  * Demo-image sources to strip from scaffolded projects — Meta's lookaside CDN
@@ -60,9 +87,12 @@ export {discoverAll as discoverTemplates};
60
87
  export function listTemplates() {
61
88
  const all = [];
62
89
  if (fs.existsSync(PAGES_DIR)) {
63
- all.push(...fs.readdirSync(PAGES_DIR, {withFileTypes: true})
64
- .filter(e => e.isDirectory())
65
- .map(e => e.name));
90
+ all.push(
91
+ ...fs
92
+ .readdirSync(PAGES_DIR, {withFileTypes: true})
93
+ .filter(e => e.isDirectory())
94
+ .map(e => e.name),
95
+ );
66
96
  }
67
97
  return all.sort();
68
98
  }
@@ -83,7 +113,8 @@ function findDocFiles(dir, pattern) {
83
113
 
84
114
  async function discoverPages() {
85
115
  if (!fs.existsSync(PAGES_DIR)) return [];
86
- const dirs = fs.readdirSync(PAGES_DIR, {withFileTypes: true})
116
+ const dirs = fs
117
+ .readdirSync(PAGES_DIR, {withFileTypes: true})
87
118
  .filter(e => e.isDirectory());
88
119
 
89
120
  const templates = [];
@@ -132,8 +163,8 @@ async function discoverBlocks() {
132
163
  }
133
164
 
134
165
  /**
135
- * Discover blocks from external packages that declare `xds.blocks`.
136
- * 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.
137
168
  *
138
169
  * @param {string} [cwd]
139
170
  */
@@ -182,20 +213,190 @@ async function discoverAllBlocks(cwd = process.cwd()) {
182
213
  return [...core, ...external];
183
214
  }
184
215
 
185
- async function discoverAll() {
186
- const [pages, blocks] = await Promise.all([
216
+ async function discoverAll(cwd = process.cwd()) {
217
+ const [pages, blocks, integration] = await Promise.all([
187
218
  discoverPages(),
188
- discoverAllBlocks(),
219
+ discoverAllBlocks(cwd),
220
+ discoverIntegrationTemplates(cwd),
189
221
  ]);
190
- return [...pages, ...blocks].sort((a, b) => a.name.localeCompare(b.name));
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([
237
+ discoverPages(),
238
+ discoverAllBlocks(cwd),
239
+ discoverIntegrationTemplates(cwd),
240
+ ]);
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};
191
394
  }
192
395
 
193
396
  export async function findRelatedBlocks(componentName, cwd) {
194
397
  const blocks = await discoverAllBlocks(cwd);
195
398
  return blocks.filter(b =>
196
- b.componentsUsed.some(c =>
197
- c.toLowerCase() === componentName.toLowerCase(),
198
- ),
399
+ b.componentsUsed.some(c => c.toLowerCase() === componentName.toLowerCase()),
199
400
  );
200
401
  }
201
402
 
@@ -218,7 +419,7 @@ export async function findShowcase(componentName, cwd, options) {
218
419
  return true;
219
420
  });
220
421
 
221
- const toResult = (b) => ({
422
+ const toResult = b => ({
222
423
  name: b.name,
223
424
  aspectRatio: b.aspectRatio,
224
425
  filePath: b.filePath,
@@ -242,8 +443,14 @@ export async function findShowcase(componentName, cwd, options) {
242
443
  }
243
444
 
244
445
  const UBIQUITOUS = new Set([
245
- 'Text', 'Heading', 'Button', 'HStack', 'VStack', 'Link',
246
- 'StackItem', 'Icon',
446
+ 'Text',
447
+ 'Heading',
448
+ 'Button',
449
+ 'HStack',
450
+ 'VStack',
451
+ 'Link',
452
+ 'StackItem',
453
+ 'Icon',
247
454
  ]);
248
455
 
249
456
  export function extractComponents(pagePath) {
@@ -259,22 +466,115 @@ export function extractComponents(pagePath) {
259
466
  while ((m = tagRegex.exec(src)) !== null) {
260
467
  matches.push(m[2]);
261
468
  }
262
- return [...new Set(
263
- matches
264
- .filter(n => !['Theme', 'ThemeProvider'].includes(n))
265
- .filter(n => !UBIQUITOUS.has(n))
266
- .map(n => n.replace(/(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/, ''))
267
- .filter(Boolean),
268
- )].sort();
469
+ return [
470
+ ...new Set(
471
+ matches
472
+ .filter(n => !['Theme', 'ThemeProvider'].includes(n))
473
+ .filter(n => !UBIQUITOUS.has(n))
474
+ .map(n =>
475
+ n.replace(
476
+ /(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/,
477
+ '',
478
+ ),
479
+ )
480
+ .filter(Boolean),
481
+ ),
482
+ ].sort();
269
483
  }
270
484
 
271
485
  const STRUCTURAL = new Set([
272
- 'AppShell', 'Layout', 'LayoutHeader', 'LayoutContent', 'LayoutPanel',
273
- 'LayoutFooter', 'Card', 'Section', 'Grid', 'GridSpan', 'List',
274
- 'Table', 'TabList', 'Toolbar', 'SideNav', 'TopNav', 'Dialog',
275
- 'FormLayout', 'Center',
486
+ 'AppShell',
487
+ 'Layout',
488
+ 'LayoutHeader',
489
+ 'LayoutContent',
490
+ 'LayoutPanel',
491
+ 'LayoutFooter',
492
+ 'Card',
493
+ 'Section',
494
+ 'Grid',
495
+ 'GridSpan',
496
+ 'List',
497
+ 'Table',
498
+ 'TabList',
499
+ 'Toolbar',
500
+ 'SideNav',
501
+ 'TopNav',
502
+ 'Dialog',
503
+ 'FormLayout',
504
+ 'Center',
276
505
  ]);
277
506
 
507
+ const SPATIAL_PROPS = [
508
+ 'padding',
509
+ 'contentPadding',
510
+ 'gap',
511
+ 'rowGap',
512
+ 'columnGap',
513
+ 'columns',
514
+ 'minChildWidth',
515
+ 'hasDivider',
516
+ 'defaultHasDividers',
517
+ 'variant',
518
+ 'density',
519
+ 'role',
520
+ 'height',
521
+ 'width',
522
+ 'maxWidth',
523
+ ];
524
+
525
+ /**
526
+ * Copy allowlisted layout props verbatim from a JSX opening-tag fragment.
527
+ * Uses quote/brace matching so object literals and spaced strings stay intact.
528
+ *
529
+ * @param {string} tagText
530
+ * @returns {string[]}
531
+ */
532
+ function extractSpatialAttrs(tagText) {
533
+ const attrs = [];
534
+ for (const name of SPATIAL_PROPS) {
535
+ const eqMatch = tagText.match(new RegExp(`\\b${name}\\s*=\\s*`));
536
+ if (eqMatch) {
537
+ const start = eqMatch.index;
538
+ let i = eqMatch.index + eqMatch[0].length;
539
+ const rest = tagText.slice(i);
540
+
541
+ if (rest[0] === '"' || rest[0] === "'") {
542
+ const q = rest[0];
543
+ i += 1;
544
+ while (i < tagText.length && tagText[i] !== q) {
545
+ if (tagText[i] === '\\') i += 1;
546
+ i += 1;
547
+ }
548
+ if (i < tagText.length) i += 1;
549
+ } else if (rest[0] === '{') {
550
+ let depth = 0;
551
+ while (i < tagText.length) {
552
+ if (tagText[i] === '{') depth += 1;
553
+ else if (tagText[i] === '}') {
554
+ depth -= 1;
555
+ if (depth === 0) {
556
+ i += 1;
557
+ break;
558
+ }
559
+ }
560
+ i += 1;
561
+ }
562
+ } else {
563
+ const bare = rest.match(/^[^\s/>]+/);
564
+ i += bare ? bare[0].length : 0;
565
+ }
566
+
567
+ attrs.push(tagText.slice(start, i).trim());
568
+ continue;
569
+ }
570
+
571
+ if (new RegExp(`\\b${name}(?=[\\s/>])`).test(tagText)) {
572
+ attrs.push(name);
573
+ }
574
+ }
575
+ return attrs;
576
+ }
577
+
278
578
  function extractSkeleton(source) {
279
579
  const lines = source.split('\n');
280
580
  const out = [];
@@ -287,11 +587,18 @@ function extractSkeleton(source) {
287
587
  for (let i = 0; i < lines.length; i++) {
288
588
  const t = lines[i].trim();
289
589
 
290
- if (t.match(/^export\s+default\s+function/)) { inDefaultExport = true; continue; }
291
- if (inDefaultExport && t.match(/^return\s*\(/)) { capturing = true; continue; }
590
+ if (t.match(/^export\s+default\s+function/)) {
591
+ inDefaultExport = true;
592
+ continue;
593
+ }
594
+ if (inDefaultExport && t.match(/^return\s*\(/)) {
595
+ capturing = true;
596
+ continue;
597
+ }
292
598
  if (!capturing) continue;
293
599
  if (out.length >= MAX_LINES) {
294
- if (!out[out.length - 1]?.includes('...')) out.push(' '.repeat(depth) + '...');
600
+ if (!out[out.length - 1]?.includes('...'))
601
+ out.push(' '.repeat(depth) + '...');
295
602
  continue;
296
603
  }
297
604
 
@@ -309,20 +616,13 @@ function extractSkeleton(source) {
309
616
  if (lines[j].includes('>')) break;
310
617
  }
311
618
 
312
- const props = [];
313
- const propRegex = /\b(padding|contentPadding|gap|rowGap|columnGap|columns|minChildWidth|hasDivider|defaultHasDividers|variant|density|role|height|width|maxWidth)\s*[=]\s*\{?\s*['"]?([^}'"\s,/>]+)/g;
314
- let m;
315
- while ((m = propRegex.exec(tagText)) !== null) {
316
- const val = m[2];
317
- if (val === 'true') props.push(m[1]);
318
- else if (/^\d+$/.test(val)) props.push(`${m[1]}={${val}}`);
319
- else props.push(`${m[1]}="${val}"`);
320
- }
321
-
619
+ const props = extractSpatialAttrs(tagText);
322
620
  const hasSpatialProps = props.length > 0;
323
621
  const propStr = hasSpatialProps ? ' ' + props.join(' ') : '';
324
622
  const isVStack = comp === 'VStack' || comp === 'HStack';
325
- const isSelfClosing = tagText.match(new RegExp('<' + tagName + '[^>]*/>', 's'));
623
+ const isSelfClosing = tagText.match(
624
+ new RegExp('<' + tagName + '[^>]*/>', 's'),
625
+ );
326
626
 
327
627
  if (isVStack && !hasSpatialProps) continue;
328
628
 
@@ -349,13 +649,18 @@ function extractSkeleton(source) {
349
649
  continue;
350
650
  }
351
651
 
352
- const slotMatch = t.match(/^(header|content|footer|start|end|sideNav|topNav)\s*=\s*\{/);
652
+ const slotMatch = t.match(
653
+ /^(header|content|footer|start|end|sideNav|topNav)\s*=\s*\{/,
654
+ );
353
655
  if (slotMatch) {
354
656
  out.push(' '.repeat(depth) + `/* ${slotMatch[1]}: */`);
355
657
  continue;
356
658
  }
357
659
 
358
- if (t.startsWith('<div') && (t.includes('padding') || t.includes('maxWidth') || t.includes('gap:'))) {
660
+ if (
661
+ t.startsWith('<div') &&
662
+ (t.includes('padding') || t.includes('maxWidth') || t.includes('gap:'))
663
+ ) {
359
664
  const styleProps = [];
360
665
  const divText = lines.slice(i, Math.min(i + 5, lines.length)).join(' ');
361
666
  const pp = divText.match(/padding[^:]*:\s*['"]?([^'"},)]+)/);
@@ -377,67 +682,6 @@ function extractSkeleton(source) {
377
682
  return out.filter(l => l.trim()).join('\n');
378
683
  }
379
684
 
380
- /**
381
- * Fetch a template by ID using the `template.get` hook in astryx.config.mjs.
382
- * @param {string} id
383
- * @param {object} [options]
384
- * @param {string} [options.cwd]
385
- * @returns {Promise<{type: 'template.get', data: {id: string, source: string}}>}
386
- */
387
- export async function getTemplateById(id, options = {}) {
388
- const {cwd = process.cwd()} = options;
389
- const config = await loadConfig(cwd);
390
-
391
- const getter = config.template?.get;
392
- if (typeof getter !== 'function') {
393
- throw new AstryxError(
394
- 'Template fetching by ID is not configured.\n' +
395
- 'Add a template.get function to astryx.config.mjs:\n\n' +
396
- ' export default {\n' +
397
- ' template: {\n' +
398
- " get: async (id) => { /* return template source string */ },\n" +
399
- ' },\n' +
400
- ' };',
401
- undefined,
402
- ERROR_CODES.ERR_TEMPLATE_CONFIG,
403
- );
404
- }
405
-
406
- let source;
407
- try {
408
- source = await getter(id);
409
- } catch (err) {
410
- const detail = err instanceof Error ? err.message : String(err);
411
- throw new AstryxError(`template.get("${id}") threw an error: ${detail}`, undefined, ERROR_CODES.ERR_TEMPLATE_GET);
412
- }
413
-
414
- if (source == null) {
415
- throw new AstryxError(
416
- `template.get("${id}") returned ${source} — no template found for that ID`,
417
- undefined,
418
- ERROR_CODES.ERR_TEMPLATE_GET,
419
- );
420
- }
421
-
422
- if (typeof source !== 'string') {
423
- throw new AstryxError(
424
- `template.get("${id}") must return a string, got ${typeof source}`,
425
- undefined,
426
- ERROR_CODES.ERR_TEMPLATE_GET,
427
- );
428
- }
429
-
430
- if (source.trim() === '') {
431
- throw new AstryxError(
432
- `template.get("${id}") returned an empty string`,
433
- undefined,
434
- ERROR_CODES.ERR_TEMPLATE_GET,
435
- );
436
- }
437
-
438
- return {type: 'template.get', data: {id, source}};
439
- }
440
-
441
685
  /**
442
686
  * @param {string} [name]
443
687
  * @param {object} [options]
@@ -445,38 +689,77 @@ export async function getTemplateById(id, options = {}) {
445
689
  * @param {boolean} [options.list]
446
690
  * @param {boolean} [options.skeleton]
447
691
  * @param {boolean} [options.show]
448
- * @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).
449
694
  * @param {string} [options.cwd]
450
695
  * @returns {Promise<{type: string, data: unknown}>}
451
696
  */
452
697
  export async function template(name, options = {}) {
453
- const {list = false, skeleton = false, show = false, targetPath, type, cwd = process.cwd()} = options;
454
- const templates = await discoverAll();
698
+ const {
699
+ list = false,
700
+ skeleton = false,
701
+ show = false,
702
+ targetPath,
703
+ type,
704
+ package: packageFilter,
705
+ cwd = process.cwd(),
706
+ } = options;
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;
455
715
 
456
716
  if (list || (!name && !skeleton)) {
457
717
  let filtered = templates;
458
- 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);
459
720
  return {
460
721
  type: 'template.list',
461
722
  data: filtered.map(t => ({
462
- name: t.dirName,
723
+ id: t.dirName,
724
+ name: t.name,
725
+ // `displayName` retained for back-compat with existing consumers.
463
726
  displayName: t.name,
464
727
  description: t.description,
728
+ type: t.type,
729
+ package: pkgOf(t),
730
+ category: t.category || undefined,
731
+ componentsUsed: t.componentsUsed ?? undefined,
465
732
  isReady: t.isReady,
466
733
  scaffold: t.scaffold ?? false,
467
- type: t.type,
468
734
  })),
469
735
  };
470
736
  }
471
737
 
472
- const match = templates.find(t => t.dirName === name);
473
- 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) {
474
746
  throw new AstryxError(
475
747
  `Unknown template "${name}"`,
476
748
  templates.map(t => ({name: t.dirName, reason: `${t.type} template`})),
477
749
  ERROR_CODES.ERR_UNKNOWN_TEMPLATE,
478
750
  );
479
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];
480
763
 
481
764
  if (skeleton) {
482
765
  if (!match) {
@@ -487,7 +770,11 @@ export async function template(name, options = {}) {
487
770
  );
488
771
  }
489
772
  if (!fs.existsSync(match.filePath)) {
490
- throw new AstryxError(`No source file found for template "${name}"`, undefined, ERROR_CODES.ERR_NO_SOURCE);
773
+ throw new AstryxError(
774
+ `No source file found for template "${name}"`,
775
+ undefined,
776
+ ERROR_CODES.ERR_NO_SOURCE,
777
+ );
491
778
  }
492
779
  const src = fs.readFileSync(match.filePath, 'utf-8');
493
780
  return {
@@ -502,7 +789,11 @@ export async function template(name, options = {}) {
502
789
  }
503
790
 
504
791
  if (!fs.existsSync(match.filePath)) {
505
- throw new AstryxError(`No source file found for template "${name}"`, undefined, ERROR_CODES.ERR_NO_SOURCE);
792
+ throw new AstryxError(
793
+ `No source file found for template "${name}"`,
794
+ undefined,
795
+ ERROR_CODES.ERR_NO_SOURCE,
796
+ );
506
797
  }
507
798
 
508
799
  if (show || !targetPath) {
@@ -529,7 +820,11 @@ export async function template(name, options = {}) {
529
820
  });
530
821
  } catch (err) {
531
822
  if (err instanceof PathSafetyError) {
532
- throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
823
+ throw new AstryxError(
824
+ err.message,
825
+ undefined,
826
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
827
+ );
533
828
  }
534
829
  throw err;
535
830
  }
@@ -546,9 +841,8 @@ export async function template(name, options = {}) {
546
841
  outputFilePath = resolvedTarget;
547
842
  } else {
548
843
  outputDir = resolvedTarget;
549
- outputFileName = match.type === 'block'
550
- ? path.basename(match.filePath)
551
- : 'page.tsx';
844
+ outputFileName =
845
+ match.type === 'block' ? path.basename(match.filePath) : 'page.tsx';
552
846
  outputFilePath = path.join(outputDir, outputFileName);
553
847
  }
554
848
 
@@ -563,6 +857,11 @@ export async function template(name, options = {}) {
563
857
  const relOutput = path.relative(cwd, outputDir) || '.';
564
858
  return {
565
859
  type: 'template.copy',
566
- data: {template: name, outputDir: relOutput, fileName: outputFileName, filesCopied: 1},
860
+ data: {
861
+ template: name,
862
+ outputDir: relOutput,
863
+ fileName: outputFileName,
864
+ filesCopied: 1,
865
+ },
567
866
  };
568
867
  }