@astryxdesign/cli 0.1.6 → 0.1.7-canary.0a6de96

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 (69) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +97 -7
  3. package/docs/cli-integrations.doc.mjs +150 -0
  4. package/docs/internationalization.doc.mjs +243 -0
  5. package/docs/layout.doc.dense.mjs +5 -0
  6. package/docs/principles.doc.dense.mjs +5 -5
  7. package/docs/principles.doc.zh.mjs +5 -5
  8. package/docs/theme.doc.dense.mjs +12 -12
  9. package/docs/theme.doc.zh.mjs +10 -10
  10. package/docs/tokens.doc.dense.mjs +6 -7
  11. package/docs/tokens.doc.zh.mjs +6 -7
  12. package/package.json +13 -9
  13. package/src/api/docOverlays.test.mjs +133 -0
  14. package/src/api/docs.mjs +14 -2
  15. package/src/api/integration-block-exports.test.mjs +240 -0
  16. package/src/api/template-suffix.test.mjs +246 -0
  17. package/src/api/template.mjs +104 -28
  18. package/src/api/validate-integration.mjs +0 -8
  19. package/src/codemods/__tests__/registry.test.mjs +1 -0
  20. package/src/codemods/registry.mjs +1 -0
  21. package/src/codemods/transforms/v0.1.7/__tests__/migrate-table-tableprops-to-direct-props.test.mjs +120 -0
  22. package/src/codemods/transforms/v0.1.7/__tests__/rename-table-renderprops-styles-to-xstyle.test.mjs +112 -0
  23. package/src/codemods/transforms/v0.1.7/index.mjs +27 -0
  24. package/src/codemods/transforms/v0.1.7/migrate-table-tableprops-to-direct-props.mjs +188 -0
  25. package/src/codemods/transforms/v0.1.7/rename-table-renderprops-styles-to-xstyle.mjs +197 -0
  26. package/src/commands/agent-docs.mjs +14 -0
  27. package/src/commands/agent-docs.test.mjs +20 -0
  28. package/src/config.mjs +5 -14
  29. package/src/doc.mjs +27 -0
  30. package/src/doc.test.mjs +383 -0
  31. package/src/integration.mjs +4 -15
  32. package/src/lib/component-discovery.importpath.test.mjs +59 -0
  33. package/src/lib/component-discovery.mjs +15 -5
  34. package/src/lib/component-format.mjs +45 -13
  35. package/src/lib/component-format.test.mjs +95 -1
  36. package/src/lib/component-loader.mjs +104 -2
  37. package/src/lib/componentDocOverlay.test.mjs +111 -0
  38. package/src/lib/config-schema.mjs +0 -30
  39. package/src/lib/hook-format.mjs +8 -3
  40. package/src/lib/xle/registry.mjs +0 -5
  41. package/src/schemas/doc-schema.mjs +226 -0
  42. package/src/schemas/template-schema.mjs +47 -0
  43. package/src/template.mjs +9 -67
  44. package/src/types/config.d.ts +11 -66
  45. package/src/types/doc.d.ts +23 -0
  46. package/src/types/integration.d.ts +7 -18
  47. package/src/types/template-api.d.ts +14 -50
  48. package/templates/blocks/components/Avatar/AvatarGroup.tsx +5 -7
  49. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -6
  50. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +3 -5
  51. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +8 -6
  52. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -5
  53. package/templates/blocks/components/ChatComposerInput/ChatComposerInputControlledInput.tsx +1 -1
  54. package/templates/blocks/components/ChatComposerInput/ChatComposerInputDisabled.tsx +1 -1
  55. package/templates/blocks/components/ChatComposerInput/ChatComposerInputMentionTrigger.tsx +1 -1
  56. package/templates/blocks/components/ChatComposerInput/ChatComposerInputMultipleTriggers.tsx +1 -1
  57. package/templates/blocks/components/ChatComposerInput/ChatComposerInputShowcase.tsx +1 -1
  58. package/templates/blocks/components/ChatComposerInput/ChatComposerInputSlashCommands.tsx +1 -1
  59. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.doc.mjs +14 -0
  60. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.tsx +41 -0
  61. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.doc.mjs +13 -0
  62. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.tsx +78 -0
  63. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.doc.mjs +14 -0
  64. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.tsx +38 -0
  65. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.doc.mjs +14 -0
  66. package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.tsx +47 -0
  67. package/templates/pages/ide/page.tsx +35 -41
  68. package/templates/pages/theme-showcase/page.tsx +7 -7
  69. package/templates/themes/neutral/neutralTheme.ts +63 -32
