@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,410 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync, statSync } from 'node:fs'
3
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { dirname, join, relative, resolve } from 'node:path'
5
+ import { cacheDir } from '../core/discovery'
6
+ import type { A11yViolation, DisplayCaseConfig, RenderDriver } from '../index'
7
+
8
+ /**
9
+ * On-demand, cached accessibility scanner for the running browse server.
10
+ *
11
+ * It owns ONE lazily-launched render driver (reused across scans) and a serial
12
+ * job queue, so a scan never blocks request handling and the heavy browser is
13
+ * started only if a scan is actually requested. Results are cached on disk under
14
+ * `.display-case/a11y/` and reused until the scanned variant's rendered output
15
+ * changes — judged by a per-variant transitive-input content hash, with a
16
+ * mtime/size fast path so an unchanged variant costs a few `stat`s.
17
+ *
18
+ * The scan prerequisite (a headless browser + axe) is optional: if it can't be
19
+ * launched, the scanner flips to an `unavailable` status instead of throwing, so
20
+ * the server keeps browsing.
21
+ */
22
+
23
+ export type A11yScanStatus =
24
+ | { status: 'ok'; violations: A11yViolation[] }
25
+ | { status: 'pending' }
26
+ | { status: 'unavailable'; reason: string }
27
+
28
+ export interface A11yScannerOptions {
29
+ pkgDir: string
30
+ config: DisplayCaseConfig
31
+ /** Live base URL of the running server (e.g. `http://localhost:3100`). */
32
+ baseUrl: () => string
33
+ /** Absolute path to a component's `.case.tsx`, or null if unknown. */
34
+ caseFileAbs: (componentId: string) => string | null
35
+ /** Called when a queued scan resolves, so the server can push the result. */
36
+ onResult: (
37
+ componentId: string,
38
+ caseId: string,
39
+ theme: 'light' | 'dark',
40
+ status: A11yScanStatus,
41
+ ) => void
42
+ }
43
+
44
+ /** A single auditable variant: one case rendered under one theme. */
45
+ export interface A11yVariant {
46
+ componentId: string
47
+ caseId: string
48
+ theme: 'light' | 'dark'
49
+ }
50
+
51
+ /** How the navigation is populated at start-up (mirrors `config.a11y.startup`). */
52
+ export type A11yStartupMode = 'off' | 'cached' | 'refresh'
53
+
54
+ export interface A11yScanner {
55
+ /** Cached result if still valid, else enqueues a scan and reports `pending`.
56
+ * Reports `unavailable` if the scan prerequisite can't be launched. Pass
57
+ * `force` to drop the cached entry and re-scan (the panel's "re-scan"). */
58
+ request: (
59
+ componentId: string,
60
+ caseId: string,
61
+ theme: 'light' | 'dark',
62
+ force?: boolean,
63
+ ) => Promise<A11yScanStatus>
64
+ /** Populate the navigation at start-up without changing the on-demand flow.
65
+ * `cached` emits only the variants with a reusable cached result (no scans);
66
+ * `refresh` additionally enqueues every uncached or stale variant so its
67
+ * verdict lands over the same `onResult` path. `off` is a no-op. Each emitted
68
+ * result is delivered through the scanner's `onResult` callback. Runs nothing
69
+ * and emits nothing when the scan prerequisite is unavailable. Never awaits a
70
+ * scan — returns once cached results are emitted and pending work is queued. */
71
+ populateAtStartup: (
72
+ variants: A11yVariant[],
73
+ mode: A11yStartupMode,
74
+ ) => Promise<void>
75
+ /** Forget in-flight bookkeeping after a rebuild; the on-disk hashes still gate
76
+ * whether anything actually re-scans. */
77
+ invalidateAll: () => void
78
+ close: () => Promise<void>
79
+ }
80
+
81
+ interface Job {
82
+ componentId: string
83
+ caseId: string
84
+ theme: 'light' | 'dark'
85
+ key: string
86
+ }
87
+
88
+ interface CacheEntry {
89
+ toolVersion: string
90
+ hash: string
91
+ files: { path: string; mtimeMs: number; size: number }[]
92
+ violations: A11yViolation[]
93
+ scannedAt: number
94
+ }
95
+
96
+ const IMPORT_RE =
97
+ /(?:import|export)\b[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|import\s*['"]([^'"]+)['"]/g
98
+ const RESOLVE_EXTS = ['', '.ts', '.tsx', '.css', '.md', '.mdx']
99
+ const RESOLVE_INDEX = ['/index.ts', '/index.tsx']
100
+ const MAX_FILES = 400
101
+
102
+ /** Display Case's own version — folded into every hash so the cache busts when
103
+ * the tool (chrome, render harness) changes under the same consumer. */
104
+ let toolVersionCache: string | null = null
105
+ function toolVersion(): string {
106
+ if (toolVersionCache) return toolVersionCache
107
+ try {
108
+ const pkg = JSON.parse(
109
+ readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8'),
110
+ ) as { version?: string }
111
+ toolVersionCache = pkg.version ?? '0'
112
+ } catch {
113
+ toolVersionCache = '0'
114
+ }
115
+ return toolVersionCache
116
+ }
117
+
118
+ /** Resolve a relative import specifier to a file within the package. */
119
+ function resolveSpecifier(fromFile: string, spec: string): string | null {
120
+ if (!spec.startsWith('.')) return null // bare/package import — out of scope
121
+ const base = resolve(dirname(fromFile), spec)
122
+ for (const ext of RESOLVE_EXTS) {
123
+ if (ext && existsSync(base + ext)) return base + ext
124
+ }
125
+ if (existsSync(base) && !statSync(base).isDirectory()) return base
126
+ for (const idx of RESOLVE_INDEX) {
127
+ if (existsSync(base + idx)) return base + idx
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** Crawl the transitive, in-package import set reachable from a case file. */
133
+ async function transitiveFiles(
134
+ entry: string,
135
+ pkgDir: string,
136
+ ): Promise<string[]> {
137
+ const seen = new Set<string>()
138
+ const stack = [entry]
139
+ while (stack.length && seen.size < MAX_FILES) {
140
+ const file = stack.pop()
141
+ if (!file || seen.has(file)) continue
142
+ if (file.includes('/node_modules/') || !file.startsWith(pkgDir)) continue
143
+ seen.add(file)
144
+ let src: string
145
+ try {
146
+ src = await readFile(file, 'utf8')
147
+ } catch {
148
+ continue
149
+ }
150
+ IMPORT_RE.lastIndex = 0
151
+ let m: RegExpExecArray | null = IMPORT_RE.exec(src)
152
+ while (m) {
153
+ const spec = m[1] ?? m[2] ?? m[3]
154
+ if (spec) {
155
+ const resolved = resolveSpecifier(file, spec)
156
+ if (resolved && !seen.has(resolved)) stack.push(resolved)
157
+ }
158
+ m = IMPORT_RE.exec(src)
159
+ }
160
+ }
161
+ return [...seen].sort()
162
+ }
163
+
164
+ export function createA11yScanner(opts: A11yScannerOptions): A11yScanner {
165
+ const { pkgDir, config, baseUrl, caseFileAbs, onResult } = opts
166
+ const dir = join(cacheDir(pkgDir), 'a11y')
167
+
168
+ let driverPromise: Promise<RenderDriver> | null = null
169
+ let driver: RenderDriver | null = null
170
+ let unavailableReason: string | null = null
171
+ const inFlight = new Set<string>()
172
+ const queue: Job[] = []
173
+ let pumping = false
174
+
175
+ async function ensureDriver(): Promise<RenderDriver> {
176
+ if (unavailableReason) throw new Error(unavailableReason)
177
+ if (!driverPromise) {
178
+ driverPromise = (
179
+ config.providers?.driver
180
+ ? Promise.resolve(config.providers.driver())
181
+ : import('./providers/playwright-driver').then((m) =>
182
+ m.createPlaywrightDriver(),
183
+ )
184
+ ).catch((err: unknown) => {
185
+ unavailableReason = err instanceof Error ? err.message : String(err)
186
+ driverPromise = null
187
+ throw err
188
+ })
189
+ }
190
+ driver = await driverPromise
191
+ return driver
192
+ }
193
+
194
+ const cachePath = (key: string) => join(dir, `${key}.json`)
195
+
196
+ /** Globs the consumer's shared style inputs into the hash so a token/global
197
+ * edit invalidates every variant (not just the case-local import graph). */
198
+ async function sharedInputs(): Promise<string[]> {
199
+ const out: string[] = []
200
+ for (const rel of config.globalStyles ?? []) {
201
+ const abs = resolve(pkgDir, rel)
202
+ if (existsSync(abs)) out.push(abs)
203
+ }
204
+ return out
205
+ }
206
+
207
+ async function fingerprint(
208
+ componentId: string,
209
+ ): Promise<{ files: CacheEntry['files']; hash: string } | null> {
210
+ const caseFile = caseFileAbs(componentId)
211
+ if (!caseFile) return null
212
+ const files = [
213
+ ...new Set([
214
+ ...(await transitiveFiles(caseFile, pkgDir)),
215
+ ...(await sharedInputs()),
216
+ ]),
217
+ ].sort()
218
+ const hasher = createHash('sha256')
219
+ hasher.update(toolVersion())
220
+ const stats: CacheEntry['files'] = []
221
+ for (const f of files) {
222
+ let st: ReturnType<typeof statSync>
223
+ try {
224
+ st = statSync(f)
225
+ } catch {
226
+ continue
227
+ }
228
+ stats.push({
229
+ path: relative(pkgDir, f),
230
+ mtimeMs: st.mtimeMs,
231
+ size: st.size,
232
+ })
233
+ hasher.update(`\0${relative(pkgDir, f)}\0`)
234
+ hasher.update(await readFile(f))
235
+ }
236
+ return { files: stats, hash: hasher.digest('hex') }
237
+ }
238
+
239
+ async function readEntry(key: string): Promise<CacheEntry | null> {
240
+ try {
241
+ return JSON.parse(await readFile(cachePath(key), 'utf8')) as CacheEntry
242
+ } catch {
243
+ return null
244
+ }
245
+ }
246
+
247
+ /** Layered validity: stat the stored file set first (no reads); only when a
248
+ * stat differs do we re-crawl + content-hash to confirm a real change. */
249
+ async function cachedViolations(
250
+ componentId: string,
251
+ key: string,
252
+ ): Promise<A11yViolation[] | null> {
253
+ const entry = await readEntry(key)
254
+ if (!entry || entry.toolVersion !== toolVersion()) return null
255
+ const statsMatch = entry.files.every((rec) => {
256
+ try {
257
+ const st = statSync(resolve(pkgDir, rec.path))
258
+ return st.mtimeMs === rec.mtimeMs && st.size === rec.size
259
+ } catch {
260
+ return false
261
+ }
262
+ })
263
+ if (statsMatch) return entry.violations
264
+ const fp = await fingerprint(componentId)
265
+ if (fp && fp.hash === entry.hash) {
266
+ // Touched but unchanged — refresh stored stats so the fast path holds next
267
+ // time, and reuse the result.
268
+ await writeFile(
269
+ cachePath(key),
270
+ JSON.stringify({ ...entry, files: fp.files }),
271
+ )
272
+ return entry.violations
273
+ }
274
+ return null
275
+ }
276
+
277
+ async function writeCache(
278
+ componentId: string,
279
+ key: string,
280
+ violations: A11yViolation[],
281
+ ): Promise<void> {
282
+ const fp = await fingerprint(componentId)
283
+ if (!fp) return
284
+ const entry: CacheEntry = {
285
+ toolVersion: toolVersion(),
286
+ hash: fp.hash,
287
+ files: fp.files,
288
+ violations,
289
+ scannedAt: Date.now(),
290
+ }
291
+ await mkdir(dir, { recursive: true })
292
+ await writeFile(cachePath(key), JSON.stringify(entry))
293
+ }
294
+
295
+ async function runJob(job: Job): Promise<void> {
296
+ let status: A11yScanStatus
297
+ try {
298
+ const d = await ensureDriver()
299
+ // `dcscan` asks the server for a render doc WITHOUT the live-reload SSE —
300
+ // the driver waits for network idle, which an open SSE would never reach.
301
+ const url = `${baseUrl()}/render/${job.componentId}/${job.caseId}?theme=${job.theme}&dcscan=1`
302
+ const page = await d.open(url, {
303
+ componentId: job.componentId,
304
+ caseId: job.caseId,
305
+ theme: job.theme,
306
+ width: 1024,
307
+ })
308
+ try {
309
+ const violations = await page.audit({ exclude: config.a11y?.exclude })
310
+ await writeCache(job.componentId, job.key, violations)
311
+ status = { status: 'ok', violations }
312
+ } finally {
313
+ await page.dispose()
314
+ }
315
+ } catch (err) {
316
+ status = {
317
+ status: 'unavailable',
318
+ reason:
319
+ unavailableReason ??
320
+ (err instanceof Error ? err.message : String(err)),
321
+ }
322
+ }
323
+ inFlight.delete(job.key)
324
+ onResult(job.componentId, job.caseId, job.theme, status)
325
+ }
326
+
327
+ async function pump(): Promise<void> {
328
+ if (pumping) return
329
+ pumping = true
330
+ try {
331
+ while (queue.length) {
332
+ const job = queue.shift()
333
+ if (job) await runJob(job)
334
+ }
335
+ } finally {
336
+ pumping = false
337
+ }
338
+ }
339
+
340
+ return {
341
+ async request(componentId, caseId, theme, force) {
342
+ if (unavailableReason) {
343
+ return { status: 'unavailable', reason: unavailableReason }
344
+ }
345
+ const key = `${componentId}__${caseId}__${theme}`
346
+ if (inFlight.has(key)) return { status: 'pending' }
347
+ if (force) {
348
+ // Re-scan: drop the cached entry so the queued job recomputes it.
349
+ await rm(cachePath(key), { force: true })
350
+ } else {
351
+ const cached = await cachedViolations(componentId, key)
352
+ if (cached) return { status: 'ok', violations: cached }
353
+ }
354
+ inFlight.add(key)
355
+ queue.push({ componentId, caseId, theme, key })
356
+ void pump()
357
+ return { status: 'pending' }
358
+ },
359
+ async populateAtStartup(variants, mode) {
360
+ if (mode !== 'cached' && mode !== 'refresh') return
361
+ // Already known-unavailable (e.g. a prior request probed the driver): emit
362
+ // nothing — the per-variant unavailable state still shows on view.
363
+ if (unavailableReason) return
364
+
365
+ // Emit every reusable cached result up front (both modes), collecting the
366
+ // misses so `refresh` can scan them. `cached` mode stops after this.
367
+ const toScan: Job[] = []
368
+ for (const v of variants) {
369
+ const key = `${v.componentId}__${v.caseId}__${v.theme}`
370
+ if (inFlight.has(key)) continue
371
+ const cached = await cachedViolations(v.componentId, key)
372
+ if (cached) {
373
+ onResult(v.componentId, v.caseId, v.theme, {
374
+ status: 'ok',
375
+ violations: cached,
376
+ })
377
+ } else if (mode === 'refresh') {
378
+ toScan.push({ ...v, key })
379
+ }
380
+ }
381
+ if (mode !== 'refresh' || !toScan.length) return
382
+
383
+ // Probe the scan prerequisite once before flooding the queue: if the
384
+ // browser can't launch, surface nothing at start-up rather than a burst of
385
+ // `unavailable` events (the on-demand path still reports it on view).
386
+ try {
387
+ await ensureDriver()
388
+ } catch {
389
+ return
390
+ }
391
+ for (const job of toScan) {
392
+ if (inFlight.has(job.key)) continue
393
+ inFlight.add(job.key)
394
+ queue.push(job)
395
+ }
396
+ void pump()
397
+ },
398
+ invalidateAll() {
399
+ // In-flight jobs finish; nothing else to drop — the on-disk hashes decide
400
+ // whether the next request re-scans.
401
+ inFlight.clear()
402
+ },
403
+ async close() {
404
+ queue.length = 0
405
+ if (driver) await driver.close().catch(() => {})
406
+ driver = null
407
+ driverPromise = null
408
+ },
409
+ }
410
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { blankComments } from './check-text'
3
+
4
+ describe('blankComments', () => {
5
+ test('blanks a JS line comment to spaces, preserving length and the newline', () => {
6
+ expect(blankComments('x//c\ny', false)).toBe('x \ny')
7
+ })
8
+
9
+ test('blanks a block comment in both JS and CSS', () => {
10
+ expect(blankComments('a/*c*/b', false)).toBe('a b')
11
+ expect(blankComments('a/*c*/b', true)).toBe('a b')
12
+ })
13
+
14
+ test('a multi-line block comment keeps its newlines (line numbers stay put)', () => {
15
+ expect(blankComments('a/*\n*/b', false)).toBe('a \n b')
16
+ })
17
+
18
+ test('CSS has no line comments: `//` is left untouched', () => {
19
+ expect(blankComments('a//b', true)).toBe('a//b')
20
+ })
21
+
22
+ test('string contents are preserved verbatim (JS)', () => {
23
+ // `//` and `/*` inside a string are data, not comments.
24
+ expect(blankComments("a='//b'", false)).toBe("a='//b'")
25
+ expect(blankComments("c:'/*x*/'", false)).toBe("c:'/*x*/'")
26
+ })
27
+
28
+ test('a token reference inside a string survives', () => {
29
+ expect(blankComments("color:'var(--x)'", false)).toBe("color:'var(--x)'")
30
+ })
31
+
32
+ test('all three JS string delimiters are honored', () => {
33
+ expect(blankComments('`a//b`', false)).toBe('`a//b`')
34
+ expect(blankComments('"a//b"', false)).toBe('"a//b"')
35
+ expect(blankComments("'a//b'", false)).toBe("'a//b'")
36
+ })
37
+
38
+ test('an escaped quote does not end the string early', () => {
39
+ // The \' is part of the string, so the trailing // is still inside it.
40
+ expect(blankComments("'a\\'//b'", false)).toBe("'a\\'//b'")
41
+ })
42
+
43
+ test('output length always equals input length', () => {
44
+ const src = "const u = 'https://x' // note\n/* blk */ fn(arg)"
45
+ expect(blankComments(src, false)).toHaveLength(src.length)
46
+ })
47
+
48
+ test('motivating case: a code token named in a comment is erased, real usage kept', () => {
49
+ const out = blankComments('// <Demo> in prose\n<Demo/>', false)
50
+ expect(out).not.toContain('<Demo>')
51
+ expect(out).toContain('<Demo/>')
52
+ })
53
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared text utilities for the static checks (tokens, structure …).
3
+ *
4
+ * These operate on raw source so the checks can scan for patterns — token
5
+ * references, JSX usages — without a full parser, while ignoring text that only
6
+ * *looks* like code (comments) and respecting text that must be read literally
7
+ * (string contents).
8
+ */
9
+
10
+ /**
11
+ * Replace comment characters with spaces, preserving every offset and newline so
12
+ * reported line/column numbers (and any index-based scanning) stay accurate.
13
+ * String literals are copied verbatim because `var(--x)` inside a JS string
14
+ * (`color: 'var(--x)'`) is a real reference, and `//` inside a string
15
+ * (`'https://…'`) must not read as a comment.
16
+ *
17
+ * @param isCss When true, only `/* … *\/` block comments are stripped (CSS has
18
+ * no `//` line comments and no string-delimited `//`); when false, JS/TSX
19
+ * `//` line comments and `"`/`'`/`` ` `` strings are handled too.
20
+ */
21
+ export function blankComments(text: string, isCss: boolean): string {
22
+ let out = ''
23
+ let inBlock = false
24
+ let inLine = false
25
+ let str: string | null = null
26
+ for (let i = 0; i < text.length; i++) {
27
+ const c = text[i]
28
+ const c2 = text[i + 1]
29
+ if (inBlock) {
30
+ if (c === '*' && c2 === '/') {
31
+ out += ' '
32
+ i++
33
+ inBlock = false
34
+ } else {
35
+ out += c === '\n' ? '\n' : ' '
36
+ }
37
+ continue
38
+ }
39
+ if (inLine) {
40
+ if (c === '\n') {
41
+ out += '\n'
42
+ inLine = false
43
+ } else {
44
+ out += ' '
45
+ }
46
+ continue
47
+ }
48
+ if (str) {
49
+ out += c
50
+ if (c === '\\') {
51
+ out += c2 ?? ''
52
+ i++
53
+ } else if (c === str) {
54
+ str = null
55
+ }
56
+ continue
57
+ }
58
+ if (c === '/' && c2 === '*') {
59
+ out += ' '
60
+ i++
61
+ inBlock = true
62
+ continue
63
+ }
64
+ if (!isCss && c === '/' && c2 === '/') {
65
+ out += ' '
66
+ i++
67
+ inLine = true
68
+ continue
69
+ }
70
+ if (!isCss && (c === '"' || c === "'" || c === '`')) {
71
+ str = c
72
+ out += c
73
+ continue
74
+ }
75
+ out += c
76
+ }
77
+ return out
78
+ }