@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.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- 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
|
+
}
|