@awarebydefault/display-case 1.0.0

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/display-case.prompt.md +64 -0
  4. package/docs/ai-agents.md +126 -0
  5. package/docs/cli.md +99 -0
  6. package/docs/configuration.md +410 -0
  7. package/docs/documentation-panel.md +50 -0
  8. package/docs/examples/README.md +14 -0
  9. package/docs/examples/multi-variant.case.tsx +30 -0
  10. package/docs/examples/plain.case.tsx +22 -0
  11. package/docs/examples/tweak-control.placard.md +80 -0
  12. package/docs/examples/tweaks.case.tsx +39 -0
  13. package/docs/hierarchy.md +59 -0
  14. package/docs/quick-start.md +78 -0
  15. package/docs/style-engines.md +180 -0
  16. package/docs/testing.md +245 -0
  17. package/docs/theming.md +97 -0
  18. package/docs/tweaks.md +75 -0
  19. package/docs/writing-cases.md +144 -0
  20. package/docs/writing-placard-docs.md +194 -0
  21. package/package.json +113 -0
  22. package/skills/display-case-author-case/README.md +20 -0
  23. package/skills/display-case-author-case/SKILL.md +40 -0
  24. package/skills/display-case-author-placard-doc/README.md +24 -0
  25. package/skills/display-case-author-placard-doc/SKILL.md +65 -0
  26. package/skills/display-case-review/README.md +19 -0
  27. package/skills/display-case-review/SKILL.md +30 -0
  28. package/skills/display-case-snapshot/README.md +20 -0
  29. package/skills/display-case-snapshot/SKILL.md +29 -0
  30. package/src/checks/a11y-scanner.test.ts +240 -0
  31. package/src/checks/a11y-scanner.ts +410 -0
  32. package/src/checks/check-text.test.ts +53 -0
  33. package/src/checks/check-text.ts +78 -0
  34. package/src/checks/check.test.ts +194 -0
  35. package/src/checks/check.ts +473 -0
  36. package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
  37. package/src/checks/providers/pixelmatch-diff.ts +30 -0
  38. package/src/checks/providers/playwright-driver.ts +104 -0
  39. package/src/checks/ssr-check.test.ts +73 -0
  40. package/src/checks/ssr-check.ts +96 -0
  41. package/src/checks/structure-check.cross-package.test.ts +165 -0
  42. package/src/checks/structure-check.test.ts +651 -0
  43. package/src/checks/structure-check.ts +988 -0
  44. package/src/checks/tokens-check.test.ts +159 -0
  45. package/src/checks/tokens-check.ts +162 -0
  46. package/src/cli.ts +218 -0
  47. package/src/commands/agents.test.ts +24 -0
  48. package/src/commands/agents.ts +28 -0
  49. package/src/commands/init-run.test.ts +123 -0
  50. package/src/commands/init.test.ts +63 -0
  51. package/src/commands/init.ts +412 -0
  52. package/src/commands/publish.test.ts +210 -0
  53. package/src/commands/publish.ts +292 -0
  54. package/src/core/affected.test.ts +99 -0
  55. package/src/core/affected.ts +144 -0
  56. package/src/core/catalog.test.ts +152 -0
  57. package/src/core/catalog.ts +92 -0
  58. package/src/core/discovery.test.ts +184 -0
  59. package/src/core/discovery.ts +250 -0
  60. package/src/core/manifest.ts +41 -0
  61. package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
  62. package/src/core/mdx-lite/index.ts +393 -0
  63. package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
  64. package/src/core/mdx-plugin.test.ts +60 -0
  65. package/src/core/mdx-plugin.ts +30 -0
  66. package/src/flow-step.test-d.ts +39 -0
  67. package/src/index.test.ts +100 -0
  68. package/src/index.ts +564 -0
  69. package/src/render/collect-styles.emotion.test.tsx +114 -0
  70. package/src/render/collect-styles.test.tsx +72 -0
  71. package/src/render/collect-styles.ts +33 -0
  72. package/src/render/documents.test.ts +184 -0
  73. package/src/render/documents.ts +88 -0
  74. package/src/render/render-node.test.tsx +160 -0
  75. package/src/render/render-node.tsx +133 -0
  76. package/src/render/ssr-primer.test.tsx +25 -0
  77. package/src/render/ssr-primer.tsx +54 -0
  78. package/src/render/ssr-render.test.tsx +142 -0
  79. package/src/render/ssr-render.tsx +63 -0
  80. package/src/render/ssr-shell.test.tsx +57 -0
  81. package/src/render/ssr-shell.tsx +54 -0
  82. package/src/server/prod-server.ts +237 -0
  83. package/src/server/server.test.ts +117 -0
  84. package/src/server/server.ts +1039 -0
  85. package/src/style-engine.test-d.ts +37 -0
  86. package/src/testing/test-helpers.ts +27 -0
  87. package/src/types/pixelmatch.d.ts +12 -0
  88. package/src/ui/browser-entry.tsx +51 -0
  89. package/src/ui/chrome.css +485 -0
  90. package/src/ui/design-system/README.md +88 -0
  91. package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
  92. package/src/ui/design-system/components/controls/Button.css +89 -0
  93. package/src/ui/design-system/components/controls/Button.placard.md +14 -0
  94. package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
  95. package/src/ui/design-system/components/controls/Button.tsx +41 -0
  96. package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
  97. package/src/ui/design-system/components/controls/IconButton.css +67 -0
  98. package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
  99. package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
  100. package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
  101. package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
  102. package/src/ui/design-system/components/controls/Input.css +52 -0
  103. package/src/ui/design-system/components/controls/Input.placard.md +12 -0
  104. package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
  105. package/src/ui/design-system/components/controls/Input.tsx +45 -0
  106. package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
  107. package/src/ui/design-system/components/controls/Select.css +44 -0
  108. package/src/ui/design-system/components/controls/Select.placard.md +15 -0
  109. package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
  110. package/src/ui/design-system/components/controls/Select.tsx +58 -0
  111. package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
  112. package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
  113. package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
  114. package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
  115. package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
  116. package/src/ui/design-system/components/index.ts +66 -0
  117. package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
  118. package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
  119. package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
  120. package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
  121. package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
  122. package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
  123. package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
  124. package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
  125. package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
  126. package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
  127. package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
  128. package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
  129. package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
  130. package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
  131. package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
  132. package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
  133. package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
  134. package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
  135. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
  136. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
  137. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
  138. package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
  139. package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
  140. package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
  141. package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
  142. package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
  143. package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
  144. package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
  145. package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
  146. package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
  147. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
  148. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
  149. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
  150. package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
  151. package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
  152. package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
  153. package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
  154. package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
  155. package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
  156. package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
  157. package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
  158. package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
  159. package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
  160. package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
  161. package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
  162. package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
  163. package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
  164. package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
  165. package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
  166. package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
  167. package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
  168. package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
  169. package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
  170. package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
  171. package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
  172. package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
  173. package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
  174. package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
  175. package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
  176. package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
  177. package/src/ui/design-system/components/showcase/Chip.css +51 -0
  178. package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
  179. package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
  180. package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
  181. package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
  182. package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
  183. package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
  184. package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
  185. package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
  186. package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
  187. package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
  188. package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
  189. package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
  190. package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
  191. package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
  192. package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
  193. package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
  194. package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
  195. package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
  196. package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
  197. package/src/ui/design-system/components/showcase/NavItem.css +111 -0
  198. package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
  199. package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
  200. package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
  201. package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
  202. package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
  203. package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
  204. package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
  205. package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
  206. package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
  207. package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
  208. package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
  209. package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
  210. package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
  211. package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
  212. package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
  213. package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
  214. package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
  215. package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
  216. package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
  217. package/src/ui/design-system/components/showcase/Stage.css +91 -0
  218. package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
  219. package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
  220. package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
  221. package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
  222. package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
  223. package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
  224. package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
  225. package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
  226. package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
  227. package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
  228. package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
  229. package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
  230. package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
  231. package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
  232. package/src/ui/design-system/primer-specimens/components.tsx +308 -0
  233. package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
  234. package/src/ui/design-system/primer-specimens/index.ts +25 -0
  235. package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
  236. package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
  237. package/src/ui/design-system/primer-specimens/type.tsx +75 -0
  238. package/src/ui/design-system/primer.mdx +236 -0
  239. package/src/ui/design-system/styles.css +14 -0
  240. package/src/ui/design-system/tokens/colors.css +172 -0
  241. package/src/ui/design-system/tokens/fonts.css +18 -0
  242. package/src/ui/design-system/tokens/spacing.css +48 -0
  243. package/src/ui/design-system/tokens/typography.css +49 -0
  244. package/src/ui/markdown.test.tsx +54 -0
  245. package/src/ui/markdown.tsx +19 -0
  246. package/src/ui/primer-mount.tsx +76 -0
  247. package/src/ui/primer.css +175 -0
  248. package/src/ui/primer.tsx +277 -0
  249. package/src/ui/render-mount.tsx +284 -0
  250. package/src/ui/shell-core.test.ts +340 -0
  251. package/src/ui/shell-core.ts +295 -0
  252. package/src/ui/shell.tsx +60 -0
  253. package/src/ui/test-ids.ts +53 -0
  254. package/src/ui/use-shell.ts +1230 -0
