@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,25 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { DisplayCaseConfig } from '../index'
3
+ import { makePrimerRenderer } from './ssr-primer'
4
+
5
+ const config: DisplayCaseConfig = { title: 'T', roots: [] }
6
+
7
+ describe('makePrimerRenderer', () => {
8
+ test('renders the MDX content to markup', () => {
9
+ const render = makePrimerRenderer(() => <h1>Primer Heading</h1>, config)
10
+ const result = render()
11
+ expect(result.browserOnly).toBe(false)
12
+ expect(result.html).toContain('Primer Heading')
13
+ expect(result.error).toBeUndefined()
14
+ })
15
+
16
+ test('falls the whole primer back to the client when a specimen needs a browser', () => {
17
+ const render = makePrimerRenderer(() => {
18
+ throw new Error('specimen touched window')
19
+ }, config)
20
+ const result = render()
21
+ expect(result.browserOnly).toBe(true)
22
+ expect(result.html).toBe('')
23
+ expect(result.error).toContain('specimen touched window')
24
+ })
25
+ })
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react'
2
+ import { StrictMode } from 'react'
3
+ import type { DisplayCaseConfig } from '../index'
4
+ import { PrimerRoot } from '../ui/primer'
5
+ import { renderWithStyles } from './collect-styles'
6
+
7
+ /**
8
+ * Server-side primer rendering — the sibling of {@link makeCaseRenderer} for
9
+ * the `/render/primer` document. The codegen'd SSR-primer entry imports the
10
+ * compiled MDX and binds it here; the server imports that freshly-built bundle
11
+ * each rebuild (same staleness reasoning as the case renderer). The primer's
12
+ * prose and its embedded specimens render to markup once — the theme is a
13
+ * document attribute, not part of the tree, so one render serves both themes.
14
+ */
15
+
16
+ type MDXContent = (props: { components?: unknown }) => ReactNode
17
+
18
+ export interface PrimerHtmlResult {
19
+ html: string
20
+ /** True when the primer could not be rendered outside a browser (a specimen
21
+ * touched a browser-only API under `renderToString`). The whole primer then
22
+ * falls back to client rendering — the same isolation a single case gets. */
23
+ browserOnly: boolean
24
+ error?: string
25
+ /** Render-time (CSS-in-JS) styling collected by the configured style engines,
26
+ * as `<head>` markup. `''` (or absent) when no engine produced styling. */
27
+ headStyles?: string
28
+ }
29
+
30
+ export function makePrimerRenderer(
31
+ Content: MDXContent,
32
+ config: DisplayCaseConfig,
33
+ ): () => PrimerHtmlResult {
34
+ return function renderPrimerToHtml(): PrimerHtmlResult {
35
+ try {
36
+ // Apply any configured style engines around the primer tree, exactly as the
37
+ // case renderer does, so a specimen's render-time CSS-in-JS styling is
38
+ // delivered before scripting too.
39
+ const { html, headStyles } = renderWithStyles(
40
+ <StrictMode>
41
+ <PrimerRoot content={Content} />
42
+ </StrictMode>,
43
+ config.styleEngines,
44
+ )
45
+ return { html, browserOnly: false, headStyles }
46
+ } catch (err) {
47
+ return {
48
+ html: '',
49
+ browserOnly: true,
50
+ error: err instanceof Error ? err.message : String(err),
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type DisplayCaseConfig, defineCases, type StyleEngine } from '../index'
3
+ import type { CaseTreeState } from './render-node'
4
+ import { makeCaseRenderer } from './ssr-render'
5
+
6
+ const NO_CONFIG: DisplayCaseConfig = {} as DisplayCaseConfig
7
+
8
+ /** A stub engine emitting a per-render-instance-tagged style tag. */
9
+ function stubEngine(counter: { n: number }): StyleEngine {
10
+ return () => {
11
+ const id = ++counter.n
12
+ return {
13
+ wrap: (node) => node,
14
+ collect: () => `<style data-stub="${id}"></style>`,
15
+ }
16
+ }
17
+ }
18
+
19
+ const state = (over: Partial<CaseTreeState>): CaseTreeState => ({
20
+ componentId: 'button',
21
+ caseId: 'default',
22
+ width: null,
23
+ tweaks: {},
24
+ ...over,
25
+ })
26
+
27
+ function Boom(): never {
28
+ throw new Error('needs a browser')
29
+ }
30
+
31
+ describe('makeCaseRenderer', () => {
32
+ test('renders an SSR-able case to inner markup', () => {
33
+ const render = makeCaseRenderer(
34
+ [
35
+ defineCases('Button', {
36
+ Default: () => <button type="button">Hi</button>,
37
+ }),
38
+ ],
39
+ NO_CONFIG,
40
+ )
41
+ const result = render(state({}))
42
+ expect(result.browserOnly).toBe(false)
43
+ expect(result.html).toContain('Hi')
44
+ expect(result.error).toBeUndefined()
45
+ })
46
+
47
+ test('skips server rendering for a browser-only module without attempting it', () => {
48
+ const render = makeCaseRenderer(
49
+ [
50
+ defineCases(
51
+ 'Canvas',
52
+ { Default: () => <canvas /> },
53
+ { browserOnly: true },
54
+ ),
55
+ ],
56
+ NO_CONFIG,
57
+ )
58
+ const result = render(state({ componentId: 'canvas' }))
59
+ expect(result).toEqual({ html: '', browserOnly: true })
60
+ })
61
+
62
+ test('catches a render that needs a browser and reports it browser-only with the error', () => {
63
+ const render = makeCaseRenderer(
64
+ [defineCases('Bad', { Default: () => <Boom /> })],
65
+ NO_CONFIG,
66
+ )
67
+ const result = render(state({ componentId: 'bad' }))
68
+ expect(result.browserOnly).toBe(true)
69
+ expect(result.html).toBe('')
70
+ expect(result.error).toContain('needs a browser')
71
+ })
72
+
73
+ test('renders the not-found node for an unknown case (not a browser-only fallback)', () => {
74
+ const render = makeCaseRenderer([], NO_CONFIG)
75
+ const result = render(state({ componentId: 'ghost', caseId: 'x' }))
76
+ expect(result.browserOnly).toBe(false)
77
+ // renderToString interleaves `<!-- -->` markers between text nodes, so match
78
+ // the stable wrapper + ids rather than the contiguous sentence.
79
+ expect(result.html).toContain('dc-render-missing')
80
+ expect(result.html).toContain('No such case:')
81
+ expect(result.html).toContain('ghost')
82
+ })
83
+
84
+ test('without a style engine, headStyles is empty (inert when unused)', () => {
85
+ const render = makeCaseRenderer(
86
+ [
87
+ defineCases('Button', {
88
+ Default: () => <button type="button">Hi</button>,
89
+ }),
90
+ ],
91
+ NO_CONFIG,
92
+ )
93
+ expect(render(state({})).headStyles).toBe('')
94
+ })
95
+
96
+ test('a configured style engine collects head styling for the render', () => {
97
+ const render = makeCaseRenderer(
98
+ [
99
+ defineCases('Button', {
100
+ Default: () => <button type="button">Hi</button>,
101
+ }),
102
+ ],
103
+ { ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
104
+ )
105
+ const result = render(state({}))
106
+ expect(result.html).toContain('Hi')
107
+ expect(result.headStyles).toContain('data-stub=')
108
+ })
109
+
110
+ test('each render gets its own isolated collector (no cross-case bleed)', () => {
111
+ const render = makeCaseRenderer(
112
+ [
113
+ defineCases('Button', {
114
+ Default: () => <button type="button">Hi</button>,
115
+ }),
116
+ defineCases('Link', { Default: () => <a href="/x">Go</a> }),
117
+ ],
118
+ { ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
119
+ )
120
+ const a = render(state({ componentId: 'button' }))
121
+ const b = render(state({ componentId: 'link' }))
122
+ // Distinct per-render instance ids ⇒ a fresh store per render, not shared.
123
+ expect(a.headStyles).toContain('data-stub="1"')
124
+ expect(b.headStyles).toContain('data-stub="2"')
125
+ })
126
+
127
+ test('a browser-only case runs no engine and emits no head styling', () => {
128
+ const render = makeCaseRenderer(
129
+ [
130
+ defineCases(
131
+ 'Canvas',
132
+ { Default: () => <canvas /> },
133
+ { browserOnly: true },
134
+ ),
135
+ ],
136
+ { ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
137
+ )
138
+ const result = render(state({ componentId: 'canvas' }))
139
+ expect(result).toEqual({ html: '', browserOnly: true })
140
+ expect(result.headStyles).toBeUndefined()
141
+ })
142
+ })
@@ -0,0 +1,63 @@
1
+ import { findCase } from '../core/catalog'
2
+ import type { CaseModule, DisplayCaseConfig } from '../index'
3
+ import { renderWithStyles } from './collect-styles'
4
+ import { type CaseTreeState, caseTree, NOOP_GOTO } from './render-node'
5
+
6
+ /**
7
+ * Server-side case rendering. The codegen'd SSR entry (see
8
+ * `codegenSsrEntry`) imports every discovered case module plus the consumer
9
+ * config, hands them here, and exports the resulting `renderCaseToHtml`. The
10
+ * server imports that freshly-built bundle each rebuild — the bundle inlines the
11
+ * case source from disk, so its modules are always current, sidestepping the
12
+ * per-path module cache that forces the manifest into a subprocess.
13
+ */
14
+
15
+ export interface CaseHtmlResult {
16
+ /** Pre-rendered `#root` inner markup, or `''` when the case is browser-only. */
17
+ html: string
18
+ /** True when the case could not be rendered outside a browser (it threw under
19
+ * `renderToString`, or no such case exists to even attempt). */
20
+ browserOnly: boolean
21
+ /** The throw's message, for the server to log once per browser-only case. */
22
+ error?: string
23
+ /** Render-time (CSS-in-JS) styling collected by the configured style engines,
24
+ * as `<head>` markup to place after the document's static styles. `''` (or
25
+ * absent) when no engine is configured or none produced styling. */
26
+ headStyles?: string
27
+ }
28
+
29
+ export type CaseRenderer = (state: CaseTreeState) => CaseHtmlResult
30
+
31
+ export function makeCaseRenderer(
32
+ modules: CaseModule[],
33
+ config: DisplayCaseConfig,
34
+ ): CaseRenderer {
35
+ return function renderCaseToHtml(state: CaseTreeState): CaseHtmlResult {
36
+ // A component declared `browserOnly` opts out of server rendering: skip the
37
+ // attempt (no throw, no log) and let the client mount it.
38
+ const found = findCase(modules, state.componentId, state.caseId)
39
+ if (found?.module.browserOnly) return { html: '', browserOnly: true }
40
+ try {
41
+ // Apply any configured style engines around the case tree so render-time
42
+ // CSS-in-JS styling (emotion/MUI, styled-components…) is collected and
43
+ // delivered before scripting. No engines ⇒ a plain render, `headStyles`
44
+ // `''`, document byte-identical to before.
45
+ const { html, headStyles } = renderWithStyles(
46
+ caseTree(modules, config, state, NOOP_GOTO),
47
+ config.styleEngines,
48
+ )
49
+ return { html, browserOnly: false, headStyles }
50
+ } catch (err) {
51
+ // The case — or a component it renders — needs a browser: it touched a
52
+ // browser-only API (window, layout measurement, canvas…) under
53
+ // `renderToString`. Don't fail the document; emit no server markup for
54
+ // this case and let the client mount it. The server records it so later
55
+ // requests skip the server attempt and so the author gets one log line.
56
+ return {
57
+ html: '',
58
+ browserOnly: true,
59
+ error: err instanceof Error ? err.message : String(err),
60
+ }
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Manifest } from '../core/manifest'
3
+ import { renderShellToHtml } from './ssr-shell'
4
+
5
+ const manifest: Manifest = {
6
+ title: 'Showcase',
7
+ components: [
8
+ {
9
+ id: 'button',
10
+ name: 'Button',
11
+ level: 'atom',
12
+ isFlow: false,
13
+ caseFile: 'src/Button.case.tsx',
14
+ placardDoc: null,
15
+ cases: [
16
+ {
17
+ id: 'default',
18
+ name: 'Default',
19
+ browseUrl: '/c/button/default',
20
+ renderUrl: '/render/button/default',
21
+ tweaks: null,
22
+ transitions: [],
23
+ },
24
+ ],
25
+ },
26
+ ],
27
+ primer: false,
28
+ landing: 'library',
29
+ }
30
+
31
+ describe('renderShellToHtml', () => {
32
+ test('server-renders the browse chrome to markup', () => {
33
+ const result = renderShellToHtml({
34
+ manifest,
35
+ pathname: '/c/button/default',
36
+ search: '',
37
+ theme: 'dark',
38
+ a11y: false,
39
+ })
40
+ expect(result.ssr).toBe(true)
41
+ expect(result.html.length).toBeGreaterThan(0)
42
+ // The seeded component name reaches the rendered nav.
43
+ expect(result.html).toContain('Button')
44
+ })
45
+
46
+ test('renders the primer landing without throwing when one is configured', () => {
47
+ const result = renderShellToHtml({
48
+ manifest: { ...manifest, primer: true, landing: 'primer' },
49
+ pathname: '/',
50
+ search: '',
51
+ theme: 'light',
52
+ a11y: true,
53
+ })
54
+ expect(result.ssr).toBe(true)
55
+ expect(result.html.length).toBeGreaterThan(0)
56
+ })
57
+ })
@@ -0,0 +1,54 @@
1
+ import { StrictMode } from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import type { Manifest } from '../core/manifest'
4
+ import { Shell } from '../ui/shell'
5
+ import { parseRoute, type Theme } from '../ui/shell-core'
6
+ import type { ShellSeed } from '../ui/use-shell'
7
+
8
+ /**
9
+ * Server-side render of the browse shell. Unlike the case/primer renderers, the
10
+ * shell depends only on the manifest *data* and the request route — not on any
11
+ * consumer case module — so it renders in-process from the in-memory manifest
12
+ * with no per-rebuild bundle. The same `<Shell seed=…>` the client hydrates is
13
+ * rendered here, seeded from the request, so the two agree.
14
+ */
15
+
16
+ export interface ShellHtmlResult {
17
+ /** Pre-rendered `#root` inner markup, or `''` when the shell could not render. */
18
+ html: string
19
+ /** Whether `html` is present, so the client adopts instead of mounting fresh. */
20
+ ssr: boolean
21
+ }
22
+
23
+ export function renderShellToHtml(args: {
24
+ manifest: Manifest
25
+ pathname: string
26
+ search: string
27
+ theme: Theme
28
+ a11y: boolean
29
+ }): ShellHtmlResult {
30
+ const route = parseRoute(args.pathname, args.search)
31
+ const seed: ShellSeed = {
32
+ manifest: args.manifest,
33
+ route,
34
+ theme: args.theme,
35
+ a11y: args.a11y,
36
+ }
37
+ try {
38
+ const html = renderToString(
39
+ <StrictMode>
40
+ <Shell seed={seed} />
41
+ </StrictMode>,
42
+ )
43
+ return { html, ssr: true }
44
+ } catch (err) {
45
+ // The shell is first-party code; a throw here is a defect, not a graceful
46
+ // case. Don't fail the document — serve it empty for the client to mount,
47
+ // and surface the error so it gets fixed.
48
+ console.warn(
49
+ '[display-case] shell server-render failed; the client will render it:',
50
+ err instanceof Error ? err.message : String(err),
51
+ )
52
+ return { html: '', ssr: false }
53
+ }
54
+ }
@@ -0,0 +1,237 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import type { BuildDescriptor } from '../commands/publish'
4
+ import type { Manifest } from '../core/manifest'
5
+ import { primerDoc, renderDoc, shellDoc } from '../render/documents'
6
+ import type { PrimerHtmlResult } from '../render/ssr-primer'
7
+ import type { CaseRenderer } from '../render/ssr-render'
8
+ import { renderShellToHtml } from '../render/ssr-shell'
9
+ import type { Theme } from '../ui/shell-core'
10
+
11
+ /**
12
+ * The production host for a published build. It serves the pre-rendered shell,
13
+ * isolated case render, and primer documents — rendered on request through the
14
+ * SAME renderers the dev server uses (`ssr-shell`/`ssr-render`/`ssr-primer`) —
15
+ * plus the content-hashed assets, with hosting-appropriate caching, a health
16
+ * endpoint, and base-path support. It carries NONE of the dev machinery: no
17
+ * watcher, no rebuild, no live-reload stream, no on-demand a11y, no dev
18
+ * endpoints. Build once, serve.
19
+ */
20
+
21
+ const ASSET_CACHE = 'public, max-age=31536000, immutable'
22
+ const HTML_CACHE = 'no-cache'
23
+
24
+ interface Loaded {
25
+ buildDir: string
26
+ descriptor: BuildDescriptor
27
+ manifest: Manifest
28
+ renderCase: CaseRenderer
29
+ renderPrimer: (() => PrimerHtmlResult) | null
30
+ }
31
+
32
+ async function load(buildDir: string): Promise<Loaded> {
33
+ const descriptor = (await Bun.file(
34
+ join(buildDir, 'dc-build.json'),
35
+ ).json()) as BuildDescriptor
36
+ const manifest = (await Bun.file(
37
+ join(buildDir, 'manifest.json'),
38
+ ).json()) as Manifest
39
+ const ssrMod = (await import(join(buildDir, 'server', 'ssr-entry.js'))) as {
40
+ renderCaseToHtml: CaseRenderer
41
+ }
42
+ let renderPrimer: (() => PrimerHtmlResult) | null = null
43
+ if (descriptor.hasPrimer) {
44
+ const pMod = (await import(
45
+ join(buildDir, 'server', 'ssr-primer-entry.js')
46
+ )) as { renderPrimerToHtml: () => PrimerHtmlResult }
47
+ renderPrimer = pMod.renderPrimerToHtml
48
+ }
49
+ return {
50
+ buildDir,
51
+ descriptor,
52
+ manifest,
53
+ renderCase: ssrMod.renderCaseToHtml,
54
+ renderPrimer,
55
+ }
56
+ }
57
+
58
+ function parseRenderState(url: URL) {
59
+ const parts = url.pathname.split('/').filter(Boolean)
60
+ const p = url.searchParams
61
+ const tweaks: Record<string, string> = {}
62
+ for (const [k, v] of p) if (k.startsWith('t.')) tweaks[k.slice(2)] = v
63
+ const widthParam = p.get('width')
64
+ // path shape (after base strip): /render/<component>/<case>
65
+ return {
66
+ componentId: parts[1] ?? '',
67
+ caseId: parts[2] ?? '',
68
+ theme: (p.get('theme') === 'dark' ? 'dark' : 'light') as Theme,
69
+ width: widthParam ? Number(widthParam) : null,
70
+ tweaks,
71
+ fit: p.get('fit') === '1',
72
+ transparent: p.get('transparent') === '1',
73
+ }
74
+ }
75
+
76
+ /** Render the document for an internal path (base already stripped) + query.
77
+ * Returns null for non-document paths (assets/health handled by the caller). */
78
+ function documentFor(loaded: Loaded, path: string, url: URL): string {
79
+ const { descriptor, manifest, renderCase, renderPrimer } = loaded
80
+ const assets = descriptor.assets
81
+ const theme: Theme =
82
+ url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
83
+
84
+ if (path === '/render/primer') {
85
+ let markup = ''
86
+ let ssr = false
87
+ let headStyles: string | undefined
88
+ if (renderPrimer) {
89
+ const r = renderPrimer()
90
+ if (!r.browserOnly) {
91
+ markup = r.html
92
+ ssr = true
93
+ headStyles = r.headStyles
94
+ }
95
+ }
96
+ return primerDoc({
97
+ tokensCss: descriptor.tokensCss,
98
+ globalCss: descriptor.globalCss,
99
+ vitrineCss: descriptor.vitrineCss,
100
+ theme,
101
+ markup,
102
+ ssr,
103
+ headStyles,
104
+ assets,
105
+ })
106
+ }
107
+
108
+ if (path === '/render' || path.startsWith('/render/')) {
109
+ const rs = parseRenderState(url)
110
+ let markup = ''
111
+ let ssr = false
112
+ let headStyles: string | undefined
113
+ if (rs.componentId && rs.caseId) {
114
+ const r = renderCase({
115
+ componentId: rs.componentId,
116
+ caseId: rs.caseId,
117
+ width: rs.width,
118
+ tweaks: rs.tweaks,
119
+ })
120
+ if (!r.browserOnly) {
121
+ markup = r.html
122
+ ssr = true
123
+ headStyles = r.headStyles
124
+ }
125
+ }
126
+ return renderDoc({
127
+ globalCss: descriptor.globalCss,
128
+ vitrineCss: descriptor.vitrineCss,
129
+ theme: rs.theme,
130
+ transparent: rs.transparent,
131
+ fit: rs.fit,
132
+ markup,
133
+ ssr,
134
+ headStyles,
135
+ assets,
136
+ })
137
+ }
138
+
139
+ // Shell: `/`, `/primer`, every `/c/...` deep link.
140
+ const shell = renderShellToHtml({
141
+ manifest,
142
+ pathname: path,
143
+ search: url.search,
144
+ theme,
145
+ a11y: false,
146
+ })
147
+ return shellDoc({
148
+ title: descriptor.title,
149
+ tokensCss: descriptor.tokensCss,
150
+ globalCss: descriptor.globalCss,
151
+ vitrineCss: descriptor.vitrineCss,
152
+ theme,
153
+ markup: shell.html,
154
+ ssr: shell.ssr,
155
+ manifest,
156
+ a11y: false,
157
+ assets,
158
+ })
159
+ }
160
+
161
+ export async function startProdServer(
162
+ buildDir: string,
163
+ opts: { port?: number } = {},
164
+ ) {
165
+ const loaded = await load(buildDir)
166
+ const base = loaded.descriptor.base
167
+
168
+ const server = Bun.serve({
169
+ port: opts.port ?? 3000,
170
+ async fetch(req) {
171
+ const url = new URL(req.url)
172
+ let path = url.pathname
173
+ if (base && (path === base || path.startsWith(`${base}/`))) {
174
+ path = path.slice(base.length) || '/'
175
+ }
176
+
177
+ if (path === '/health') return new Response('ok')
178
+
179
+ if (path.startsWith('/assets/')) {
180
+ const file = Bun.file(
181
+ join(buildDir, 'assets', path.slice('/assets/'.length)),
182
+ )
183
+ if (!(await file.exists())) {
184
+ return new Response('not found', { status: 404 })
185
+ }
186
+ return new Response(file, { headers: { 'cache-control': ASSET_CACHE } })
187
+ }
188
+
189
+ return new Response(documentFor(loaded, path, url), {
190
+ headers: {
191
+ 'content-type': 'text/html; charset=utf-8',
192
+ 'cache-control': HTML_CACHE,
193
+ },
194
+ })
195
+ },
196
+ })
197
+ return server
198
+ }
199
+
200
+ /**
201
+ * Crawl every address and write complete HTML files, so the build can be hosted
202
+ * with no running server. Files are keyed by *path* (default theme + tweaks);
203
+ * address-encoded variations resolve on the client after hydration — logged so
204
+ * the boundary is explicit, not silent.
205
+ */
206
+ export async function writeStaticExport(buildDir: string): Promise<void> {
207
+ const loaded = await load(buildDir)
208
+ const { manifest } = loaded
209
+ const write = async (route: string, file: string) => {
210
+ const url = new URL(`http://static${route}`)
211
+ const html = documentFor(loaded, url.pathname, url)
212
+ const abs = join(buildDir, file)
213
+ await mkdir(dirname(abs), { recursive: true })
214
+ await Bun.write(abs, html)
215
+ }
216
+
217
+ await write('/', 'index.html')
218
+ if (loaded.descriptor.hasPrimer) {
219
+ await write('/primer', 'primer/index.html')
220
+ await write('/render/primer', 'render/primer/index.html')
221
+ }
222
+ let pages = 2
223
+ for (const c of manifest.components) {
224
+ for (const cs of c.cases) {
225
+ await write(`/c/${c.id}/${cs.id}`, `c/${c.id}/${cs.id}/index.html`)
226
+ await write(
227
+ `/render/${c.id}/${cs.id}`,
228
+ `render/${c.id}/${cs.id}/index.html`,
229
+ )
230
+ pages += 2
231
+ }
232
+ }
233
+ console.log(
234
+ ` static export: ${pages} page(s). Note: query-encoded tweak/theme ` +
235
+ 'variations have no per-path file — they resolve on the client after hydration.',
236
+ )
237
+ }