@@ -5,17 +5,17 @@
5
5
  export const docsDense = {
6
6
  description: 'Theme provider, custom themes, light/dark, component overrides',
7
7
  sections: [
8
- { title: 'Quick Start', content: [null, null, null, null, { type: 'prose', text: 'default import = runtime injection. /built import = pre-compiled CSS (pair with theme.css).' }] },
9
- { title: 'Themes', content: [null, null, { type: 'prose', text: 'published: neutral (start here), butter, chocolate, gothic (dark-only), matcha, stone, y2k. @astryxdesign/theme-{name} = source (runtime). @astryxdesign/theme-{name}/built = optimized (+ theme.css).' }] },
10
- { title: 'Props', content: [null] },
11
- { title: 'Custom Theme', content: [{ type: 'prose', text: 'CLI wizard or manual defineTheme. only override tokens that differ.' }, null] },
12
- { title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent hex via HCT.' }, null, null] },
13
- { title: 'Component Overrides', content: [{ type: 'prose', text: 'components field uses semantic component keys + style keys (base, variant:value, stateName), not raw selectors. for external CSS, prefer data-* selectors from `astryx docs styling`. write standard CSS (borderRadius, padding) — pipeline expands to internal vars. public vars (--button-press-scale etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `npx astryx component <Name>` for details.' }, null, null, null, null] },
14
- { title: 'Custom Variants', content: [{ type: 'prose', text: 'any unknown prop:value in components becomes a new variant. astryx theme build generates TS augmentations. works on any extensible prop axis (variant, status, etc).' }, null, null, null, null] },
15
- { title: 'Build for Production', content: [{ type: 'prose', text: 'npx astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
16
- { title: 'Runtime vs Built', content: [{ type: 'prose', text: 'runtime: useInsertionEffect injects styles client-side. built: static CSS on first paint. USE /built + theme.css FOR SSR.' }, null, null, null] },
17
- { title: 'Light/Dark', content: [{ type: 'prose', text: 'light-dark() in token values via [light, dark] tuples. mode=system follows OS.' }, null, null] },
18
- { title: 'Nesting', content: [{ type: 'prose', text: 'wrap sections in separate <Theme> providers' }, null] },
19
- { title: 'useTheme', content: [null, { type: 'prose', text: 'read-only. manage state at app level.' }] },
8
+ { section: 'Quick Start', title: 'Quick Start', content: [null, null, null, null, { type: 'prose', text: 'default import = runtime injection. /built import = pre-compiled CSS (pair with theme.css).' }] },
9
+ { section: 'Available Themes', title: 'Themes', content: [null, null, { type: 'prose', text: 'published: neutral (start here), butter, chocolate, gothic (dark-only), matcha, stone, y2k. @astryxdesign/theme-{name} = source (runtime). @astryxdesign/theme-{name}/built = optimized (+ theme.css).' }] },
10
+ { section: 'Theme Props', title: 'Props', content: [null] },
11
+ { section: 'Creating a Custom Theme', title: 'Custom Theme', content: [{ type: 'prose', text: 'CLI wizard or manual defineTheme. only override tokens that differ.' }, null] },
12
+ { section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: 'scale configs (color, typography, radius, motion) + explicit token overrides + component overrides. color derives full palette from accent hex via HCT.' }, null, null] },
13
+ { section: 'Component Style Overrides', title: 'Component Overrides', content: [{ type: 'prose', text: 'components field uses semantic component keys + style keys (base, variant:value, stateName), not raw selectors. for external CSS, prefer data-* selectors from `astryx docs styling`. write standard CSS (borderRadius, padding) — pipeline expands to internal vars. public vars (--button-press-scale etc) set directly. private vars (--_*) cannot be set — use CSS properties. run `npx astryx component <Name>` for details.' }, null, null, null, null] },
14
+ { section: 'Custom Variants', title: 'Custom Variants', content: [{ type: 'prose', text: 'any unknown prop:value in components becomes a new variant. astryx theme build generates TS augmentations. works on any extensible prop axis (variant, status, etc).' }, null, null, null, null] },
15
+ { section: 'Building Themes for Production', title: 'Build for Production', content: [{ type: 'prose', text: 'npx astryx theme build compiles defineTheme to static CSS. outputs .css + .js (__built:true) + .d.ts.' }, null, null, null, null] },
16
+ { section: 'Runtime vs Built Themes', title: 'Runtime vs Built', content: [{ type: 'prose', text: 'runtime: useInsertionEffect injects styles client-side. built: static CSS on first paint. USE /built + theme.css FOR SSR.' }, null, null, null] },
17
+ { section: 'Light/Dark Mode', title: 'Light/Dark', content: [{ type: 'prose', text: 'light-dark() in token values via [light, dark] tuples. mode=system follows OS.' }, null, null] },
18
+ { section: 'Nesting Themes', title: 'Nesting', content: [{ type: 'prose', text: 'wrap sections in separate <Theme> providers' }, null] },
19
+ { section: 'useTheme Hook', title: 'useTheme', content: [null, { type: 'prose', text: 'read-only. manage state at app level.' }] },
20
20
  ],
21
21
  };
@@ -5,15 +5,15 @@
5
5
  export const docsZh = {
6
6
  description: 'Theme 提供者、自定义主题、亮/暗模式和组件样式覆盖。',
7
7
  sections: [
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
- { title: 'Theme 属性', content: [null] },
11
- { title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
12
- { title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
13
- { title: '生产构建', content: [{ type: 'prose', text: 'npx astryx theme build 将 defineTheme 编译为静态 CSS。输出 .css + .js(__built:true)+ .d.ts。' }, null, null, null, null] },
14
- { title: '运行时 vs 构建', content: [{ type: 'prose', text: '运行时:useInsertionEffect 在客户端注入样式。构建:静态 CSS 在首次渲染时就存在。SSR 应用请使用 /built + theme.css。' }, null, null, null] },
15
- { title: '亮/暗模式', content: [{ type: 'prose', text: "令牌值使用 [light, dark] 元组实现自动模式切换。Theme 上 mode='system'(默认)跟随系统偏好。" }, null, null] },
16
- { title: '嵌套主题', content: [{ type: 'prose', text: '将不同部分包裹在独立的 <Theme> 提供者中。' }, null] },
17
- { title: 'useTheme 钩子', content: [null, { type: 'prose', text: '这是只读的。要更改主题/模式,在应用层管理状态并传递给 <Theme>。' }] },
8
+ { section: 'Quick Start', title: '快速开始', content: [null, null, null, null, { type: 'prose', text: '默认导入使用运行时样式注入。/built 导入使用预编译 CSS(需配合 theme.css)。' }] },
9
+ { section: 'Available Themes', title: '可用主题', content: [null, null, { type: 'prose', text: '已发布主题:neutral(推荐起点)、butter、chocolate、gothic(仅暗色)、matcha、stone、y2k。@astryxdesign/theme-{name} = 源码版(运行时注入)。@astryxdesign/theme-{name}/built = 优化版(配合 theme.css)。' }] },
10
+ { section: 'Theme Props', title: 'Theme 属性', content: [null] },
11
+ { section: 'Creating a Custom Theme', title: '创建自定义主题', content: [{ type: 'prose', text: '使用 CLI 向导(推荐)或手动 defineTheme。只覆盖与默认值不同的令牌。' }, null] },
12
+ { section: 'defineTheme', title: 'defineTheme', content: [{ type: 'prose', text: '支持比例配置(typography、radius、motion)+ 显式令牌覆盖 + 组件覆盖。' }, null, null] },
13
+ { section: 'Building Themes for Production', title: '生产构建', content: [{ type: 'prose', text: 'npx astryx theme build 将 defineTheme 编译为静态 CSS。输出 .css + .js(__built:true)+ .d.ts。' }, null, null, null, null] },
14
+ { section: 'Runtime vs Built Themes', title: '运行时 vs 构建', content: [{ type: 'prose', text: '运行时:useInsertionEffect 在客户端注入样式。构建:静态 CSS 在首次渲染时就存在。SSR 应用请使用 /built + theme.css。' }, null, null, null] },
15
+ { section: 'Light/Dark Mode', title: '亮/暗模式', content: [{ type: 'prose', text: "令牌值使用 [light, dark] 元组实现自动模式切换。Theme 上 mode='system'(默认)跟随系统偏好。" }, null, null] },
16
+ { section: 'Nesting Themes', title: '嵌套主题', content: [{ type: 'prose', text: '将不同部分包裹在独立的 <Theme> 提供者中。' }, null] },
17
+ { section: 'useTheme Hook', title: 'useTheme 钩子', content: [null, { type: 'prose', text: '这是只读的。要更改主题/模式,在应用层管理状态并传递给 <Theme>。' }] },
18
18
  ],
19
19
  };
@@ -5,12 +5,11 @@
5
5
  export const docsDense = {
6
6
  description: 'spacing/color/radius/type/shadow token ref',
7
7
  sections: [
8
- { title: 'Spacing', content: [{ type: 'prose', text: 'defined in tokens.stylex.ts. gap props use space0-space12.' }, null] },
9
- { title: 'Size', content: [{ type: 'prose', text: 'control heights for buttons/inputs/selectors.' }, null] },
10
- { title: 'Color', content: [{ type: 'prose', text: 'semantic colors, support light-dark() auto switching.' }, null, null, null] },
11
- { title: 'Radius', content: [null] },
12
- { title: 'Elevation', content: [null] },
13
- { title: 'Typography', content: [null, null, null, null] },
14
- { title: 'StyleX Usage', content: [null, null] },
8
+ { section: 'Color Tokens', title: 'Color', content: [{ type: 'prose', text: 'semantic colors, support light-dark() auto switching.' }, null, null, null] },
9
+ { section: 'Spacing Tokens', title: 'Spacing', content: [{ type: 'prose', text: 'defined in tokens.stylex.ts. gap props use space0-space12.' }, null] },
10
+ { section: 'Size Tokens', title: 'Size', content: [{ type: 'prose', text: 'control heights for buttons/inputs/selectors.' }, null] },
11
+ { section: 'Radius Tokens', title: 'Radius', content: [null] },
12
+ { section: 'Shadow Tokens', title: 'Elevation', content: [null] },
13
+ { section: 'Usage in StyleX', title: 'StyleX Usage', content: [null, null] },
15
14
  ],
16
15
  };
@@ -5,12 +5,11 @@
5
5
  export const docsZh = {
6
6
  description: '间距、颜色、圆角、排版和阴影设计令牌参考。',
7
7
  sections: [
8
- { title: '间距令牌', content: [{ type: 'prose', text: '所有设计令牌定义在 packages/core/src/theme/tokens.stylex.ts 中。组件的 gap 属性使用 space0-space12 映射到这些令牌。' }, null] },
9
- { title: '尺寸令牌', content: [{ type: 'prose', text: '控制按钮、输入框和选择器的一致高度。' }, null] },
10
- { title: '颜色令牌', content: [{ type: 'prose', text: '语义化颜色,支持 light-dark() 自动切换模式。' }, null, null, null] },
11
- { title: '圆角令牌', content: [null] },
12
- { title: '阴影令牌', content: [null] },
13
- { title: '排版令牌', content: [null, null, null, null] },
14
- { title: 'StyleX 用法', content: [null, null] },
8
+ { section: 'Color Tokens', title: '颜色令牌', content: [{ type: 'prose', text: '语义化颜色,支持 light-dark() 自动切换模式。' }, null, null, null] },
9
+ { section: 'Spacing Tokens', title: '间距令牌', content: [{ type: 'prose', text: '所有设计令牌定义在 packages/core/src/theme/tokens.stylex.ts 中。组件的 gap 属性使用 space0-space12 映射到这些令牌。' }, null] },
10
+ { section: 'Size Tokens', title: '尺寸令牌', content: [{ type: 'prose', text: '控制按钮、输入框和选择器的一致高度。' }, null] },
11
+ { section: 'Radius Tokens', title: '圆角令牌', content: [null] },
12
+ { section: 'Shadow Tokens', title: '阴影令牌', content: [null] },
13
+ { section: 'Usage in StyleX', title: 'StyleX 用法', content: [null, null] },
15
14
  ],
16
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7-canary.0a6de96",
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",
@@ -47,6 +47,10 @@
47
47
  "types": "./src/types/integration.d.ts",
48
48
  "import": "./src/integration.mjs"
49
49
  },
50
+ "./doc": {
51
+ "types": "./src/types/doc.d.ts",
52
+ "import": "./src/doc.mjs"
53
+ },
50
54
  "./template": {
51
55
  "types": "./src/types/template-api.d.ts",
52
56
  "import": "./src/template.mjs"
@@ -75,10 +79,10 @@
75
79
  "zod": "^4.4.3"
76
80
  },
77
81
  "peerDependencies": {
78
- "@astryxdesign/charts": "*",
79
- "@astryxdesign/core": "*",
80
- "@astryxdesign/lab": "*",
81
- "@astryxdesign/theme-neutral": "*",
82
+ "@astryxdesign/charts": "0.1.7-canary.0a6de96",
83
+ "@astryxdesign/core": "0.1.7-canary.0a6de96",
84
+ "@astryxdesign/lab": "0.1.7-canary.0a6de96",
85
+ "@astryxdesign/theme-neutral": "0.1.7-canary.0a6de96",
82
86
  "gpt-tokenizer": "^3.4.0"
83
87
  },
84
88
  "peerDependenciesMeta": {
@@ -96,10 +100,10 @@
96
100
  }
97
101
  },
98
102
  "devDependencies": {
99
- "@astryxdesign/charts": "*",
100
- "@astryxdesign/core": "*",
101
- "@astryxdesign/lab": "*",
102
- "@astryxdesign/theme-neutral": "*",
103
+ "@astryxdesign/charts": "0.1.7-canary.0a6de96",
104
+ "@astryxdesign/core": "0.1.7-canary.0a6de96",
105
+ "@astryxdesign/lab": "0.1.7-canary.0a6de96",
106
+ "@astryxdesign/theme-neutral": "0.1.7-canary.0a6de96",
103
107
  "gpt-tokenizer": "^3.4.0"
104
108
  },
105
109
  "scripts": {
@@ -0,0 +1,133 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Guards the --dense / --zh reference-doc overlays against drift (#2182).
5
+ * @input packages/cli/docs/{topic}.doc.mjs and its .doc.dense.mjs / .doc.zh.mjs overlays.
6
+ * @output Vitest failures naming any overlay section that does not anchor to a
7
+ * real base section, or whose content overrides land on the wrong block type.
8
+ * @position Regression gate for the docs API. Sits with the loader it guards.
9
+ *
10
+ * The overlays used to be applied BY ARRAY POSITION, so an overlay whose
11
+ * sections were ordered differently from the base grafted every title onto the
12
+ * wrong body. `docs tokens --dense` printed the entire colour table under a
13
+ * heading that said "## Spacing", above prose reading "gap props use
14
+ * space0-space12" — teaching any agent that spacing tokens are named
15
+ * `--color-*`. CLAUDE.md tells every agent to run that exact command at
16
+ * bootstrap, which is what made a doc bug into a codegen bug.
17
+ *
18
+ * Overlays now anchor to a base section by title, so a reordered or
19
+ * partial overlay is correct by construction. These tests keep it that way.
20
+ */
21
+
22
+ import {describe, it, expect} from 'vitest';
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import {pathToFileURL} from 'node:url';
26
+ import {docs} from './docs.mjs';
27
+
28
+ const DOCS_DIR = path.join(import.meta.dirname, '..', '..', 'docs');
29
+
30
+ /** Every base reference doc that has at least one overlay. */
31
+ function overlayPairs() {
32
+ const pairs = [];
33
+ for (const file of fs.readdirSync(DOCS_DIR)) {
34
+ const m = file.match(/^(.+)\.doc\.(dense|zh)\.mjs$/);
35
+ if (!m) continue;
36
+ const [, topic, variant] = m;
37
+ pairs.push({
38
+ topic,
39
+ variant,
40
+ basePath: path.join(DOCS_DIR, `${topic}.doc.mjs`),
41
+ overlayPath: path.join(DOCS_DIR, file),
42
+ });
43
+ }
44
+ return pairs;
45
+ }
46
+
47
+ async function load(p) {
48
+ return await import(pathToFileURL(p).href);
49
+ }
50
+
51
+ describe('reference doc overlays (#2182)', () => {
52
+ const pairs = overlayPairs();
53
+
54
+ it('finds overlays to check', () => {
55
+ // A rename that stops overlays being discovered must not silently pass.
56
+ expect(pairs.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ for (const {topic, variant, basePath, overlayPath} of pairs) {
60
+ it(`${topic} --${variant}: every overlay section anchors to a real base section`, async () => {
61
+ const base = await load(basePath);
62
+ const overlayMod = await load(overlayPath);
63
+ const overlay = overlayMod.docsDense || overlayMod.docsZh;
64
+
65
+ const baseTitles = base.docs.sections.map(s => s.title);
66
+ const unanchored = (overlay.sections || [])
67
+ .filter(s => s.section == null || !baseTitles.includes(s.section))
68
+ .map(s => s.section ?? `(no anchor) "${s.title}"`);
69
+
70
+ expect(
71
+ unanchored,
72
+ `${topic}.doc.${variant}.mjs has section entries that do not name a ` +
73
+ `base section via \`section:\`. Without an anchor the overlay is ` +
74
+ `applied by array position, which silently grafts each title onto ` +
75
+ `the wrong body. Base sections are: ${baseTitles.join(', ')}.`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it(`${topic} --${variant}: no base section is overridden twice`, async () => {
80
+ const overlayMod = await load(overlayPath);
81
+ const overlay = overlayMod.docsDense || overlayMod.docsZh;
82
+ const anchors = (overlay.sections || []).map(s => s.section);
83
+ const dupes = anchors.filter((a, i) => anchors.indexOf(a) !== i);
84
+ expect(dupes, `${topic}.doc.${variant}.mjs overrides the same base section twice`).toEqual([]);
85
+ });
86
+ }
87
+ });
88
+
89
+ describe('the reported defect: docs tokens --dense (#2182)', () => {
90
+ it('does not print the colour table under the Spacing heading', async () => {
91
+ const result = await docs('tokens', null, {dense: true});
92
+ const spacing = result.data.sections.find(s => /spacing/i.test(s.title));
93
+ expect(spacing, 'tokens docs should have a spacing section').toBeTruthy();
94
+
95
+ const text = JSON.stringify(spacing);
96
+ expect(
97
+ text.includes('--color-'),
98
+ `The "${spacing.title}" section of \`docs tokens --dense\` contains ` +
99
+ `colour tokens. An agent reading this learns that spacing tokens are ` +
100
+ `named --color-*.`,
101
+ ).toBe(false);
102
+ expect(text).toContain('--spacing-');
103
+ });
104
+
105
+ it('keeps every base section reachable, even without an overlay entry', async () => {
106
+ // The tokens overlay compresses only 6 of 13 sections. The other 7 must
107
+ // still render (in English), not vanish or absorb a neighbour's title.
108
+ const full = await docs('tokens', null, {});
109
+ const dense = await docs('tokens', null, {dense: true});
110
+ expect(dense.data.sections.length).toBe(full.data.sections.length);
111
+ });
112
+
113
+ it('does not lose the Extending a Theme section from docs theme --dense', async () => {
114
+ const dense = await docs('theme', null, {dense: true});
115
+ const titles = dense.data.sections.map(s => s.title);
116
+ expect(titles).toContain('Extending a Theme');
117
+ });
118
+
119
+ it('does not emit a duplicate useTheme heading in docs theme --dense', async () => {
120
+ const dense = await docs('theme', null, {dense: true});
121
+ const titles = dense.data.sections.map(s => s.title.toLowerCase());
122
+ const useTheme = titles.filter(t => t.includes('usetheme'));
123
+ expect(useTheme.length).toBe(1);
124
+ });
125
+
126
+ it('does not leak English headings into docs theme --zh', async () => {
127
+ const zh = await docs('theme', null, {zh: true});
128
+ const titles = zh.data.sections.map(s => s.title);
129
+ // Every section the overlay translates must appear once, in Chinese only.
130
+ expect(titles).not.toContain('Light/Dark Mode');
131
+ expect(titles).toContain('亮/暗模式');
132
+ });
133
+ });
package/src/api/docs.mjs CHANGED
@@ -38,11 +38,23 @@ async function loadReferenceDocs(docPath, {lang} = {}) {
38
38
  const translation = translationMod.docsZh || translationMod.docsDense;
39
39
  if (!translation) return docs;
40
40
 
41
+ // Overlays are keyed to a base section by title (`section`), not by array
42
+ // position. Position-keying silently grafted each overlay title onto whatever
43
+ // base section happened to share its index, so an overlay that omitted or
44
+ // reordered a section corrupted every section after it — `docs tokens --dense`
45
+ // printed the colour table under a "Spacing" heading (#2182). An overlay may
46
+ // now cover any subset of sections, in any order; sections it does not name
47
+ // keep their base content.
48
+ const bySection = new Map();
49
+ for (const ts of translation.sections ?? []) {
50
+ if (ts?.section != null) bySection.set(ts.section, ts);
51
+ }
52
+
41
53
  return {
42
54
  ...docs,
43
55
  description: translation.description || docs.description,
44
- sections: docs.sections.map((section, si) => {
45
- const ts = translation.sections?.[si];
56
+ sections: docs.sections.map(section => {
57
+ const ts = bySection.get(section.title);
46
58
  if (!ts) return section;
47
59
  return {
48
60
  ...section,
@@ -0,0 +1,240 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Proves the minimal `exports` recipe an integration package needs so a
5
+ * bundler-resolution consumer can `import()` its block templates AND type-check
6
+ * them under `moduleResolution: bundler`.
7
+ *
8
+ * Integration packages in this ecosystem ship TypeScript SOURCE — their block
9
+ * templates are `.tsx` files with no compiled `.d.ts`. A later feature renders
10
+ * showcase previews by a REAL dynamic `import()` of a block's `.tsx` source
11
+ * (e.g. `import('@acme/widgets/templates/Gauge/GaugeShowcase.tsx')`) instead of
12
+ * eval'ing source text. For that import to resolve, the integration's
13
+ * `package.json#exports` map must GATE the deep path.
14
+ *
15
+ * These tests stand up a throwaway `@acme/widgets` fixture and drive the two
16
+ * gates that matter with the repo's own toolchain:
17
+ * 1. `tsc --noEmit` under `moduleResolution: bundler` (the consumer profile
18
+ * used by the Next.js example apps — NO `allowImportingTsExtensions`).
19
+ * 2. `esbuild --bundle` (a real bundler resolving + loading the deep import).
20
+ *
21
+ * CANONICAL RECIPE (see integration-authoring.md):
22
+ * exports: { "./templates/*.tsx": "./templates/*.tsx" }
23
+ * import: import('@acme/widgets/templates/<Name>/<Name>Showcase.tsx') // WITH .tsx
24
+ *
25
+ * The negative controls lock in WHY the extension is required: a bare
26
+ * `./templates/*` export with an extensionless import fails to type-check under
27
+ * bundler resolution (TS cannot infer the `.tsx` extension for a deep
28
+ * specifier), and an extensionless import fails to bundle.
29
+ */
30
+
31
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
32
+ import * as fs from 'node:fs';
33
+ import * as os from 'node:os';
34
+ import * as path from 'node:path';
35
+ import {createRequire} from 'node:module';
36
+ import {execFileSync} from 'node:child_process';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ /** Resolve the workspace tsc/esbuild binaries; null if unavailable. */
41
+ function resolveBin(spec) {
42
+ try {
43
+ return require.resolve(spec);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ const TSC_BIN = resolveBin('typescript/bin/tsc');
49
+ const ESBUILD_BIN = resolveBin('esbuild/bin/esbuild');
50
+
51
+ let tmpDir;
52
+
53
+ /**
54
+ * Build an @acme/widgets fixture: a block template (`.template.ts` doc +
55
+ * same-stem `.tsx` source) under `templates/`, plus an astryx.integration
56
+ * manifest, plus a caller-supplied `exports` map.
57
+ * @param {Record<string, string> | undefined} exportsMap
58
+ */
59
+ function makeWidgets(exportsMap) {
60
+ const pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
61
+ const blockDir = path.join(pkgDir, 'templates', 'Gauge');
62
+ fs.mkdirSync(blockDir, {recursive: true});
63
+
64
+ const pkg = {name: '@acme/widgets', version: '2.0.0', type: 'module'};
65
+ if (exportsMap) pkg.exports = exportsMap;
66
+ fs.writeFileSync(
67
+ path.join(pkgDir, 'package.json'),
68
+ JSON.stringify(pkg, null, 2),
69
+ );
70
+ fs.writeFileSync(
71
+ path.join(pkgDir, 'astryx.integration.mjs'),
72
+ `export default { templates: './templates' };\n`,
73
+ );
74
+ // The template-spec doc (canonical `.template.*` family).
75
+ fs.writeFileSync(
76
+ path.join(blockDir, 'GaugeShowcase.template.ts'),
77
+ `export default { name: 'Gauge showcase', description: 'A gauge.' };\n`,
78
+ );
79
+ // The same-stem `.tsx` SOURCE that a preview will dynamically import().
80
+ // Returns a plain value so the fixture type-checks without React types.
81
+ fs.writeFileSync(
82
+ path.join(blockDir, 'GaugeShowcase.tsx'),
83
+ `const GaugeShowcase = (): string => 'gauge-showcase';\n` +
84
+ `export default GaugeShowcase;\n`,
85
+ );
86
+ return pkgDir;
87
+ }
88
+
89
+ /** Write a consumer that dynamically imports the given specifier. */
90
+ function writeConsumer(specifier) {
91
+ fs.writeFileSync(
92
+ path.join(tmpDir, 'consumer.ts'),
93
+ `const load = () => import('${specifier}');\nexport default load;\n`,
94
+ );
95
+ }
96
+
97
+ /**
98
+ * The realistic consumer tsconfig: matches the repo's Next.js example apps
99
+ * (`moduleResolution: bundler`, `noEmit`, and deliberately NO
100
+ * `allowImportingTsExtensions`).
101
+ */
102
+ function writeTsconfig() {
103
+ fs.writeFileSync(
104
+ path.join(tmpDir, 'tsconfig.json'),
105
+ JSON.stringify(
106
+ {
107
+ compilerOptions: {
108
+ target: 'ES2022',
109
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
110
+ module: 'ESNext',
111
+ moduleResolution: 'bundler',
112
+ jsx: 'react-jsx',
113
+ strict: true,
114
+ skipLibCheck: true,
115
+ esModuleInterop: true,
116
+ noEmit: true,
117
+ isolatedModules: true,
118
+ },
119
+ include: ['consumer.ts'],
120
+ },
121
+ null,
122
+ 2,
123
+ ),
124
+ );
125
+ }
126
+
127
+ /** @returns {{ok: boolean, out: string}} */
128
+ function runTsc() {
129
+ try {
130
+ execFileSync(process.execPath, [TSC_BIN, '--noEmit', '-p', 'tsconfig.json'], {
131
+ cwd: tmpDir,
132
+ stdio: 'pipe',
133
+ });
134
+ return {ok: true, out: ''};
135
+ } catch (e) {
136
+ return {ok: false, out: `${e.stdout ?? ''}${e.stderr ?? ''}`};
137
+ }
138
+ }
139
+
140
+ /** @returns {{ok: boolean, out: string}} */
141
+ function runEsbuild() {
142
+ try {
143
+ // esbuild's bin is a native executable (not a node script), so invoke it
144
+ // directly rather than through process.execPath.
145
+ execFileSync(
146
+ ESBUILD_BIN,
147
+ [
148
+ 'consumer.ts',
149
+ '--bundle',
150
+ '--format=esm',
151
+ '--loader:.tsx=tsx',
152
+ '--outfile=/dev/null',
153
+ '--log-level=error',
154
+ ],
155
+ {cwd: tmpDir, stdio: 'pipe'},
156
+ );
157
+ return {ok: true, out: ''};
158
+ } catch (e) {
159
+ return {ok: false, out: `${e.stdout ?? ''}${e.stderr ?? ''}`};
160
+ }
161
+ }
162
+
163
+ beforeEach(() => {
164
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-block-exports-'));
165
+ fs.writeFileSync(
166
+ path.join(tmpDir, 'package.json'),
167
+ JSON.stringify({name: 'consumer', private: true, type: 'module'}),
168
+ );
169
+ writeTsconfig();
170
+ });
171
+
172
+ afterEach(() => {
173
+ fs.rmSync(tmpDir, {recursive: true, force: true});
174
+ });
175
+
176
+ const CANONICAL = 'import(@acme/widgets/templates/Gauge/GaugeShowcase.tsx)';
177
+ const SPEC = '@acme/widgets/templates/Gauge/GaugeShowcase.tsx';
178
+
179
+ describe('integration block-template exports recipe', () => {
180
+ it.runIf(TSC_BIN != null)(
181
+ `CANONICAL: exports {"./templates/*.tsx"} + ${CANONICAL} type-checks under bundler resolution`,
182
+ () => {
183
+ makeWidgets({'./templates/*.tsx': './templates/*.tsx'});
184
+ writeConsumer(SPEC);
185
+ const {ok, out} = runTsc();
186
+ expect(out).toBe('');
187
+ expect(ok).toBe(true);
188
+ },
189
+ 30_000,
190
+ );
191
+
192
+ it.runIf(ESBUILD_BIN != null)(
193
+ `CANONICAL: the same import resolves + bundles with a real bundler`,
194
+ () => {
195
+ makeWidgets({'./templates/*.tsx': './templates/*.tsx'});
196
+ writeConsumer(SPEC);
197
+ const {ok, out} = runEsbuild();
198
+ expect(out).toBe('');
199
+ expect(ok).toBe(true);
200
+ },
201
+ 30_000,
202
+ );
203
+
204
+ it.runIf(TSC_BIN != null)(
205
+ 'NEGATIVE: an extensionless import does NOT type-check (TS cannot infer .tsx)',
206
+ () => {
207
+ makeWidgets({'./templates/*.tsx': './templates/*.tsx'});
208
+ writeConsumer('@acme/widgets/templates/Gauge/GaugeShowcase');
209
+ const {ok, out} = runTsc();
210
+ expect(ok).toBe(false);
211
+ expect(out).toContain('TS2307');
212
+ },
213
+ 30_000,
214
+ );
215
+
216
+ it.runIf(TSC_BIN != null)(
217
+ 'NEGATIVE: a bare "./templates/*" export (no .tsx entry) does NOT type-check the .tsx import',
218
+ () => {
219
+ // Only a bare mapping — the extensionful subpath is not exported.
220
+ makeWidgets({'./templates/*': './templates/*'});
221
+ writeConsumer(SPEC);
222
+ const {ok, out} = runTsc();
223
+ expect(ok).toBe(false);
224
+ // Bare export forces the TS5097 opt-in requirement (allowImportingTsExtensions).
225
+ expect(out).toContain('TS5097');
226
+ },
227
+ 30_000,
228
+ );
229
+
230
+ it.runIf(ESBUILD_BIN != null)(
231
+ 'NEGATIVE: an extensionless import does NOT bundle against the .tsx-only export',
232
+ () => {
233
+ makeWidgets({'./templates/*.tsx': './templates/*.tsx'});
234
+ writeConsumer('@acme/widgets/templates/Gauge/GaugeShowcase');
235
+ const {ok} = runEsbuild();
236
+ expect(ok).toBe(false);
237
+ },
238
+ 30_000,
239
+ );
240
+ });