@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,144 @@
1
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
2
+
3
+ /**
4
+ * Change-scoping support: compute, for a set of changed files, which components
5
+ * a render-time check (a11y / visual) actually needs to re-run.
6
+ *
7
+ * A component is "affected" when any changed file lies in the *import closure*
8
+ * of its case file — the case itself, the component it imports, and everything
9
+ * those pull in transitively (including `@import`ed CSS). The closure follows
10
+ * only **relative** specifiers: a Display Case showcase is dependency-light and
11
+ * a pull request virtually never edits a node_modules package, so bare imports
12
+ * (`react`, `display-case`, …) are intentionally not traced. This keeps the
13
+ * analysis a pure, fast file walk with no bundler or module-graph dependency.
14
+ *
15
+ * Everything here is pure (path + file reads only) so it is unit-testable
16
+ * without a server, a browser, or git.
17
+ */
18
+
19
+ // Extensions we try when a relative specifier omits one, in resolution order.
20
+ // A specifier that already carries an extension (e.g. `./styles.css`) resolves
21
+ // via the bare candidate first.
22
+ const RESOLVE_EXTS = [
23
+ '.tsx',
24
+ '.ts',
25
+ '.jsx',
26
+ '.js',
27
+ '.mjs',
28
+ '.cjs',
29
+ '.css',
30
+ '.json',
31
+ ] as const
32
+
33
+ // Relative import/export specifiers in a JS/TS source. Covers `import … from`,
34
+ // `export … from`, side-effect `import '…'`, and dynamic `import('…')` /
35
+ // `require('…')`. Source-level regexes (not a parser) — good enough for a
36
+ // dependency walk and free of any parsing dependency.
37
+ const JS_FROM = /(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g
38
+ const JS_BARE = /\bimport\s*['"]([^'"]+)['"]/g
39
+ const JS_DYNAMIC = /\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g
40
+ // `@import '…'` / `@import "…"` / `@import url(…)` in CSS.
41
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?['"]([^'"]+)['"]/g
42
+
43
+ function specifiers(source: string, isCss: boolean): string[] {
44
+ const out: string[] = []
45
+ const collect = (re: RegExp) => {
46
+ re.lastIndex = 0
47
+ for (let m = re.exec(source); m; m = re.exec(source)) out.push(m[1])
48
+ }
49
+ if (isCss) {
50
+ collect(CSS_IMPORT)
51
+ } else {
52
+ collect(JS_FROM)
53
+ collect(JS_BARE)
54
+ collect(JS_DYNAMIC)
55
+ }
56
+ return out
57
+ }
58
+
59
+ function* candidates(base: string): Generator<string> {
60
+ yield base
61
+ for (const ext of RESOLVE_EXTS) yield base + ext
62
+ for (const ext of RESOLVE_EXTS) yield join(base, `index${ext}`)
63
+ }
64
+
65
+ /** Resolve a relative specifier to an absolute file path, or null. Bare
66
+ * specifiers (not starting with `.` or `/`) are not traced and return null. */
67
+ async function resolveSpecifier(
68
+ fromFile: string,
69
+ spec: string,
70
+ ): Promise<string | null> {
71
+ if (!spec.startsWith('.') && !spec.startsWith('/')) return null
72
+ const base = isAbsolute(spec) ? spec : resolve(dirname(fromFile), spec)
73
+ for (const cand of candidates(base)) {
74
+ if (await Bun.file(cand).exists()) return cand
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * The transitive relative-import closure of one or more entry files, as a set
81
+ * of absolute paths that includes the entries themselves. Files that can't be
82
+ * read are skipped (an entry pointing at a deleted file contributes only
83
+ * itself).
84
+ */
85
+ export async function importClosure(entries: string[]): Promise<Set<string>> {
86
+ const seen = new Set<string>()
87
+ const queue = entries.map((e) => resolve(e))
88
+ while (queue.length) {
89
+ const file = queue.pop()
90
+ if (!file || seen.has(file)) continue
91
+ seen.add(file)
92
+ let source: string
93
+ try {
94
+ source = await Bun.file(file).text()
95
+ } catch {
96
+ continue
97
+ }
98
+ for (const spec of specifiers(source, file.endsWith('.css'))) {
99
+ const resolved = await resolveSpecifier(file, spec)
100
+ if (resolved && !seen.has(resolved)) queue.push(resolved)
101
+ }
102
+ }
103
+ return seen
104
+ }
105
+
106
+ /** Each component's import closure, keyed by component id. */
107
+ export async function componentClosures(
108
+ components: { id: string; caseFile: string }[],
109
+ ): Promise<Map<string, Set<string>>> {
110
+ const map = new Map<string, Set<string>>()
111
+ for (const c of components) map.set(c.id, await importClosure([c.caseFile]))
112
+ return map
113
+ }
114
+
115
+ /**
116
+ * The subset of `components` affected by `changedFiles` — i.e. those whose case
117
+ * file's import closure contains at least one changed file. `changedFiles` and
118
+ * each `caseFile` are absolute paths.
119
+ *
120
+ * Note: this attributes a changed file to a component only when that file is in
121
+ * the component's *import closure*. A changed file that no closure claims (e.g.
122
+ * globally-inlined CSS, the render pipeline) is invisible here — the caller is
123
+ * responsible for treating such an unattributed render-input as affecting every
124
+ * component. See `componentClosures` for building the closures once to make that
125
+ * coverage check.
126
+ */
127
+ export async function affectedComponents(
128
+ components: { id: string; caseFile: string }[],
129
+ changedFiles: Iterable<string>,
130
+ ): Promise<Set<string>> {
131
+ const changed = new Set<string>()
132
+ for (const f of changedFiles) changed.add(resolve(f))
133
+ const closures = await componentClosures(components)
134
+ const affected = new Set<string>()
135
+ for (const [id, files] of closures) {
136
+ for (const f of files) {
137
+ if (changed.has(f)) {
138
+ affected.add(id)
139
+ break
140
+ }
141
+ }
142
+ }
143
+ return affected
144
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { CaseModule } from '../index'
3
+ import { defineCases, defineFlow, flowStep, tweak } from '../index'
4
+ import { buildCatalog, findCase, slugify } from './catalog'
5
+
6
+ describe('slugify', () => {
7
+ test('lowercases and kebabs whitespace', () => {
8
+ expect(slugify('Sign In Form')).toBe('sign-in-form')
9
+ })
10
+
11
+ test('collapses runs of non-alphanumerics into a single dash', () => {
12
+ expect(slugify('Hello World!!!')).toBe('hello-world')
13
+ })
14
+
15
+ test('strips leading and trailing separators', () => {
16
+ expect(slugify(' --Button-- ')).toBe('button')
17
+ })
18
+
19
+ test('keeps an existing hyphen inside a name', () => {
20
+ expect(slugify('Sign-in')).toBe('sign-in')
21
+ })
22
+
23
+ test('keeps digits', () => {
24
+ expect(slugify('H1 Heading 2')).toBe('h1-heading-2')
25
+ })
26
+
27
+ test('treats non-ascii letters as separators', () => {
28
+ expect(slugify('Café Crème')).toBe('caf-cr-me')
29
+ })
30
+
31
+ test('slugs all-separator or empty input to an empty string', () => {
32
+ expect(slugify(' ')).toBe('')
33
+ expect(slugify('—')).toBe('')
34
+ expect(slugify('')).toBe('')
35
+ })
36
+ })
37
+
38
+ describe('buildCatalog', () => {
39
+ test('slugifies component and case ids while preserving display names', () => {
40
+ const cat = buildCatalog([
41
+ defineCases('Icon Button', { 'With Label': () => null }),
42
+ ])
43
+ expect(cat).toHaveLength(1)
44
+ expect(cat[0].id).toBe('icon-button')
45
+ expect(cat[0].name).toBe('Icon Button')
46
+ expect(cat[0].cases[0].id).toBe('with-label')
47
+ expect(cat[0].cases[0].name).toBe('With Label')
48
+ })
49
+
50
+ test('a simple (function) case carries null tweaks and no transitions', () => {
51
+ const cat = buildCatalog([defineCases('Button', { Default: () => null })])
52
+ expect(cat[0].cases[0].tweaks).toBeNull()
53
+ expect(cat[0].cases[0].transitions).toEqual([])
54
+ })
55
+
56
+ test('a tweaked case exposes its declared tweak schema', () => {
57
+ const tweaks = { label: tweak.text('Save') }
58
+ const cat = buildCatalog([
59
+ defineCases('Button', { Custom: { tweaks, render: () => null } }),
60
+ ])
61
+ expect(cat[0].cases[0].tweaks).toEqual(tweaks)
62
+ })
63
+
64
+ test('orders components by hierarchy level, then by name', () => {
65
+ const cat = buildCatalog([
66
+ defineCases('Zeta', { D: () => null }, { level: 'organism' }),
67
+ defineCases('Alpha', { D: () => null }, { level: 'atom' }),
68
+ defineCases('Beta', { D: () => null }, { level: 'atom' }),
69
+ ])
70
+ expect(cat.map((c) => c.name)).toEqual(['Alpha', 'Beta', 'Zeta'])
71
+ })
72
+
73
+ test('places unclassified (level-less) components last', () => {
74
+ const cat = buildCatalog([
75
+ defineCases('NoLevel', { D: () => null }),
76
+ defineCases('Page', { D: () => null }, { level: 'page' }),
77
+ defineCases('Atom', { D: () => null }, { level: 'atom' }),
78
+ ])
79
+ expect(cat.map((c) => c.name)).toEqual(['Atom', 'Page', 'NoLevel'])
80
+ })
81
+
82
+ test('preserves case insertion order within a component', () => {
83
+ const cat = buildCatalog([
84
+ defineCases('Button', {
85
+ Third: () => null,
86
+ First: () => null,
87
+ Second: () => null,
88
+ }),
89
+ ])
90
+ expect(cat[0].cases.map((c) => c.name)).toEqual([
91
+ 'Third',
92
+ 'First',
93
+ 'Second',
94
+ ])
95
+ })
96
+
97
+ test('records a flow step’s outgoing transitions as slugified target ids', () => {
98
+ const cat = buildCatalog([
99
+ defineFlow('Sign In', {
100
+ steps: {
101
+ 'Request Link': flowStep({
102
+ transitions: ['Check Email'],
103
+ render: () => null,
104
+ }),
105
+ 'Check Email': flowStep({ render: () => null }),
106
+ },
107
+ }),
108
+ ])
109
+ expect(cat[0].isFlow).toBe(true)
110
+ expect(cat[0].level).toBe('flow')
111
+ expect(cat[0].cases[0].transitions).toEqual(['check-email'])
112
+ expect(cat[0].cases[1].transitions).toEqual([])
113
+ })
114
+
115
+ test('sorts flows after every classified level (flow is the last level)', () => {
116
+ const cat = buildCatalog([
117
+ defineFlow('Onboard', { steps: { A: flowStep({ render: () => null }) } }),
118
+ defineCases('Page', { D: () => null }, { level: 'page' }),
119
+ ])
120
+ expect(cat.map((c) => c.name)).toEqual(['Page', 'Onboard'])
121
+ })
122
+ })
123
+
124
+ describe('findCase', () => {
125
+ const modules: CaseModule[] = [
126
+ defineCases('Icon Button', { 'With Label': () => null }),
127
+ defineFlow('Sign In', {
128
+ steps: { 'Request Link': flowStep({ render: () => null }) },
129
+ }),
130
+ ]
131
+
132
+ test('resolves a component+case slug to original names and the case', () => {
133
+ const hit = findCase(modules, 'icon-button', 'with-label')
134
+ expect(hit).not.toBeNull()
135
+ expect(hit?.module.component).toBe('Icon Button')
136
+ expect(hit?.caseName).toBe('With Label')
137
+ expect(typeof hit?.case).toBe('function')
138
+ })
139
+
140
+ test('resolves a flow step by its slug', () => {
141
+ const hit = findCase(modules, 'sign-in', 'request-link')
142
+ expect(hit?.caseName).toBe('Request Link')
143
+ })
144
+
145
+ test('returns null for an unknown component', () => {
146
+ expect(findCase(modules, 'nope', 'with-label')).toBeNull()
147
+ })
148
+
149
+ test('returns null for an unknown case within a known component', () => {
150
+ expect(findCase(modules, 'icon-button', 'nope')).toBeNull()
151
+ })
152
+ })
@@ -0,0 +1,92 @@
1
+ import type {
2
+ Case,
3
+ CaseModule,
4
+ FlowStep,
5
+ HierarchyLevel,
6
+ TweakSchema,
7
+ } from '../index'
8
+ import { HIERARCHY_LEVELS } from '../index'
9
+
10
+ /**
11
+ * Pure catalog model shared by the server (to build the manifest) and the
12
+ * browser (to resolve a slug back to a case). No node/DOM-specific imports.
13
+ */
14
+
15
+ export interface CatalogCase {
16
+ id: string
17
+ name: string
18
+ /** Declared tweak schema, or null when the case takes no tweaks. */
19
+ tweaks: TweakSchema | null
20
+ /** Slugified ids of steps this step can transition to (flows only; else []). */
21
+ transitions: string[]
22
+ }
23
+
24
+ export interface CatalogComponent {
25
+ id: string
26
+ name: string
27
+ level: HierarchyLevel | null
28
+ isFlow: boolean
29
+ cases: CatalogCase[]
30
+ }
31
+
32
+ /** Kebab-case a display name into a stable, URL-safe slug. */
33
+ export function slugify(name: string): string {
34
+ return name
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '-')
38
+ .replace(/^-+|-+$/g, '')
39
+ }
40
+
41
+ function caseTweaks(c: Case | FlowStep): TweakSchema | null {
42
+ return typeof c === 'function' ? null : (c.tweaks ?? null)
43
+ }
44
+
45
+ /** Outgoing transitions of a flow step, as slugified target ids. */
46
+ function caseTransitions(c: Case | FlowStep): string[] {
47
+ if (typeof c === 'function' || !('transitions' in c) || !c.transitions) {
48
+ return []
49
+ }
50
+ return c.transitions.map(slugify)
51
+ }
52
+
53
+ /** Build the ordered catalog from discovered case modules. */
54
+ export function buildCatalog(modules: CaseModule[]): CatalogComponent[] {
55
+ const components = modules.map((mod) => ({
56
+ id: slugify(mod.component),
57
+ name: mod.component,
58
+ level: mod.level ?? null,
59
+ isFlow: mod.isFlow,
60
+ cases: Object.entries(mod.cases).map(([name, c]) => ({
61
+ id: slugify(name),
62
+ name,
63
+ tweaks: caseTweaks(c),
64
+ transitions: caseTransitions(c),
65
+ })),
66
+ }))
67
+ // Stable sort: by hierarchy level (atoms first, unclassified last), then name.
68
+ return components.sort((a, b) => {
69
+ const la = a.level
70
+ ? HIERARCHY_LEVELS.indexOf(a.level)
71
+ : HIERARCHY_LEVELS.length
72
+ const lb = b.level
73
+ ? HIERARCHY_LEVELS.indexOf(b.level)
74
+ : HIERARCHY_LEVELS.length
75
+ return la - lb || a.name.localeCompare(b.name)
76
+ })
77
+ }
78
+
79
+ /** Resolve a component+case slug pair back to its module and renderable case. */
80
+ export function findCase(
81
+ modules: CaseModule[],
82
+ componentId: string,
83
+ caseId: string,
84
+ ): { module: CaseModule; caseName: string; case: Case | FlowStep } | null {
85
+ const mod = modules.find((m) => slugify(m.component) === componentId)
86
+ if (!mod) return null
87
+ const entry = Object.entries(mod.cases).find(
88
+ ([name]) => slugify(name) === caseId,
89
+ )
90
+ if (!entry) return null
91
+ return { module: mod, caseName: entry[0], case: entry[1] }
92
+ }
@@ -0,0 +1,184 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import type { DisplayCaseConfig } from '../index'
5
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
6
+ import {
7
+ baselineDir,
8
+ cacheDir,
9
+ codegenPrimerEntry,
10
+ codegenRenderEntry,
11
+ discoverCaseFiles,
12
+ loadModules,
13
+ resolveConfig,
14
+ } from './discovery'
15
+
16
+ const dirs: string[] = []
17
+ const setup = async (files: Record<string, string>) => {
18
+ const dir = await makeTempDir()
19
+ dirs.push(dir)
20
+ await writeFiles(dir, files)
21
+ return dir
22
+ }
23
+
24
+ afterEach(async () => {
25
+ while (dirs.length)
26
+ await rm(dirs.pop() as string, { recursive: true, force: true })
27
+ })
28
+
29
+ const cfg = (over: Partial<DisplayCaseConfig> = {}): DisplayCaseConfig => ({
30
+ title: 'T',
31
+ roots: ['**/*.case.tsx'],
32
+ ...over,
33
+ })
34
+
35
+ describe('resolveConfig', () => {
36
+ test('imports the default-exported config and returns its path', async () => {
37
+ const dir = await setup({
38
+ 'display-case.config.ts': `export default { title: 'T', roots: ['x'] }\n`,
39
+ })
40
+ const { config, configPath } = await resolveConfig(dir)
41
+ expect(config.title).toBe('T')
42
+ expect(configPath.endsWith('display-case.config.ts')).toBe(true)
43
+ })
44
+
45
+ test('throws when no config file is present', async () => {
46
+ const dir = await setup({})
47
+ expect(resolveConfig(dir)).rejects.toThrow(/No Display Case config/)
48
+ })
49
+
50
+ test('throws when the config file has no default export', async () => {
51
+ const dir = await setup({
52
+ 'display-case.config.ts': `export const notDefault = { title: 'T', roots: [] }\n`,
53
+ })
54
+ expect(resolveConfig(dir)).rejects.toThrow(/default-export/)
55
+ })
56
+ })
57
+
58
+ describe('discoverCaseFiles', () => {
59
+ test('resolves globs to sorted absolute paths', async () => {
60
+ const dir = await setup({
61
+ 'a/Beta.case.tsx': 'export default {}\n',
62
+ 'a/Alpha.case.tsx': 'export default {}\n',
63
+ 'b/Gamma.case.tsx': 'export default {}\n',
64
+ })
65
+ const files = await discoverCaseFiles(dir, cfg())
66
+ expect(files.every(isAbsolute)).toBe(true)
67
+ expect(files.map((f) => f.slice(dir.length + 1))).toEqual([
68
+ 'a/Alpha.case.tsx',
69
+ 'a/Beta.case.tsx',
70
+ 'b/Gamma.case.tsx',
71
+ ])
72
+ })
73
+
74
+ test('skips node_modules and dedups across overlapping globs', async () => {
75
+ const dir = await setup({
76
+ 'Button.case.tsx': 'export default {}\n',
77
+ 'node_modules/pkg/Ignored.case.tsx': 'export default {}\n',
78
+ })
79
+ const files = await discoverCaseFiles(
80
+ dir,
81
+ cfg({ roots: ['**/*.case.tsx', '**/*.case.tsx'] }),
82
+ )
83
+ expect(files.map((f) => f.slice(dir.length + 1))).toEqual([
84
+ 'Button.case.tsx',
85
+ ])
86
+ })
87
+ })
88
+
89
+ describe('loadModules', () => {
90
+ test('imports a valid case module', async () => {
91
+ const dir = await setup({
92
+ 'Button.case.tsx': `export default { component: 'Button', cases: {}, isFlow: false }\n`,
93
+ })
94
+ const file = join(dir, 'Button.case.tsx')
95
+ const { modules, errors } = await loadModules([file])
96
+ expect(errors).toEqual([])
97
+ expect(modules).toHaveLength(1)
98
+ expect(modules[0].module.component).toBe('Button')
99
+ expect(modules[0].file).toBe(file)
100
+ })
101
+
102
+ test('records a file with no default export as an error', async () => {
103
+ const dir = await setup({ 'Bad.case.tsx': `export const nope = 1\n` })
104
+ const file = join(dir, 'Bad.case.tsx')
105
+ const { modules, errors } = await loadModules([file])
106
+ expect(modules).toEqual([])
107
+ expect(errors).toHaveLength(1)
108
+ expect(errors[0].file).toBe(file)
109
+ expect(errors[0].error).toMatch(/no valid default export/)
110
+ })
111
+
112
+ test('records a default export whose component is not a string', async () => {
113
+ const dir = await setup({
114
+ 'Bad.case.tsx': `export default { cases: {}, isFlow: false }\n`,
115
+ })
116
+ const { modules, errors } = await loadModules([join(dir, 'Bad.case.tsx')])
117
+ expect(modules).toEqual([])
118
+ expect(errors).toHaveLength(1)
119
+ })
120
+
121
+ test('captures a throwing import and still loads the rest', async () => {
122
+ const dir = await setup({
123
+ 'Throws.case.tsx': `throw new Error('boom')\n`,
124
+ 'Good.case.tsx': `export default { component: 'Good', cases: {}, isFlow: false }\n`,
125
+ })
126
+ const { modules, errors } = await loadModules([
127
+ join(dir, 'Throws.case.tsx'),
128
+ join(dir, 'Good.case.tsx'),
129
+ ])
130
+ expect(modules.map((m) => m.module.component)).toEqual(['Good'])
131
+ expect(errors).toHaveLength(1)
132
+ expect(errors[0].error).toMatch(/boom/)
133
+ })
134
+ })
135
+
136
+ describe('cacheDir / baselineDir', () => {
137
+ test('cacheDir is the .display-case dir under the package', () => {
138
+ expect(cacheDir('/pkg')).toBe('/pkg/.display-case')
139
+ })
140
+
141
+ test('baselineDir defaults to the cache baselines dir', () => {
142
+ expect(baselineDir('/pkg', cfg())).toBe('/pkg/.display-case/baselines')
143
+ })
144
+
145
+ test('baselineDir joins a relative override to the package', () => {
146
+ expect(baselineDir('/pkg', cfg({ baselineDir: 'snaps' }))).toBe(
147
+ '/pkg/snaps',
148
+ )
149
+ })
150
+
151
+ test('baselineDir honors an absolute override verbatim', () => {
152
+ expect(baselineDir('/pkg', cfg({ baselineDir: '/abs/snaps' }))).toBe(
153
+ '/abs/snaps',
154
+ )
155
+ })
156
+ })
157
+
158
+ describe('codegen entries', () => {
159
+ test('codegenRenderEntry imports every case, tags its source path, and mounts', async () => {
160
+ const dir = await setup({})
161
+ const files = [join(dir, 'a/Button.case.tsx'), join(dir, 'b/Card.case.tsx')]
162
+ const configPath = join(dir, 'display-case.config.ts')
163
+ const entry = await codegenRenderEntry(dir, files, configPath)
164
+ expect(entry).toBe(join(cacheDir(dir), 'render-entry.tsx'))
165
+ const src = await Bun.file(entry).text()
166
+ expect(src).toContain('AUTO-GENERATED')
167
+ expect(src).toContain('import m0 from')
168
+ expect(src).toContain('import m1 from')
169
+ expect(src).toContain('mountRender([')
170
+ expect(src).toContain(`sourcePath: ${JSON.stringify('a/Button.case.tsx')}`)
171
+ expect(src).toContain(`sourcePath: ${JSON.stringify('b/Card.case.tsx')}`)
172
+ })
173
+
174
+ test('codegenPrimerEntry imports the MDX document and mounts the primer', async () => {
175
+ const dir = await setup({})
176
+ const entry = await codegenPrimerEntry(dir, 'docs/primer.mdx')
177
+ expect(entry).toBe(join(cacheDir(dir), 'primer-entry.tsx'))
178
+ const src = await Bun.file(entry).text()
179
+ expect(src).toContain('AUTO-GENERATED')
180
+ expect(src).toContain('import MDXContent from')
181
+ expect(src).toContain('primer.mdx')
182
+ expect(src).toContain('mountPrimer(MDXContent)')
183
+ })
184
+ })