@@ -0,0 +1,210 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import type { Manifest } from '../core/manifest'
5
+ import { startProdServer } from '../server/prod-server'
6
+ import { makeTempDir } from '../testing/test-helpers'
7
+ import { type BuildDescriptor, publish } from './publish'
8
+
9
+ /**
10
+ * A build dir anchored INSIDE the repo. The published server/static SSR bundle
11
+ * keeps `react`/`react-dom` external (resolved at runtime), so when we import or
12
+ * serve it in-process the build must sit where module resolution walks up to the
13
+ * repo's `node_modules` — a `/tmp` dir has none. (A real deploy installs
14
+ * `node_modules` alongside the build; this reproduces that.) Gitignored via
15
+ * `.tmp/`.
16
+ */
17
+ const REPO = resolve(import.meta.dir, '..', '..')
18
+ async function makeRepoTempDir(): Promise<string> {
19
+ const base = join(REPO, '.tmp')
20
+ await mkdir(base, { recursive: true })
21
+ return mkdtemp(join(base, 'publish-'))
22
+ }
23
+
24
+ /**
25
+ * Coverage for the publish / deploy path — previously untested. Two levels,
26
+ * both Docker-free:
27
+ * 1. the published *artifacts* (bundle, frozen manifest, generated
28
+ * server/Dockerfile, no dev machinery), and
29
+ * 2. the *served* build booted in-process via `startProdServer`, plus the
30
+ * `--static` export.
31
+ * The containerized deploy path (a real `docker build`) is Docker-gated in
32
+ * `test/publish-container.test.ts`.
33
+ *
34
+ * Published against a minimal fixture for speed and determinism.
35
+ */
36
+ const FIXTURE = resolve(
37
+ import.meta.dir,
38
+ '..',
39
+ '..',
40
+ 'e2e/fixtures/consumer-plain',
41
+ )
42
+
43
+ describe('publish: artifacts', () => {
44
+ let out: string
45
+ let descriptor: BuildDescriptor
46
+
47
+ beforeAll(async () => {
48
+ out = await makeTempDir()
49
+ ;({ descriptor } = await publish(FIXTURE, { out }))
50
+ })
51
+ afterAll(() => rm(out, { recursive: true, force: true }))
52
+
53
+ test('emits content-hashed browser + render bundles', async () => {
54
+ const assets = await readdir(join(out, 'assets'))
55
+ expect(assets.some((f) => /^browser-entry-.+\.js$/.test(f))).toBe(true)
56
+ expect(assets.some((f) => /^render-entry-.+\.js$/.test(f))).toBe(true)
57
+ })
58
+
59
+ test('descriptor: production, a11y disabled, base-prefixed assets', () => {
60
+ expect(descriptor.title).toBe('Plain Consumer')
61
+ expect(descriptor.a11y).toBe(false)
62
+ expect(descriptor.base).toBe('')
63
+ expect(descriptor.assets.browser).toMatch(
64
+ /^\/assets\/browser-entry-.+\.js$/,
65
+ )
66
+ expect(descriptor.assets.render).toMatch(/^\/assets\/render-entry-.+\.js$/)
67
+ })
68
+
69
+ test('frozen manifest + build descriptor written to disk', async () => {
70
+ const manifest = (await Bun.file(
71
+ join(out, 'manifest.json'),
72
+ ).json()) as Manifest
73
+ expect(Array.isArray(manifest.components)).toBe(true)
74
+ expect(manifest.components.length).toBeGreaterThan(0)
75
+ const dc = (await Bun.file(
76
+ join(out, 'dc-build.json'),
77
+ ).json()) as BuildDescriptor
78
+ expect(dc.a11y).toBe(false)
79
+ })
80
+
81
+ test('SSR renderers are built for the production server', async () => {
82
+ expect(await Bun.file(join(out, 'server', 'ssr-entry.js')).exists()).toBe(
83
+ true,
84
+ )
85
+ })
86
+
87
+ test('generated server.ts uses the prod-server, carries no dev machinery', async () => {
88
+ const s = await Bun.file(join(out, 'server.ts')).text()
89
+ expect(s).toContain("from 'display-case/prod-server'")
90
+ expect(s).toContain('AUTO-GENERATED')
91
+ expect(s).not.toContain('__livereload')
92
+ })
93
+
94
+ test('generated package.json is a deployable service descriptor', async () => {
95
+ const pkg = (await Bun.file(join(out, 'package.json')).json()) as {
96
+ scripts: Record<string, string>
97
+ dependencies: Record<string, string>
98
+ }
99
+ expect(pkg.scripts.start).toBe('bun server.ts')
100
+ expect(pkg.dependencies['display-case']).toBeDefined()
101
+ expect(pkg.dependencies.react).toBeDefined()
102
+ expect(pkg.dependencies['react-dom']).toBeDefined()
103
+ })
104
+
105
+ test('generated Dockerfile builds a Bun service with a health check', async () => {
106
+ const df = await Bun.file(join(out, 'Dockerfile')).text()
107
+ expect(df).toContain('FROM oven/bun')
108
+ expect(df).toContain('bun install --production')
109
+ expect(df).toContain('/health')
110
+ expect(df).toContain('CMD ["bun", "server.ts"]')
111
+ })
112
+
113
+ test('--base prefixes the hashed asset URLs for subpath hosting', async () => {
114
+ const o2 = await makeTempDir()
115
+ try {
116
+ const { descriptor: d } = await publish(FIXTURE, {
117
+ out: o2,
118
+ base: '/showcase',
119
+ })
120
+ expect(d.base).toBe('/showcase')
121
+ expect(d.assets.browser).toMatch(/^\/showcase\/assets\//)
122
+ } finally {
123
+ await rm(o2, { recursive: true, force: true })
124
+ }
125
+ })
126
+ })
127
+
128
+ describe('publish: the served build is a functional showcase', () => {
129
+ let out: string
130
+ let descriptor: BuildDescriptor
131
+ let manifest: Manifest
132
+ let server: Awaited<ReturnType<typeof startProdServer>>
133
+ let baseUrl: string
134
+
135
+ beforeAll(async () => {
136
+ out = await makeRepoTempDir()
137
+ ;({ descriptor } = await publish(FIXTURE, { out }))
138
+ manifest = (await Bun.file(join(out, 'manifest.json')).json()) as Manifest
139
+ server = await startProdServer(out, { port: 0 })
140
+ baseUrl = `http://localhost:${server.port}`
141
+ })
142
+ afterAll(async () => {
143
+ await server?.stop(true)
144
+ await rm(out, { recursive: true, force: true })
145
+ })
146
+
147
+ test('GET /health returns ok', async () => {
148
+ const r = await fetch(`${baseUrl}/health`)
149
+ expect(r.status).toBe(200)
150
+ expect(await r.text()).toBe('ok')
151
+ })
152
+
153
+ test('the shell is server-rendered, links hashed assets, and has no dev live-reload', async () => {
154
+ const r = await fetch(`${baseUrl}/`)
155
+ expect(r.status).toBe(200)
156
+ const html = await r.text()
157
+ expect(html).toContain('Plain Consumer') // title server-rendered
158
+ expect(html).toContain('data-ssr="1"') // content present before scripts
159
+ expect(html).toContain(descriptor.assets.browser) // hashed bundle linked
160
+ expect(html).not.toContain('__livereload') // dev SSE stripped
161
+ expect(html).not.toContain('EventSource')
162
+ expect(r.headers.get('cache-control')).toContain('no-cache')
163
+ })
164
+
165
+ test('the isolated /render endpoint serves a chrome-free, pre-scripting document', async () => {
166
+ const c = manifest.components[0]
167
+ const cs = c.cases[0]
168
+ const r = await fetch(`${baseUrl}/render/${c.id}/${cs.id}`)
169
+ expect(r.status).toBe(200)
170
+ const html = await r.text()
171
+ expect(html).toContain('data-ssr="1"')
172
+ expect(html).toContain(descriptor.assets.render)
173
+ // Chrome-free: the shell's title chrome is absent from the isolated doc.
174
+ expect(html).not.toContain('Plain Consumer')
175
+ })
176
+
177
+ test('hashed assets are served with immutable caching', async () => {
178
+ const r = await fetch(`${baseUrl}${descriptor.assets.browser}`)
179
+ expect(r.status).toBe(200)
180
+ expect(r.headers.get('cache-control')).toContain('immutable')
181
+ })
182
+ })
183
+
184
+ describe('publish: the static export needs no running server', () => {
185
+ let out: string
186
+ let descriptor: BuildDescriptor
187
+ let manifest: Manifest
188
+
189
+ beforeAll(async () => {
190
+ out = await makeRepoTempDir()
191
+ ;({ descriptor } = await publish(FIXTURE, { out, static: true }))
192
+ manifest = (await Bun.file(join(out, 'manifest.json')).json()) as Manifest
193
+ })
194
+ afterAll(() => rm(out, { recursive: true, force: true }))
195
+
196
+ test('writes a complete index.html with the shell pre-rendered', async () => {
197
+ const html = await Bun.file(join(out, 'index.html')).text()
198
+ expect(html).toContain('<!doctype html>')
199
+ expect(html).toContain('Plain Consumer')
200
+ expect(html).toContain(descriptor.assets.browser)
201
+ })
202
+
203
+ test('writes a complete per-case render document', async () => {
204
+ const c = manifest.components[0]
205
+ const cs = c.cases[0]
206
+ const file = join(out, 'render', c.id, cs.id, 'index.html')
207
+ expect(await Bun.file(file).exists()).toBe(true)
208
+ expect(await Bun.file(file).text()).toContain('data-ssr="1"')
209
+ })
210
+ })
@@ -0,0 +1,292 @@
1
+ import { mkdir, rm } from 'node:fs/promises'
2
+ import { basename, join, resolve } from 'node:path'
3
+ import {
4
+ cacheDir,
5
+ codegenPrimerEntry,
6
+ codegenRenderEntry,
7
+ codegenSsrEntry,
8
+ codegenSsrPrimerEntry,
9
+ discoverCaseFiles,
10
+ resolveConfig,
11
+ } from '../core/discovery'
12
+ import { mdxPlugin } from '../core/mdx-plugin'
13
+ import type { DisplayCaseConfig } from '../index'
14
+ import { getManifest } from '../server/server'
15
+
16
+ /**
17
+ * Build a self-contained, hostable showcase: production-bundled assets
18
+ * (minified, content-hashed, no dev injects) plus a frozen manifest, styles, and
19
+ * the SSR renderers, written to an output directory that a thin production server
20
+ * (`prod-server.ts`) serves — or, with `static: true`, a crawl writes complete
21
+ * HTML files that need no running server. None of the dev machinery (watcher,
22
+ * live-reload, on-demand a11y, dev endpoints) is carried into the build.
23
+ */
24
+
25
+ const HERE = resolve(import.meta.dir, '..')
26
+ const BROWSER_ENTRY = join(HERE, 'ui', 'browser-entry.tsx')
27
+ const CHROME_CSS = join(HERE, 'ui', 'chrome.css')
28
+ const DS_DIR = join(HERE, 'ui', 'design-system', 'tokens')
29
+ const DS_TOKEN_FILES = ['colors.css', 'typography.css', 'spacing.css']
30
+ const COMPONENTS_DIR = join(HERE, 'ui', 'design-system', 'components')
31
+ const PRIMER_CSS = join(HERE, 'ui', 'primer.css')
32
+
33
+ export interface PublishOptions {
34
+ /** Output directory (default `<pkgDir>/dist-showcase`). */
35
+ out?: string
36
+ /** Base path for hosting under a subpath (e.g. `/showcase`). */
37
+ base?: string
38
+ /** Also write a fully-static (server-less) export. */
39
+ static?: boolean
40
+ }
41
+
42
+ /** Public env (`BUN_PUBLIC_*`) inlined into the browser bundle, plus a forced
43
+ * production NODE_ENV so React builds in production mode. */
44
+ async function buildDefines(pkgDir: string): Promise<Record<string, string>> {
45
+ const defines: Record<string, string> = {
46
+ 'process.env.NODE_ENV': '"production"',
47
+ }
48
+ for (const name of ['.env', '.env.local']) {
49
+ const file = Bun.file(join(pkgDir, name))
50
+ if (!(await file.exists())) continue
51
+ for (const raw of (await file.text()).split('\n')) {
52
+ const line = raw.trim()
53
+ if (!line || line.startsWith('#')) continue
54
+ const eq = line.indexOf('=')
55
+ if (eq === -1) continue
56
+ const key = line
57
+ .slice(0, eq)
58
+ .replace(/^export\s+/, '')
59
+ .trim()
60
+ if (!key.startsWith('BUN_PUBLIC_')) continue
61
+ let value = line.slice(eq + 1).trim()
62
+ if (
63
+ (value.startsWith('"') && value.endsWith('"')) ||
64
+ (value.startsWith("'") && value.endsWith("'"))
65
+ ) {
66
+ value = value.slice(1, -1)
67
+ }
68
+ defines[`process.env.${key}`] = JSON.stringify(value)
69
+ }
70
+ }
71
+ for (const [key, value] of Object.entries(process.env)) {
72
+ if (key.startsWith('BUN_PUBLIC_') && value !== undefined) {
73
+ defines[`process.env.${key}`] = JSON.stringify(value)
74
+ }
75
+ }
76
+ return defines
77
+ }
78
+
79
+ async function readDesignTokens(): Promise<string> {
80
+ const parts = await Promise.all(
81
+ DS_TOKEN_FILES.map((f) => Bun.file(join(DS_DIR, f)).text()),
82
+ )
83
+ return parts.join('\n')
84
+ }
85
+
86
+ // The Vitrine's own chrome stylesheet — chrome.css + every design-system
87
+ // component's co-located CSS + the primer chrome's CSS, read and concatenated in
88
+ // path-sorted order. Mirrors server.ts; the published documents inline it so the
89
+ // chrome paints before scripts (the components no longer inject at runtime).
90
+ async function readVitrineCss(): Promise<string> {
91
+ const componentFiles: string[] = []
92
+ for await (const f of new Bun.Glob('**/*.css').scan({
93
+ cwd: COMPONENTS_DIR,
94
+ absolute: true,
95
+ })) {
96
+ componentFiles.push(f)
97
+ }
98
+ componentFiles.sort()
99
+ const parts = await Promise.all(
100
+ [CHROME_CSS, ...componentFiles, PRIMER_CSS].map((f) => Bun.file(f).text()),
101
+ )
102
+ return parts.join('\n')
103
+ }
104
+
105
+ async function readGlobalCss(
106
+ pkgDir: string,
107
+ config: DisplayCaseConfig,
108
+ ): Promise<string> {
109
+ const parts: string[] = []
110
+ for (const rel of config.globalStyles ?? []) {
111
+ const abs = resolve(pkgDir, rel)
112
+ if (await Bun.file(abs).exists()) parts.push(await Bun.file(abs).text())
113
+ }
114
+ return parts.join('\n')
115
+ }
116
+
117
+ /** Map a built entry-point's output path back to its logical name. */
118
+ function entryName(path: string): 'browser' | 'render' | 'primer' | null {
119
+ const b = basename(path)
120
+ if (b.startsWith('browser-entry')) return 'browser'
121
+ if (b.startsWith('render-entry')) return 'render'
122
+ if (b.startsWith('primer-entry')) return 'primer'
123
+ return null
124
+ }
125
+
126
+ export interface BuildDescriptor {
127
+ title: string
128
+ base: string
129
+ a11y: false
130
+ hasPrimer: boolean
131
+ /** Content-hashed, base-prefixed entry URLs. */
132
+ assets: { browser: string; render: string; primer: string }
133
+ tokensCss: string
134
+ globalCss: string
135
+ vitrineCss: string
136
+ }
137
+
138
+ export async function publish(
139
+ pkgDir: string,
140
+ opts: PublishOptions = {},
141
+ ): Promise<{ out: string; descriptor: BuildDescriptor }> {
142
+ const out = resolve(opts.out ?? join(pkgDir, 'dist-showcase'))
143
+ const base = (opts.base ?? '').replace(/\/+$/, '')
144
+ const { config, configPath } = await resolveConfig(pkgDir)
145
+
146
+ await rm(out, { recursive: true, force: true })
147
+ await mkdir(join(out, 'assets'), { recursive: true })
148
+ await mkdir(join(out, 'server'), { recursive: true })
149
+
150
+ const files = await discoverCaseFiles(pkgDir, config)
151
+ const primerConfigured = !!config.primer
152
+ const primerSrc = primerConfigured
153
+ ? resolve(pkgDir, config.primer as string)
154
+ : null
155
+ const hasPrimer = !!primerSrc && (await Bun.file(primerSrc).exists())
156
+
157
+ // Codegen the same entries the dev server uses.
158
+ const renderEntry = await codegenRenderEntry(pkgDir, files, configPath)
159
+ const primerEntry = hasPrimer
160
+ ? await codegenPrimerEntry(pkgDir, config.primer as string)
161
+ : null
162
+ const ssrEntry = await codegenSsrEntry(pkgDir, files, configPath)
163
+ const ssrPrimerEntry = hasPrimer
164
+ ? await codegenSsrPrimerEntry(pkgDir, config.primer as string, configPath)
165
+ : null
166
+
167
+ const defines = await buildDefines(pkgDir)
168
+
169
+ // Browser bundle: minified, content-hashed, production React.
170
+ const browserEntries = [BROWSER_ENTRY, renderEntry]
171
+ if (primerEntry) browserEntries.push(primerEntry)
172
+ const browser = await Bun.build({
173
+ entrypoints: browserEntries,
174
+ outdir: join(out, 'assets'),
175
+ target: 'browser',
176
+ minify: true,
177
+ sourcemap: 'none',
178
+ plugins: [mdxPlugin()],
179
+ define: defines,
180
+ naming: {
181
+ entry: '[name]-[hash].[ext]',
182
+ chunk: '[name]-[hash].[ext]',
183
+ asset: '[name]-[hash].[ext]',
184
+ },
185
+ })
186
+ if (!browser.success) {
187
+ for (const log of browser.logs) console.error(log)
188
+ throw new Error('Display Case publish: browser bundle failed')
189
+ }
190
+
191
+ const assets = { browser: '', render: '', primer: '' }
192
+ for (const o of browser.outputs) {
193
+ if (o.kind !== 'entry-point') continue
194
+ const name = entryName(o.path)
195
+ if (name) assets[name] = `${base}/assets/${basename(o.path)}`
196
+ }
197
+
198
+ // SSR renderers for the production server: built once (no watching), imported
199
+ // by `prod-server`. React stays external (resolved at runtime).
200
+ const ssrEntries = [ssrEntry]
201
+ if (ssrPrimerEntry) ssrEntries.push(ssrPrimerEntry)
202
+ const ssr = await Bun.build({
203
+ entrypoints: ssrEntries,
204
+ outdir: join(out, 'server'),
205
+ target: 'bun',
206
+ plugins: [mdxPlugin()],
207
+ define: defines,
208
+ external: [
209
+ 'react',
210
+ 'react-dom',
211
+ 'react-dom/server',
212
+ 'react/jsx-runtime',
213
+ 'react/jsx-dev-runtime',
214
+ ],
215
+ naming: { entry: '[name].[ext]', chunk: '[name]-[hash].[ext]' },
216
+ })
217
+ if (!ssr.success) {
218
+ for (const log of ssr.logs) console.error(log)
219
+ throw new Error('Display Case publish: SSR bundle failed')
220
+ }
221
+
222
+ const manifest = await getManifest(pkgDir)
223
+ const descriptor: BuildDescriptor = {
224
+ title: config.title,
225
+ base,
226
+ a11y: false,
227
+ hasPrimer,
228
+ assets,
229
+ tokensCss: await readDesignTokens(),
230
+ globalCss: await readGlobalCss(pkgDir, config),
231
+ vitrineCss: await readVitrineCss(),
232
+ }
233
+
234
+ await Bun.write(
235
+ join(out, 'manifest.json'),
236
+ `${JSON.stringify(manifest, null, 2)}\n`,
237
+ )
238
+ await Bun.write(
239
+ join(out, 'dc-build.json'),
240
+ `${JSON.stringify(descriptor, null, 2)}\n`,
241
+ )
242
+
243
+ // A standalone start script + run recipe, so the build deploys like any service.
244
+ await Bun.write(
245
+ join(out, 'server.ts'),
246
+ `// AUTO-GENERATED by display-case publish — do not edit.\n` +
247
+ `import { startProdServer } from 'display-case/prod-server'\n` +
248
+ `await startProdServer(import.meta.dir, { port: Number(process.env.PORT) || 3000 })\n`,
249
+ )
250
+ await Bun.write(
251
+ join(out, 'package.json'),
252
+ `${JSON.stringify(
253
+ {
254
+ name: 'display-case-showcase',
255
+ private: true,
256
+ type: 'module',
257
+ scripts: { start: 'bun server.ts' },
258
+ dependencies: {
259
+ 'display-case': 'latest',
260
+ react: '^19',
261
+ 'react-dom': '^19',
262
+ },
263
+ },
264
+ null,
265
+ 2,
266
+ )}\n`,
267
+ )
268
+ await Bun.write(
269
+ join(out, 'Dockerfile'),
270
+ [
271
+ 'FROM oven/bun:1 AS base',
272
+ 'WORKDIR /app',
273
+ 'COPY . .',
274
+ 'RUN bun install --production',
275
+ 'ENV PORT=3000',
276
+ 'EXPOSE 3000',
277
+ `HEALTHCHECK CMD bun -e "await fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"`,
278
+ 'CMD ["bun", "server.ts"]',
279
+ '',
280
+ ].join('\n'),
281
+ )
282
+
283
+ // Drop the gitignored codegen leftovers out of the published tree's cache.
284
+ void cacheDir
285
+
286
+ if (opts.static) {
287
+ const { writeStaticExport } = await import('../server/prod-server')
288
+ await writeStaticExport(out)
289
+ }
290
+
291
+ return { out, descriptor }
292
+ }
@@ -0,0 +1,99 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
5
+ import { affectedComponents, importClosure } from './affected'
6
+
7
+ describe('importClosure', () => {
8
+ let dir: string
9
+ beforeAll(async () => {
10
+ dir = await makeTempDir()
11
+ await writeFiles(dir, {
12
+ // Button: case → component → styles.css; styles.css → tokens.css.
13
+ 'Button.case.tsx':
14
+ "import { Button } from './Button'\nexport default Button",
15
+ 'Button.tsx': "import './styles.css'\nimport { tk } from './util'",
16
+ 'styles.css': "@import './tokens.css';\n.b { color: red }",
17
+ 'tokens.css': ':root { --x: 1 }',
18
+ 'util.ts': "import React from 'react'\nexport const tk = 1",
19
+ // Unrelated island the Button does not import.
20
+ 'Other.tsx': 'export const Other = 1',
21
+ })
22
+ })
23
+ afterAll(async () => {
24
+ await rm(dir, { recursive: true, force: true })
25
+ })
26
+
27
+ test('follows relative JS and CSS imports transitively', async () => {
28
+ const closure = await importClosure([join(dir, 'Button.case.tsx')])
29
+ expect(closure.has(resolve(dir, 'Button.case.tsx'))).toBe(true)
30
+ expect(closure.has(resolve(dir, 'Button.tsx'))).toBe(true)
31
+ expect(closure.has(resolve(dir, 'styles.css'))).toBe(true)
32
+ expect(closure.has(resolve(dir, 'tokens.css'))).toBe(true)
33
+ expect(closure.has(resolve(dir, 'util.ts'))).toBe(true)
34
+ })
35
+
36
+ test('does not pull in unrelated files or bare specifiers', async () => {
37
+ const closure = await importClosure([join(dir, 'Button.case.tsx')])
38
+ expect(closure.has(resolve(dir, 'Other.tsx'))).toBe(false)
39
+ // `react` is a bare specifier — not traced, so nothing resolves for it.
40
+ for (const f of closure) expect(f.includes('node_modules')).toBe(false)
41
+ })
42
+
43
+ test('a missing entry contributes only itself', async () => {
44
+ const ghost = join(dir, 'Nope.case.tsx')
45
+ const closure = await importClosure([ghost])
46
+ expect([...closure]).toEqual([resolve(ghost)])
47
+ })
48
+ })
49
+
50
+ describe('affectedComponents', () => {
51
+ let dir: string
52
+ let components: { id: string; caseFile: string }[]
53
+ beforeAll(async () => {
54
+ dir = await makeTempDir()
55
+ await writeFiles(dir, {
56
+ 'Button.case.tsx': "import './Button'",
57
+ 'Button.tsx': "import './button.css'",
58
+ 'button.css': '.b {}',
59
+ 'Card.case.tsx': "import './Card'",
60
+ 'Card.tsx': "import { tk } from './shared'",
61
+ 'shared.ts': 'export const tk = 1',
62
+ })
63
+ components = [
64
+ { id: 'button', caseFile: resolve(dir, 'Button.case.tsx') },
65
+ { id: 'card', caseFile: resolve(dir, 'Card.case.tsx') },
66
+ ]
67
+ })
68
+ afterAll(async () => {
69
+ await rm(dir, { recursive: true, force: true })
70
+ })
71
+
72
+ test('a changed leaf affects only the component that imports it', async () => {
73
+ const affected = await affectedComponents(components, [
74
+ join(dir, 'button.css'),
75
+ ])
76
+ expect([...affected]).toEqual(['button'])
77
+ })
78
+
79
+ test('a shared dependency affects every importer', async () => {
80
+ const affected = await affectedComponents(components, [
81
+ join(dir, 'shared.ts'),
82
+ ])
83
+ expect([...affected]).toEqual(['card'])
84
+ })
85
+
86
+ test('a changed case file affects its own component', async () => {
87
+ const affected = await affectedComponents(components, [
88
+ join(dir, 'Card.case.tsx'),
89
+ ])
90
+ expect([...affected]).toEqual(['card'])
91
+ })
92
+
93
+ test('an unrelated change affects nothing', async () => {
94
+ const affected = await affectedComponents(components, [
95
+ join(dir, 'README.md'),
96
+ ])
97
+ expect(affected.size).toBe(0)
98
+ })
99
+ })