@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,1039 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
3
|
+
import { join, relative, resolve } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
type A11yScanner,
|
|
6
|
+
type A11yScanStatus,
|
|
7
|
+
createA11yScanner,
|
|
8
|
+
} from '../checks/a11y-scanner'
|
|
9
|
+
import { buildCatalog, slugify } from '../core/catalog'
|
|
10
|
+
import type { LoadedModule } from '../core/discovery'
|
|
11
|
+
import {
|
|
12
|
+
cacheDir,
|
|
13
|
+
codegenPrimerEntry,
|
|
14
|
+
codegenRenderEntry,
|
|
15
|
+
codegenSsrEntry,
|
|
16
|
+
codegenSsrPrimerEntry,
|
|
17
|
+
discoverCaseFiles,
|
|
18
|
+
loadModules,
|
|
19
|
+
resolveConfig,
|
|
20
|
+
} from '../core/discovery'
|
|
21
|
+
import type { Manifest } from '../core/manifest'
|
|
22
|
+
import { mdxPlugin } from '../core/mdx-plugin'
|
|
23
|
+
import type { DisplayCaseConfig } from '../index'
|
|
24
|
+
import type { PrimerHtmlResult } from '../render/ssr-primer'
|
|
25
|
+
import type { CaseRenderer } from '../render/ssr-render'
|
|
26
|
+
import { renderShellToHtml } from '../render/ssr-shell'
|
|
27
|
+
import type { Theme } from '../ui/shell-core'
|
|
28
|
+
|
|
29
|
+
const HERE = resolve(import.meta.dir, '..')
|
|
30
|
+
const BROWSER_ENTRY = join(HERE, 'ui', 'browser-entry.tsx')
|
|
31
|
+
const CHROME_CSS = join(HERE, 'ui', 'chrome.css')
|
|
32
|
+
const CLI = join(HERE, 'cli.ts')
|
|
33
|
+
|
|
34
|
+
// The package's own design system — "The Vitrine". Display Case dogfoods it:
|
|
35
|
+
// the browse chrome is styled entirely from these `--dc-*` tokens. The token
|
|
36
|
+
// files are inlined (in @import order, fonts excluded) ahead of chrome.css; the
|
|
37
|
+
// webfonts load via the <link>s below so the declaration leads the document and
|
|
38
|
+
// never fights a consumer's stylesheet @imports. See ui/design-system/.
|
|
39
|
+
const DS_DIR = join(HERE, 'ui', 'design-system', 'tokens')
|
|
40
|
+
const DS_TOKEN_FILES = ['colors.css', 'typography.css', 'spacing.css']
|
|
41
|
+
const FONT_LINKS =
|
|
42
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com"/>' +
|
|
43
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>' +
|
|
44
|
+
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"/>'
|
|
45
|
+
|
|
46
|
+
async function readDesignTokens(): Promise<string> {
|
|
47
|
+
const parts = await Promise.all(
|
|
48
|
+
DS_TOKEN_FILES.map((f) => Bun.file(join(DS_DIR, f)).text()),
|
|
49
|
+
)
|
|
50
|
+
return parts.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The Vitrine's own chrome stylesheet, assembled by reading and concatenating
|
|
54
|
+
// (in path-sorted order) the shell layout (chrome.css), every design-system
|
|
55
|
+
// component's co-located CSS, and the primer chrome's CSS. The design-system
|
|
56
|
+
// components no longer inject their CSS at runtime; this blob is inlined into
|
|
57
|
+
// every document head so the chrome paints before scripts run. Mirrors
|
|
58
|
+
// readDesignTokens (read N files, join) — no bundler step, no JS-graph import.
|
|
59
|
+
const COMPONENTS_DIR = join(HERE, 'ui', 'design-system', 'components')
|
|
60
|
+
const PRIMER_CSS = join(HERE, 'ui', 'primer.css')
|
|
61
|
+
|
|
62
|
+
async function readVitrineCss(): Promise<string> {
|
|
63
|
+
const componentFiles: string[] = []
|
|
64
|
+
for await (const f of new Bun.Glob('**/*.css').scan({
|
|
65
|
+
cwd: COMPONENTS_DIR,
|
|
66
|
+
absolute: true,
|
|
67
|
+
})) {
|
|
68
|
+
componentFiles.push(f)
|
|
69
|
+
}
|
|
70
|
+
componentFiles.sort()
|
|
71
|
+
const files = [CHROME_CSS, ...componentFiles]
|
|
72
|
+
if (existsSync(PRIMER_CSS)) files.push(PRIMER_CSS)
|
|
73
|
+
const parts = await Promise.all(files.map((f) => Bun.file(f).text()))
|
|
74
|
+
return parts.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Walk up from this package to find the repo root (nearest dir with `.git`). */
|
|
78
|
+
function findRepoRoot(): string {
|
|
79
|
+
let dir = HERE
|
|
80
|
+
for (let i = 0; i < 12; i++) {
|
|
81
|
+
if (existsSync(join(dir, '.git'))) return dir
|
|
82
|
+
const parent = resolve(dir, '..')
|
|
83
|
+
if (parent === dir) break
|
|
84
|
+
dir = parent
|
|
85
|
+
}
|
|
86
|
+
return process.cwd()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const REPO_ROOT = findRepoRoot()
|
|
90
|
+
|
|
91
|
+
interface BuiltState {
|
|
92
|
+
manifest: Manifest
|
|
93
|
+
/** component id → absolute placard-doc path (only present when a doc exists). */
|
|
94
|
+
placardById: Map<string, string>
|
|
95
|
+
/** Concatenated consumer global stylesheet contents. */
|
|
96
|
+
globalCss: string
|
|
97
|
+
/** Pre-render a case to markup for the isolated `/render` document. Rebuilt
|
|
98
|
+
* fresh each rebuild so its case modules track edits (see `rebuild`). */
|
|
99
|
+
renderCase: CaseRenderer
|
|
100
|
+
/** Pre-render the primer to markup, or null when no primer is configured. */
|
|
101
|
+
renderPrimer: (() => PrimerHtmlResult) | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Monotonic suffix for the SSR bundle's filename. Bun caches `import()` by
|
|
105
|
+
// resolved path and ignores `?v=` busting, so each rebuild must write — and
|
|
106
|
+
// import — a uniquely-named bundle to pick up edited case source. (The browser
|
|
107
|
+
// render bundle is always fresh because `Bun.build` re-reads from disk; this is
|
|
108
|
+
// the in-process import equivalent of that freshness.)
|
|
109
|
+
let ssrBuildSeq = 0
|
|
110
|
+
|
|
111
|
+
function relPath(p: string): string {
|
|
112
|
+
return relative(REPO_ROOT, p)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Absolute path of the configured primer `.mdx`, or null if none/missing. */
|
|
116
|
+
function primerFile(pkgDir: string, config: DisplayCaseConfig): string | null {
|
|
117
|
+
if (!config.primer) return null
|
|
118
|
+
const abs = resolve(pkgDir, config.primer)
|
|
119
|
+
return existsSync(abs) ? abs : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildManifest(
|
|
123
|
+
modules: LoadedModule[],
|
|
124
|
+
config: DisplayCaseConfig,
|
|
125
|
+
hasPrimer: boolean,
|
|
126
|
+
): { manifest: Manifest; placardById: Map<string, string> } {
|
|
127
|
+
const fileByComponent = new Map(
|
|
128
|
+
modules.map((m) => [m.module.component, m.file]),
|
|
129
|
+
)
|
|
130
|
+
const placardById = new Map<string, string>()
|
|
131
|
+
const catalog = buildCatalog(modules.map((m) => m.module))
|
|
132
|
+
|
|
133
|
+
const components = catalog.map((c) => {
|
|
134
|
+
const file = fileByComponent.get(c.name) as string
|
|
135
|
+
const placardAbs = file.replace(/\.case\.tsx?$/, '.placard.md')
|
|
136
|
+
const hasDoc = existsSync(placardAbs)
|
|
137
|
+
if (hasDoc) placardById.set(c.id, placardAbs)
|
|
138
|
+
return {
|
|
139
|
+
id: c.id,
|
|
140
|
+
name: c.name,
|
|
141
|
+
level: c.level,
|
|
142
|
+
isFlow: c.isFlow,
|
|
143
|
+
caseFile: relPath(file),
|
|
144
|
+
placardDoc: hasDoc ? relPath(placardAbs) : null,
|
|
145
|
+
cases: c.cases.map((cs) => ({
|
|
146
|
+
id: cs.id,
|
|
147
|
+
name: cs.name,
|
|
148
|
+
browseUrl: `/c/${c.id}/${cs.id}`,
|
|
149
|
+
renderUrl: `/render/${c.id}/${cs.id}`,
|
|
150
|
+
tweaks: cs.tweaks,
|
|
151
|
+
transitions: cs.transitions,
|
|
152
|
+
})),
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Land on the Primer by default when one exists; `landing: 'cases'` opts the
|
|
157
|
+
// root view back to the library even with a Primer configured. With no
|
|
158
|
+
// Primer there's only the library to land on.
|
|
159
|
+
const landing = hasPrimer && config.landing !== 'cases' ? 'primer' : 'library'
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
manifest: { title: config.title, components, primer: hasPrimer, landing },
|
|
163
|
+
placardById,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readGlobalCss(
|
|
168
|
+
pkgDir: string,
|
|
169
|
+
config: DisplayCaseConfig,
|
|
170
|
+
): Promise<string> {
|
|
171
|
+
const parts: string[] = []
|
|
172
|
+
for (const rel of config.globalStyles ?? []) {
|
|
173
|
+
const abs = resolve(pkgDir, rel)
|
|
174
|
+
if (await Bun.file(abs).exists()) parts.push(await Bun.file(abs).text())
|
|
175
|
+
}
|
|
176
|
+
return parts.join('\n')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the manifest in a fresh subprocess. Bun caches ES modules by resolved
|
|
181
|
+
* path for the life of a process (a `?v=` query does not bust it), so an
|
|
182
|
+
* in-process re-import after an edit would return the stale module — the
|
|
183
|
+
* manifest shape (case order/names, level, tweak schema) would never update on
|
|
184
|
+
* a watch rebuild. Spawning `--print-manifest` gives a clean module graph each
|
|
185
|
+
* time; the child's stderr (load errors) is relayed to ours.
|
|
186
|
+
*/
|
|
187
|
+
async function loadManifestFresh(pkgDir: string): Promise<Manifest> {
|
|
188
|
+
const proc = Bun.spawn(['bun', CLI, pkgDir, '--print-manifest'], {
|
|
189
|
+
stdout: 'pipe',
|
|
190
|
+
stderr: 'pipe',
|
|
191
|
+
})
|
|
192
|
+
const [out, err, code] = await Promise.all([
|
|
193
|
+
new Response(proc.stdout).text(),
|
|
194
|
+
new Response(proc.stderr).text(),
|
|
195
|
+
proc.exited,
|
|
196
|
+
])
|
|
197
|
+
if (err.trim()) process.stderr.write(err.endsWith('\n') ? err : `${err}\n`)
|
|
198
|
+
if (code !== 0) {
|
|
199
|
+
throw new Error(`manifest build subprocess exited with code ${code}`)
|
|
200
|
+
}
|
|
201
|
+
return JSON.parse(out) as Manifest
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Discover, codegen, bundle, and assemble the served state. */
|
|
205
|
+
async function rebuild(
|
|
206
|
+
pkgDir: string,
|
|
207
|
+
config: DisplayCaseConfig,
|
|
208
|
+
configPath: string,
|
|
209
|
+
): Promise<BuiltState> {
|
|
210
|
+
const files = await discoverCaseFiles(pkgDir, config)
|
|
211
|
+
|
|
212
|
+
const renderEntry = await codegenRenderEntry(pkgDir, files, configPath)
|
|
213
|
+
const outdir = join(cacheDir(pkgDir), 'dist')
|
|
214
|
+
// The Primer is its own isolated document (like /render), so it's a separate
|
|
215
|
+
// bundle entry — keeping the consumer's `.mdx` and the arbitrary components it
|
|
216
|
+
// imports out of the browse chrome's bundle.
|
|
217
|
+
const primerSrc = primerFile(pkgDir, config)
|
|
218
|
+
const primerEntry = primerSrc
|
|
219
|
+
? await codegenPrimerEntry(pkgDir, config.primer as string)
|
|
220
|
+
: null
|
|
221
|
+
const entrypoints = [BROWSER_ENTRY, renderEntry]
|
|
222
|
+
if (primerEntry) entrypoints.push(primerEntry)
|
|
223
|
+
const result = await Bun.build({
|
|
224
|
+
entrypoints,
|
|
225
|
+
outdir,
|
|
226
|
+
target: 'browser',
|
|
227
|
+
// The MDX plugin compiles the primer's `.mdx` (and any `.mdx` it imports)
|
|
228
|
+
// to JS on load; it's a no-op for builds without a primer entry.
|
|
229
|
+
plugins: [mdxPlugin()],
|
|
230
|
+
// Inline the consumer's public env (BUN_PUBLIC_*) so a `process.env.*` read
|
|
231
|
+
// in bundled code (e.g. the API base URL) doesn't survive as a literal that
|
|
232
|
+
// throws `process is not defined` in the browser. See publicEnvDefines.
|
|
233
|
+
define: await publicEnvDefines(pkgDir),
|
|
234
|
+
naming: {
|
|
235
|
+
entry: '[name].[ext]',
|
|
236
|
+
chunk: '[name]-[hash].[ext]',
|
|
237
|
+
asset: '[name]-[hash].[ext]',
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
if (!result.success) {
|
|
241
|
+
for (const log of result.logs) console.error(log)
|
|
242
|
+
throw new Error('Display Case bundle failed')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Pre-render bundle: the same case import list as the browser render bundle,
|
|
246
|
+
// but built for Bun and imported in-process so the server can render a case to
|
|
247
|
+
// markup before delivering its document. Built to a fresh, sequence-named file
|
|
248
|
+
// each rebuild because Bun caches imports by resolved path — a stable name
|
|
249
|
+
// would return the stale renderer after an edit (the same staleness that forces
|
|
250
|
+
// the manifest into a subprocess). The bundle inlines case source from disk, so
|
|
251
|
+
// importing the fresh file yields current modules. React stays external so the
|
|
252
|
+
// server resolves it from node_modules at import time.
|
|
253
|
+
const ssrEntry = await codegenSsrEntry(pkgDir, files, configPath)
|
|
254
|
+
const ssrOutDir = join(cacheDir(pkgDir), 'ssr')
|
|
255
|
+
const ssrName = `ssr-entry-${++ssrBuildSeq}`
|
|
256
|
+
const ssrResult = await Bun.build({
|
|
257
|
+
entrypoints: [ssrEntry],
|
|
258
|
+
outdir: ssrOutDir,
|
|
259
|
+
target: 'bun',
|
|
260
|
+
plugins: [mdxPlugin()],
|
|
261
|
+
define: await publicEnvDefines(pkgDir),
|
|
262
|
+
external: [
|
|
263
|
+
'react',
|
|
264
|
+
'react-dom',
|
|
265
|
+
'react-dom/server',
|
|
266
|
+
'react/jsx-runtime',
|
|
267
|
+
'react/jsx-dev-runtime',
|
|
268
|
+
],
|
|
269
|
+
naming: {
|
|
270
|
+
entry: `${ssrName}.[ext]`,
|
|
271
|
+
chunk: '[name]-[hash].[ext]',
|
|
272
|
+
asset: '[name]-[hash].[ext]',
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
if (!ssrResult.success) {
|
|
276
|
+
for (const log of ssrResult.logs) console.error(log)
|
|
277
|
+
throw new Error('Display Case SSR bundle failed')
|
|
278
|
+
}
|
|
279
|
+
const ssrModule = (await import(join(ssrOutDir, `${ssrName}.js`))) as {
|
|
280
|
+
renderCaseToHtml: CaseRenderer
|
|
281
|
+
}
|
|
282
|
+
const renderCase = ssrModule.renderCaseToHtml
|
|
283
|
+
|
|
284
|
+
// Pre-render bundle for the primer, built and imported the same way (and only
|
|
285
|
+
// when a primer is configured). Its specimens are real consumer components, so
|
|
286
|
+
// — like a case — one may be browser-only; the renderer reports that and the
|
|
287
|
+
// server falls back to client-rendering the whole primer.
|
|
288
|
+
let renderPrimer: (() => PrimerHtmlResult) | null = null
|
|
289
|
+
if (primerSrc) {
|
|
290
|
+
const ssrPrimerEntry = await codegenSsrPrimerEntry(
|
|
291
|
+
pkgDir,
|
|
292
|
+
config.primer as string,
|
|
293
|
+
configPath,
|
|
294
|
+
)
|
|
295
|
+
const ssrPrimerName = `ssr-primer-entry-${ssrBuildSeq}`
|
|
296
|
+
const ssrPrimerResult = await Bun.build({
|
|
297
|
+
entrypoints: [ssrPrimerEntry],
|
|
298
|
+
outdir: ssrOutDir,
|
|
299
|
+
target: 'bun',
|
|
300
|
+
plugins: [mdxPlugin()],
|
|
301
|
+
define: await publicEnvDefines(pkgDir),
|
|
302
|
+
external: [
|
|
303
|
+
'react',
|
|
304
|
+
'react-dom',
|
|
305
|
+
'react-dom/server',
|
|
306
|
+
'react/jsx-runtime',
|
|
307
|
+
'react/jsx-dev-runtime',
|
|
308
|
+
],
|
|
309
|
+
naming: {
|
|
310
|
+
entry: `${ssrPrimerName}.[ext]`,
|
|
311
|
+
chunk: '[name]-[hash].[ext]',
|
|
312
|
+
asset: '[name]-[hash].[ext]',
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
if (!ssrPrimerResult.success) {
|
|
316
|
+
for (const log of ssrPrimerResult.logs) console.error(log)
|
|
317
|
+
throw new Error('Display Case SSR primer bundle failed')
|
|
318
|
+
}
|
|
319
|
+
const ssrPrimerModule = (await import(
|
|
320
|
+
join(ssrOutDir, `${ssrPrimerName}.js`)
|
|
321
|
+
)) as { renderPrimerToHtml: () => PrimerHtmlResult }
|
|
322
|
+
renderPrimer = ssrPrimerModule.renderPrimerToHtml
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The render bundle above is rebuilt fresh from disk by Bun.build; the
|
|
326
|
+
// manifest comes from a fresh subprocess for the same reason (see above).
|
|
327
|
+
const manifest = await loadManifestFresh(pkgDir)
|
|
328
|
+
const placardById = new Map<string, string>()
|
|
329
|
+
for (const c of manifest.components) {
|
|
330
|
+
if (c.placardDoc) placardById.set(c.id, resolve(REPO_ROOT, c.placardDoc))
|
|
331
|
+
}
|
|
332
|
+
const globalCss = await readGlobalCss(pkgDir, config)
|
|
333
|
+
console.log(
|
|
334
|
+
` ${manifest.components.length} component(s), ${manifest.components.reduce((n, c) => n + c.cases.length, 0)} case(s)`,
|
|
335
|
+
)
|
|
336
|
+
return { manifest, placardById, globalCss, renderCase, renderPrimer }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A classic (non-module) inline script that runs *before* the deferred module
|
|
341
|
+
* bundle. If a bundled module references a Node/Bun runtime global that is
|
|
342
|
+
* undefined in the browser (`process`/`Bun`), it throws during module
|
|
343
|
+
* evaluation — before React mounts, and before any error boundary or module-level
|
|
344
|
+
* handler exists — so every case would otherwise blank *silently*. This catches
|
|
345
|
+
* that uncaught error and paints a visible, explained banner instead. The same
|
|
346
|
+
* impurity is caught statically by the `page-component-purity` lint; this is the
|
|
347
|
+
* runtime backstop for anything that slips through (or a non-app package).
|
|
348
|
+
*/
|
|
349
|
+
const ERROR_OVERLAY_SCRIPT = `<script>
|
|
350
|
+
window.addEventListener('error', function (e) {
|
|
351
|
+
var m = (e && e.message) || '';
|
|
352
|
+
if (!/\\b(process|Bun) is not defined\\b/.test(m)) return;
|
|
353
|
+
var root = document.getElementById('root');
|
|
354
|
+
if (root && !root.firstChild) {
|
|
355
|
+
root.innerHTML =
|
|
356
|
+
'<div style="margin:2rem;padding:1rem 1.25rem;border:1px solid #c00;border-radius:8px;font-family:ui-monospace,monospace;font-size:13px;line-height:1.5;color:#c00;background:#fff5f5">' +
|
|
357
|
+
'<strong>Display Case bundle error</strong><br>' +
|
|
358
|
+
'A showcased component (or a module it imports) references <code>' + m.replace(/ is not defined.*/, '') + '</code>, ' +
|
|
359
|
+
'which is undefined in the browser bundle. It threw on load, which blanks every case.<br>' +
|
|
360
|
+
'Read env/runtime values in the route (or a config module) and pass them in as props. (' + m + ')' +
|
|
361
|
+
'</div>';
|
|
362
|
+
}
|
|
363
|
+
console.error('[display-case] runtime-global reference broke the bundle:', m);
|
|
364
|
+
});
|
|
365
|
+
</script>`
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Dev-only live-reload client. Subscribes to the `/__livereload` SSE stream and
|
|
369
|
+
* reloads the page on a `reload` event (an in-process rebuild after editing the
|
|
370
|
+
* Display Case app — chrome, components, primer). It also reloads when the
|
|
371
|
+
* stream *reconnects* after dropping, which is how it picks up a backend change:
|
|
372
|
+
* `bun --watch` restarts the server process, the stream errors, and the reload
|
|
373
|
+
* fires on the fresh connection. Injected only when the server runs with `dev`.
|
|
374
|
+
*/
|
|
375
|
+
const LIVERELOAD_SCRIPT = `<script>
|
|
376
|
+
(function () {
|
|
377
|
+
var seen = false;
|
|
378
|
+
function connect() {
|
|
379
|
+
var es = new EventSource('/__livereload');
|
|
380
|
+
es.onopen = function () { if (seen) location.reload(); seen = true; };
|
|
381
|
+
es.addEventListener('reload', function () { location.reload(); });
|
|
382
|
+
es.onerror = function () { es.close(); setTimeout(connect, 400); };
|
|
383
|
+
}
|
|
384
|
+
connect();
|
|
385
|
+
})();
|
|
386
|
+
</script>`
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Runtime config the browse chrome reads to wire its own event stream: whether
|
|
390
|
+
* live reload is on (so it refetches the manifest + reloads the iframe on a
|
|
391
|
+
* rebuild — in non-dev, where there's no inline full-page reload), and whether
|
|
392
|
+
* a11y surfacing is configured (so it requests + receives scan results).
|
|
393
|
+
*/
|
|
394
|
+
function clientConfigScript(cfg: {
|
|
395
|
+
reload: boolean
|
|
396
|
+
a11y: boolean
|
|
397
|
+
dev: boolean
|
|
398
|
+
}): string {
|
|
399
|
+
return `<script>window.__displayCase=${JSON.stringify(cfg)}</script>`
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function shellHtml(
|
|
403
|
+
title: string,
|
|
404
|
+
globalCss: string,
|
|
405
|
+
vitrineCss: string,
|
|
406
|
+
tokensCss: string,
|
|
407
|
+
liveReload: boolean,
|
|
408
|
+
clientConfig: string,
|
|
409
|
+
doc: { theme: Theme; markup: string; ssr: boolean; seedScript: string },
|
|
410
|
+
): string {
|
|
411
|
+
// Reset html/body and paint the themed surface on them. The theme is baked
|
|
412
|
+
// into <html> so the token background reaches the body edges from first paint
|
|
413
|
+
// (and the client's hydration finds a matching theme); the shell still tracks
|
|
414
|
+
// later theme toggles on the client. Background is the chrome's own `--dc-bg`
|
|
415
|
+
// (the Vitrine canvas), not a consumer token. Design-system tokens lead the
|
|
416
|
+
// <style> so chrome.css (last) can rely on them; the consumer's globalCss
|
|
417
|
+
// styles the isolated exhibit. `data-ssr` tells the client whether to adopt
|
|
418
|
+
// the rendered shell (1) or mount fresh (0). The seed (manifest/theme/a11y)
|
|
419
|
+
// is inlined before the module so the client hydrates from the same data.
|
|
420
|
+
const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
|
|
421
|
+
return `<!doctype html><html lang="en" data-theme="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${FONT_LINKS}<style>${tokensCss}\n${globalCss}\n${reset}\n${vitrineCss}</style></head><body><div id="root" data-ssr="${doc.ssr ? '1' : '0'}">${doc.markup}</div>${ERROR_OVERLAY_SCRIPT}${doc.seedScript}${clientConfig}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/browser-entry.js"></script></body></html>`
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The document-level state the render template bakes in, mirroring what the
|
|
425
|
+
* client otherwise sets imperatively on load (theme, the decorated/transparent
|
|
426
|
+
* surface, the fit-to-content mount), plus the pre-rendered case markup. */
|
|
427
|
+
interface RenderDoc {
|
|
428
|
+
theme: 'light' | 'dark'
|
|
429
|
+
/** Drop the document background (decorated exhibit on the stage grid). */
|
|
430
|
+
transparent: boolean
|
|
431
|
+
/** Shrink-wrap the mount to the case's natural width. */
|
|
432
|
+
fit: boolean
|
|
433
|
+
/** Pre-rendered `#root` inner markup (`''` for a browser-only case). */
|
|
434
|
+
markup: string
|
|
435
|
+
/** Whether `markup` is present, so the client adopts instead of mounting. */
|
|
436
|
+
ssr: boolean
|
|
437
|
+
/** Render-time (CSS-in-JS) styling collected by the style engines, as `<head>`
|
|
438
|
+
* markup placed after the static `<style>` block. `''` when none. */
|
|
439
|
+
headStyles?: string
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The render state the server decodes from a `/render/...` address — the same
|
|
443
|
+
* shape `render-mount`'s `stateFromUrl` reads on the client, so the server's
|
|
444
|
+
* initial render and the client's hydration agree. */
|
|
445
|
+
interface ParsedRenderState extends RenderDoc {
|
|
446
|
+
componentId: string
|
|
447
|
+
caseId: string
|
|
448
|
+
width: number | null
|
|
449
|
+
tweaks: Record<string, string>
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseRenderState(url: URL): ParsedRenderState {
|
|
453
|
+
const parts = url.pathname.split('/').filter(Boolean) // ['render', comp, case]
|
|
454
|
+
const p = url.searchParams
|
|
455
|
+
const tweaks: Record<string, string> = {}
|
|
456
|
+
for (const [k, v] of p) if (k.startsWith('t.')) tweaks[k.slice(2)] = v
|
|
457
|
+
const widthParam = p.get('width')
|
|
458
|
+
return {
|
|
459
|
+
componentId: parts[1] ?? '',
|
|
460
|
+
caseId: parts[2] ?? '',
|
|
461
|
+
theme: p.get('theme') === 'dark' ? 'dark' : 'light',
|
|
462
|
+
width: widthParam ? Number(widthParam) : null,
|
|
463
|
+
tweaks,
|
|
464
|
+
fit: p.get('fit') === '1',
|
|
465
|
+
transparent: p.get('transparent') === '1',
|
|
466
|
+
markup: '',
|
|
467
|
+
ssr: false,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderHtml(
|
|
472
|
+
globalCss: string,
|
|
473
|
+
vitrineCss: string,
|
|
474
|
+
liveReload: boolean,
|
|
475
|
+
doc: RenderDoc,
|
|
476
|
+
): string {
|
|
477
|
+
// A complete document (title, lang, single <main> landmark) so the a11y runner
|
|
478
|
+
// reports only real component issues, not isolated-harness chrome violations.
|
|
479
|
+
// The body paints the theme surface (bg + fg) so a case renders on the same
|
|
480
|
+
// background the app gives it — without this, light dark-theme text would sit
|
|
481
|
+
// on a default-white body and fail contrast checks.
|
|
482
|
+
// Decorated exhibits (atoms…templates, marked `data-decorated` by render-mount)
|
|
483
|
+
// center their content in the frame: when the exhibit wraps or is narrower than
|
|
484
|
+
// the frame, its rows sit centered rather than top-left. Inline styles on a
|
|
485
|
+
// case still win, so an author can opt back to `flex-start`. Pages/flows are
|
|
486
|
+
// excluded — they own their full-bleed layout and must not be re-centered.
|
|
487
|
+
const exhibitCenter =
|
|
488
|
+
'body[data-decorated] #root>*{justify-content:center;align-content:center}'
|
|
489
|
+
// The theme, decorated surface, and fit mount are baked in so the first paint
|
|
490
|
+
// is already correct (no flash, and the client's hydration finds a matching
|
|
491
|
+
// tree). The client still sets them on load — idempotent — and updates them on
|
|
492
|
+
// an in-place swap. `data-ssr` tells the client whether to adopt the markup
|
|
493
|
+
// (1) or mount fresh (0, a browser-only case that didn't render server-side).
|
|
494
|
+
const bodyAttrs = doc.transparent
|
|
495
|
+
? ' data-decorated style="background:transparent"'
|
|
496
|
+
: ''
|
|
497
|
+
const rootAttrs = `${doc.fit ? ' style="width:fit-content"' : ''} data-ssr="${doc.ssr ? '1' : '0'}"`
|
|
498
|
+
// The Vitrine stylesheet follows globalCss so a dogfooded design-system case
|
|
499
|
+
// (the showcase's own `dcui-*`/`dcpl-*`/shell components) paints before
|
|
500
|
+
// scripts; its `--dc-*` tokens come from globalCss (the showcase lists the
|
|
501
|
+
// token files in globalStyles). For a non-dogfooding consumer these rules are
|
|
502
|
+
// inert chrome CSS — harmless in this dev-time-only preview document.
|
|
503
|
+
// The style engines' collected styling (if any) follows the static <style>
|
|
504
|
+
// block as its own discrete markup — emotion/styled-components tag their output
|
|
505
|
+
// with attributes the client runtime keys on to adopt it, so it must not be
|
|
506
|
+
// folded into the block above. Empty string when no engine is configured.
|
|
507
|
+
return `<!doctype html><html lang="en" data-theme="${doc.theme}" data-theme-pref="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Display Case render</title><style>html,body{margin:0}body{background:var(--color-bg);color:var(--color-fg);font-family:var(--font-sans, ui-sans-serif, system-ui, sans-serif)}${exhibitCenter}${globalCss}\n${vitrineCss}</style>${doc.headStyles ?? ''}</head><body${bodyAttrs}><main id="root"${rootAttrs}>${doc.markup}</main>${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/render-entry.js"></script></body></html>`
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function primerHtml(
|
|
511
|
+
globalCss: string,
|
|
512
|
+
tokensCss: string,
|
|
513
|
+
vitrineCss: string,
|
|
514
|
+
liveReload: boolean,
|
|
515
|
+
doc: {
|
|
516
|
+
theme: 'light' | 'dark'
|
|
517
|
+
markup: string
|
|
518
|
+
ssr: boolean
|
|
519
|
+
headStyles?: string
|
|
520
|
+
},
|
|
521
|
+
): string {
|
|
522
|
+
// The Primer's own document. It needs the Vitrine `--dc-*` tokens (the
|
|
523
|
+
// reading-page + Display-card chrome paints from them), the consumer's
|
|
524
|
+
// globalCss (the embedded specimens are real consumer components), and the
|
|
525
|
+
// Vitrine stylesheet (the specimen + card chrome CSS, inlined server-side so
|
|
526
|
+
// it paints before scripts). A single <main> landmark keeps the a11y runner
|
|
527
|
+
// honest. The theme is baked into <html> so the first paint is correct; the
|
|
528
|
+
// mount re-applies it (idempotent) and accepts later theme messages.
|
|
529
|
+
// `data-ssr` tells the client whether to adopt the markup or mount fresh.
|
|
530
|
+
const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
|
|
531
|
+
const rootAttrs = ` data-ssr="${doc.ssr ? '1' : '0'}"`
|
|
532
|
+
// Style-engine output follows the static <style> block as discrete markup (see
|
|
533
|
+
// renderHtml). `''` when no engine is configured.
|
|
534
|
+
return `<!doctype html><html lang="en" data-theme="${doc.theme}" data-theme-pref="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Primer</title>${FONT_LINKS}<style>${tokensCss}\n${globalCss}\n${reset}\n${vitrineCss}</style>${doc.headStyles ?? ''}</head><body><main id="root"${rootAttrs}>${doc.markup}</main>${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/primer-entry.js"></script></body></html>`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Build a `Bun.build` `define` map that inlines the consumer's public env
|
|
539
|
+
* (`BUN_PUBLIC_*`) into the browser bundle — the same values the app's own
|
|
540
|
+
* production build inlines (`bun build … --env='BUN_PUBLIC_*'`).
|
|
541
|
+
*
|
|
542
|
+
* Why `define` and not `Bun.build({ env: 'BUN_PUBLIC_*' })`: the `env` option
|
|
543
|
+
* only inlines vars present in the environment Bun snapshotted at *process*
|
|
544
|
+
* startup (plus the CWD-relative `.env` Bun auto-loads). Display Case runs from
|
|
545
|
+
* the repo root, not the consumer package, so a public var defined only in
|
|
546
|
+
* `<pkg>/.env` (e.g. the API base URL) is absent at build time — and mutating
|
|
547
|
+
* `process.env` at runtime does not influence it. Left unreplaced, a
|
|
548
|
+
* `process.env.BUN_PUBLIC_*` read survives as a literal that throws
|
|
549
|
+
* `process is not defined` in the browser, blanking the whole single-bundle
|
|
550
|
+
* showcase (not just the case that reached it). `define` replaces the literal
|
|
551
|
+
* unconditionally, independent of env timing.
|
|
552
|
+
*
|
|
553
|
+
* Scoped strictly to the public prefix so non-public env (secrets, NODE_ENV,
|
|
554
|
+
* ports) never enters the bundle. A real exported `process.env` value wins over
|
|
555
|
+
* the file so local overrides still apply.
|
|
556
|
+
*/
|
|
557
|
+
async function publicEnvDefines(
|
|
558
|
+
pkgDir: string,
|
|
559
|
+
): Promise<Record<string, string>> {
|
|
560
|
+
const values = new Map<string, string>()
|
|
561
|
+
for (const name of ['.env', '.env.local']) {
|
|
562
|
+
const file = Bun.file(join(pkgDir, name))
|
|
563
|
+
if (!(await file.exists())) continue
|
|
564
|
+
for (const raw of (await file.text()).split('\n')) {
|
|
565
|
+
const line = raw.trim()
|
|
566
|
+
if (!line || line.startsWith('#')) continue
|
|
567
|
+
const eq = line.indexOf('=')
|
|
568
|
+
if (eq === -1) continue
|
|
569
|
+
const key = line
|
|
570
|
+
.slice(0, eq)
|
|
571
|
+
.replace(/^export\s+/, '')
|
|
572
|
+
.trim()
|
|
573
|
+
if (!key.startsWith('BUN_PUBLIC_')) continue
|
|
574
|
+
let value = line.slice(eq + 1).trim()
|
|
575
|
+
if (
|
|
576
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
577
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
578
|
+
) {
|
|
579
|
+
value = value.slice(1, -1)
|
|
580
|
+
}
|
|
581
|
+
values.set(key, value)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// A real exported env value overrides the file (local override wins).
|
|
585
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
586
|
+
if (key.startsWith('BUN_PUBLIC_') && value !== undefined) {
|
|
587
|
+
values.set(key, value)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const defines: Record<string, string> = {}
|
|
591
|
+
for (const [key, value] of values) {
|
|
592
|
+
defines[`process.env.${key}`] = JSON.stringify(value)
|
|
593
|
+
}
|
|
594
|
+
return defines
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export interface StartOptions {
|
|
598
|
+
port?: number
|
|
599
|
+
/**
|
|
600
|
+
* Developing Display Case *itself* (not just authoring cases). Enables live
|
|
601
|
+
* reload: the served documents subscribe to an SSE stream, the watcher also
|
|
602
|
+
* covers the app's own source (chrome, components, primer) and re-reads the
|
|
603
|
+
* inlined CSS, and a rebuild pushes a browser reload — so editing the chrome
|
|
604
|
+
* hot-reloads the open page. (We don't run under `bun --watch`: re-invoking
|
|
605
|
+
* `Bun.build` inside a watch process corrupts module resolution. Backend edits
|
|
606
|
+
* to this server need a manual restart; the client auto-reloads on the SSE
|
|
607
|
+
* reconnect that follows.) Off for normal case authoring and for `check`.
|
|
608
|
+
*/
|
|
609
|
+
dev?: boolean
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Probe whether a port is bindable on localhost, without disturbing whatever
|
|
613
|
+
// might currently hold it.
|
|
614
|
+
const isPortFree = (port: number): Promise<boolean> =>
|
|
615
|
+
new Promise((res) => {
|
|
616
|
+
const srv = createServer()
|
|
617
|
+
srv.once('error', () => res(false))
|
|
618
|
+
srv.once('listening', () => srv.close(() => res(true)))
|
|
619
|
+
srv.listen(port, '127.0.0.1')
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// Treat a requested port as *preferred*: if it is busy — e.g. another git
|
|
623
|
+
// worktree is already running Display Case on it — bump to the next free port so
|
|
624
|
+
// concurrent checkouts don't fight over one port. Falls back to the original if
|
|
625
|
+
// nothing nearby is free (Bun.serve then surfaces the bind error).
|
|
626
|
+
const firstFreePort = async (start: number): Promise<number> => {
|
|
627
|
+
for (let p = start; p < start + 100; p++) {
|
|
628
|
+
if (await isPortFree(p)) return p
|
|
629
|
+
}
|
|
630
|
+
return start
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function startDisplayCase(
|
|
634
|
+
pkgDir: string,
|
|
635
|
+
opts: StartOptions = {},
|
|
636
|
+
) {
|
|
637
|
+
const dev = opts.dev ?? false
|
|
638
|
+
// The headless check harness runs on port 0 and wants none of the live-server
|
|
639
|
+
// behaviors (watch, SSE, on-demand a11y) — it does its own one-shot scan.
|
|
640
|
+
const interactive = opts.port !== 0
|
|
641
|
+
const { config, configPath } = await resolveConfig(pkgDir)
|
|
642
|
+
let state = await rebuild(pkgDir, config, configPath)
|
|
643
|
+
// `let` so dev mode can re-read them when the chrome's CSS/tokens change.
|
|
644
|
+
let vitrineCss = await readVitrineCss()
|
|
645
|
+
let tokensCss = await readDesignTokens()
|
|
646
|
+
const outdir = join(cacheDir(pkgDir), 'dist')
|
|
647
|
+
|
|
648
|
+
// Live reload is the default for the interactive server (not just `--dev`): a
|
|
649
|
+
// rebuild reloads the stage iframe and refetches the manifest. In `--dev`
|
|
650
|
+
// (developing the chrome itself) the shell additionally does a full reload.
|
|
651
|
+
const reload = interactive
|
|
652
|
+
|
|
653
|
+
// Cases that threw under `renderToString` (browser-only). Once recorded, the
|
|
654
|
+
// server skips the server-render attempt and serves an adopt-free document
|
|
655
|
+
// that the client mounts. Cleared on rebuild so a fixed case recovers.
|
|
656
|
+
const browserOnly = new Set<string>()
|
|
657
|
+
|
|
658
|
+
// SSE fan-out: open streams (one per browser tab) that a rebuild pushes a
|
|
659
|
+
// `reload` event to, and that completed a11y scans push an `a11y` event to.
|
|
660
|
+
const encoder = new TextEncoder()
|
|
661
|
+
const reloadClients = new Set<ReadableStreamDefaultController>()
|
|
662
|
+
const broadcast = (chunk: Uint8Array) => {
|
|
663
|
+
for (const c of reloadClients) {
|
|
664
|
+
try {
|
|
665
|
+
c.enqueue(chunk)
|
|
666
|
+
} catch {
|
|
667
|
+
reloadClients.delete(c)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// A rebuild reloads the open tabs, but how depends on *what* changed: a change
|
|
672
|
+
// to the shell bundle itself (the chrome — its layout, the design-system
|
|
673
|
+
// components it composes) needs a full page reload, while a change that only
|
|
674
|
+
// affects rendered case/component content can reload just the stage iframe and
|
|
675
|
+
// refetch the manifest, preserving nav state. The event payload tells the
|
|
676
|
+
// client which; we detect a shell change by hashing the browser-entry bundle.
|
|
677
|
+
const shellBundleHash = async (): Promise<string> => {
|
|
678
|
+
const f = Bun.file(join(outdir, 'browser-entry.js'))
|
|
679
|
+
return (await f.exists())
|
|
680
|
+
? Bun.hash(await f.arrayBuffer()).toString(16)
|
|
681
|
+
: ''
|
|
682
|
+
}
|
|
683
|
+
let shellHash = await shellBundleHash()
|
|
684
|
+
const triggerReload = (kind: 'shell' | 'content') =>
|
|
685
|
+
broadcast(encoder.encode(`event: reload\ndata: ${kind}\n\n`))
|
|
686
|
+
|
|
687
|
+
// On-demand a11y scanner (only when configured + interactive). Completed scans
|
|
688
|
+
// are pushed to open browsers over the SSE stream so the panel updates in place.
|
|
689
|
+
let scanner: A11yScanner | null = null
|
|
690
|
+
|
|
691
|
+
// Latest known verdict per `${component}__${case}__${theme}`, recorded as
|
|
692
|
+
// results flow through `onResult`. SSE only reaches tabs open at emit time, so
|
|
693
|
+
// start-up population (and any earlier scan) would be invisible to a tab that
|
|
694
|
+
// connects later; `/a11y/known` replays these so a fresh client seeds its nav.
|
|
695
|
+
const lastA11y = new Map<
|
|
696
|
+
string,
|
|
697
|
+
{
|
|
698
|
+
component: string
|
|
699
|
+
case: string
|
|
700
|
+
theme: 'light' | 'dark'
|
|
701
|
+
} & A11yScanStatus
|
|
702
|
+
>()
|
|
703
|
+
|
|
704
|
+
// Port 0 (the headless check harness) means "let the OS pick" — leave it. Any
|
|
705
|
+
// other port is preferred-not-mandatory, so two worktrees never collide.
|
|
706
|
+
const port = opts.port === 0 ? 0 : await firstFreePort(opts.port ?? 3100)
|
|
707
|
+
|
|
708
|
+
const server = Bun.serve({
|
|
709
|
+
port,
|
|
710
|
+
// The `/__livereload` SSE stream is long-lived; the default 10s idle timeout
|
|
711
|
+
// would close it and churn reconnects. Disable it for the interactive server.
|
|
712
|
+
// (0 = no timeout.) The check harness (non-interactive) keeps the default.
|
|
713
|
+
idleTimeout: interactive ? 0 : 10,
|
|
714
|
+
async fetch(req) {
|
|
715
|
+
const url = new URL(req.url)
|
|
716
|
+
const path = url.pathname
|
|
717
|
+
|
|
718
|
+
if (path === '/health') return new Response('ok')
|
|
719
|
+
|
|
720
|
+
if (interactive && path === '/__livereload') {
|
|
721
|
+
let self: ReadableStreamDefaultController | null = null
|
|
722
|
+
const stream = new ReadableStream({
|
|
723
|
+
start(controller) {
|
|
724
|
+
self = controller
|
|
725
|
+
reloadClients.add(controller)
|
|
726
|
+
controller.enqueue(encoder.encode(': connected\n\n'))
|
|
727
|
+
},
|
|
728
|
+
cancel() {
|
|
729
|
+
if (self) reloadClients.delete(self)
|
|
730
|
+
},
|
|
731
|
+
})
|
|
732
|
+
return new Response(stream, {
|
|
733
|
+
headers: {
|
|
734
|
+
'content-type': 'text/event-stream',
|
|
735
|
+
'cache-control': 'no-cache',
|
|
736
|
+
connection: 'keep-alive',
|
|
737
|
+
},
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (path === '/manifest.json') {
|
|
742
|
+
return Response.json(state.manifest)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Every verdict known so far (start-up population + completed scans), so a
|
|
746
|
+
// client connecting after those SSE events still seeds its nav markers.
|
|
747
|
+
if (scanner && path === '/a11y/known') {
|
|
748
|
+
return Response.json([...lastA11y.values()])
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// On-demand a11y for the viewed variant: cached → result, miss → enqueue a
|
|
752
|
+
// scan and report `pending` (the result later arrives over the SSE stream).
|
|
753
|
+
if (scanner && path === '/a11y') {
|
|
754
|
+
const component = url.searchParams.get('component')
|
|
755
|
+
const caseId = url.searchParams.get('case')
|
|
756
|
+
const theme = url.searchParams.get('theme')
|
|
757
|
+
if (!component || !caseId || (theme !== 'light' && theme !== 'dark')) {
|
|
758
|
+
return new Response('bad request', { status: 400 })
|
|
759
|
+
}
|
|
760
|
+
const force = url.searchParams.get('rescan') === '1'
|
|
761
|
+
return Response.json(
|
|
762
|
+
await scanner.request(component, caseId, theme, force),
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (path.startsWith('/dist/')) {
|
|
767
|
+
const file = Bun.file(join(outdir, path.slice('/dist/'.length)))
|
|
768
|
+
return (await file.exists())
|
|
769
|
+
? new Response(file)
|
|
770
|
+
: new Response('not found', { status: 404 })
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (path.startsWith('/doc/')) {
|
|
774
|
+
const id = path.slice('/doc/'.length)
|
|
775
|
+
const docPath = state.placardById.get(id)
|
|
776
|
+
if (!docPath) return new Response('no doc', { status: 404 })
|
|
777
|
+
return new Response(Bun.file(docPath), {
|
|
778
|
+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
|
|
779
|
+
})
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// The Primer's chrome-free document lives under the reserved
|
|
783
|
+
// `/render/primer` name — a sibling of the other `/render/*` snapshots,
|
|
784
|
+
// not the SPA's `/primer` browse route (handled by the shell fallthrough
|
|
785
|
+
// below). Matched before the generic `/render/*` so it's served as the
|
|
786
|
+
// Primer, never mistaken for a component render of an id `primer`.
|
|
787
|
+
if (path === '/render/primer') {
|
|
788
|
+
const scanning = url.searchParams.has('dcscan')
|
|
789
|
+
const theme =
|
|
790
|
+
url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
|
|
791
|
+
// Pre-render the primer (prose + live specimens) into the document so it
|
|
792
|
+
// reads without scripting. A browser-only specimen makes the renderer
|
|
793
|
+
// report `browserOnly`; the whole primer then falls back to client
|
|
794
|
+
// rendering (delivered empty for the client to mount).
|
|
795
|
+
let markup = ''
|
|
796
|
+
let ssr = false
|
|
797
|
+
let headStyles: string | undefined
|
|
798
|
+
if (state.renderPrimer) {
|
|
799
|
+
const result = state.renderPrimer()
|
|
800
|
+
if (result.browserOnly) {
|
|
801
|
+
console.warn(
|
|
802
|
+
` ⚠ primer can't render server-side (${result.error ?? 'threw'}); the client will render it`,
|
|
803
|
+
)
|
|
804
|
+
} else {
|
|
805
|
+
markup = result.html
|
|
806
|
+
ssr = true
|
|
807
|
+
headStyles = result.headStyles
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return new Response(
|
|
811
|
+
primerHtml(
|
|
812
|
+
state.globalCss,
|
|
813
|
+
tokensCss,
|
|
814
|
+
vitrineCss,
|
|
815
|
+
reload && !scanning,
|
|
816
|
+
{
|
|
817
|
+
theme,
|
|
818
|
+
markup,
|
|
819
|
+
ssr,
|
|
820
|
+
headStyles,
|
|
821
|
+
},
|
|
822
|
+
),
|
|
823
|
+
{
|
|
824
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
825
|
+
},
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (path === '/render' || path.startsWith('/render/')) {
|
|
830
|
+
// The a11y scanner appends `?dcscan=1` and waits for network idle, which
|
|
831
|
+
// an open live-reload SSE would never reach — so omit it for that fetch.
|
|
832
|
+
const scanning = url.searchParams.has('dcscan')
|
|
833
|
+
const rs = parseRenderState(url)
|
|
834
|
+
const key = `${rs.componentId}/${rs.caseId}`
|
|
835
|
+
// Pre-render the case to markup, baked into the document so it's present
|
|
836
|
+
// before the page's scripts run. A browser-only case (or one already
|
|
837
|
+
// recorded as such) is served adopt-free for the client to mount.
|
|
838
|
+
if (rs.componentId && rs.caseId && !browserOnly.has(key)) {
|
|
839
|
+
const result = state.renderCase({
|
|
840
|
+
componentId: rs.componentId,
|
|
841
|
+
caseId: rs.caseId,
|
|
842
|
+
width: rs.width,
|
|
843
|
+
tweaks: rs.tweaks,
|
|
844
|
+
})
|
|
845
|
+
if (result.browserOnly) {
|
|
846
|
+
browserOnly.add(key)
|
|
847
|
+
console.warn(
|
|
848
|
+
` ⚠ ${key} can't render server-side (${result.error ?? 'threw'}); the client will render it`,
|
|
849
|
+
)
|
|
850
|
+
} else {
|
|
851
|
+
rs.markup = result.html
|
|
852
|
+
rs.ssr = true
|
|
853
|
+
rs.headStyles = result.headStyles
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return new Response(
|
|
857
|
+
renderHtml(state.globalCss, vitrineCss, reload && !scanning, rs),
|
|
858
|
+
{
|
|
859
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
860
|
+
},
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Shell handles `/`, `/primer`, and all `/c/...` browse routes. The server
|
|
865
|
+
// pre-renders the shell from the in-memory manifest + this request's route
|
|
866
|
+
// so the landing surface and every deep link arrive painted; the client
|
|
867
|
+
// adopts it. The shell does a full reload only in `--dev` (chrome may have
|
|
868
|
+
// changed); the runtime config drives the non-dev iframe + manifest refresh.
|
|
869
|
+
const theme: Theme =
|
|
870
|
+
url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
|
|
871
|
+
const a11y = scanner !== null
|
|
872
|
+
const shell = renderShellToHtml({
|
|
873
|
+
manifest: state.manifest,
|
|
874
|
+
pathname: path,
|
|
875
|
+
search: url.search,
|
|
876
|
+
theme,
|
|
877
|
+
a11y,
|
|
878
|
+
})
|
|
879
|
+
const seedScript = `<script>window.__dcSeed=${JSON.stringify({ manifest: state.manifest, theme, a11y })}</script>`
|
|
880
|
+
return new Response(
|
|
881
|
+
shellHtml(
|
|
882
|
+
state.manifest.title,
|
|
883
|
+
state.globalCss,
|
|
884
|
+
vitrineCss,
|
|
885
|
+
tokensCss,
|
|
886
|
+
dev,
|
|
887
|
+
interactive ? clientConfigScript({ reload, a11y, dev }) : '',
|
|
888
|
+
{ theme, markup: shell.html, ssr: shell.ssr, seedScript },
|
|
889
|
+
),
|
|
890
|
+
{
|
|
891
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
892
|
+
},
|
|
893
|
+
)
|
|
894
|
+
},
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
// Build the on-demand scanner now that the server has a URL to scan against.
|
|
898
|
+
if (interactive && config.a11y?.enabled) {
|
|
899
|
+
const base = String(server.url).replace(/\/$/, '')
|
|
900
|
+
scanner = createA11yScanner({
|
|
901
|
+
pkgDir,
|
|
902
|
+
config,
|
|
903
|
+
baseUrl: () => base,
|
|
904
|
+
caseFileAbs: (id) => {
|
|
905
|
+
const c = state.manifest.components.find((x) => x.id === id)
|
|
906
|
+
return c ? resolve(REPO_ROOT, c.caseFile) : null
|
|
907
|
+
},
|
|
908
|
+
onResult: (component, caseId, theme, status) => {
|
|
909
|
+
// Remember the latest verdict so late-joining tabs can replay it, then
|
|
910
|
+
// push it to the tabs already listening.
|
|
911
|
+
lastA11y.set(`${component}__${caseId}__${theme}`, {
|
|
912
|
+
component,
|
|
913
|
+
case: caseId,
|
|
914
|
+
theme,
|
|
915
|
+
...status,
|
|
916
|
+
})
|
|
917
|
+
broadcast(
|
|
918
|
+
encoder.encode(
|
|
919
|
+
`event: a11y\ndata: ${JSON.stringify({ component, case: caseId, theme, ...status })}\n\n`,
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
},
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// Populate the nav at start-up per the configured mode (default 'off' — a
|
|
926
|
+
// no-op). Detached: scanning must never delay the server becoming reachable,
|
|
927
|
+
// and `refresh` work rides the scanner's own bounded queue.
|
|
928
|
+
const startupMode = config.a11y?.startup ?? 'off'
|
|
929
|
+
if (startupMode !== 'off') {
|
|
930
|
+
const themes = config.a11y?.themes ?? ['light', 'dark']
|
|
931
|
+
const variants = state.manifest.components.flatMap((c) =>
|
|
932
|
+
c.cases.flatMap((cs) =>
|
|
933
|
+
themes.map((theme) => ({
|
|
934
|
+
componentId: c.id,
|
|
935
|
+
caseId: cs.id,
|
|
936
|
+
theme,
|
|
937
|
+
})),
|
|
938
|
+
),
|
|
939
|
+
)
|
|
940
|
+
void scanner.populateAtStartup(variants, startupMode)
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Debounced rebuild. Refreshes the manifest + bundle, drops the a11y cache's
|
|
945
|
+
// in-flight bookkeeping (the on-disk hashes still decide what re-scans), and —
|
|
946
|
+
// when reload is on — pushes a reload so the iframe + manifest refresh. In dev
|
|
947
|
+
// it also re-reads the inlined chrome CSS/tokens (the shell full-reloads).
|
|
948
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
949
|
+
const scheduleRebuild = (label: string) => {
|
|
950
|
+
if (timer) clearTimeout(timer)
|
|
951
|
+
timer = setTimeout(async () => {
|
|
952
|
+
try {
|
|
953
|
+
console.log(`↻ ${label}, rebuilding…`)
|
|
954
|
+
if (dev) {
|
|
955
|
+
vitrineCss = await readVitrineCss()
|
|
956
|
+
tokensCss = await readDesignTokens()
|
|
957
|
+
}
|
|
958
|
+
state = await rebuild(pkgDir, config, configPath)
|
|
959
|
+
browserOnly.clear()
|
|
960
|
+
scanner?.invalidateAll()
|
|
961
|
+
if (reload) {
|
|
962
|
+
// Full-reload the tab when the chrome bundle changed; otherwise just
|
|
963
|
+
// refresh the rendered content (iframe + manifest), keeping nav state.
|
|
964
|
+
const nextHash = await shellBundleHash()
|
|
965
|
+
const kind = nextHash !== shellHash ? 'shell' : 'content'
|
|
966
|
+
shellHash = nextHash
|
|
967
|
+
triggerReload(kind)
|
|
968
|
+
}
|
|
969
|
+
} catch (err) {
|
|
970
|
+
console.error(err)
|
|
971
|
+
}
|
|
972
|
+
}, 150)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Watch the consumer package's source. Any app-relevant source change rebuilds
|
|
976
|
+
// — component implementations and styles, not just case/doc/primer files — so
|
|
977
|
+
// editing a component hot-reloads its rendered case (and re-evaluates a11y).
|
|
978
|
+
//
|
|
979
|
+
// We watch via @parcel/watcher (native FSEvents / inotify / ReadDirectoryChangesW)
|
|
980
|
+
// rather than node's `fs.watch`: recursive `fs.watch` on macOS drops events for
|
|
981
|
+
// atomic/rename writes (most editor saves) and coalesces rapid changes, so an
|
|
982
|
+
// edit would silently fail to rebuild and the open page would serve stale code.
|
|
983
|
+
// @parcel/watcher delivers reliable, absolute-path events across platforms.
|
|
984
|
+
const srcDir = join(pkgDir, 'src')
|
|
985
|
+
const watchSrc = interactive && existsSync(srcDir)
|
|
986
|
+
const watchHere = dev && resolve(srcDir) !== resolve(HERE)
|
|
987
|
+
// Load the native watcher only on the paths that actually watch — `check` and
|
|
988
|
+
// the per-rebuild `--print-manifest` subprocess import this module too, and
|
|
989
|
+
// shouldn't pay to dlopen the binding.
|
|
990
|
+
const { subscribe } =
|
|
991
|
+
watchSrc || watchHere
|
|
992
|
+
? await import('@parcel/watcher')
|
|
993
|
+
: { subscribe: undefined }
|
|
994
|
+
const watched = /\.(tsx?|css|mdx)$|\.placard\.md$/
|
|
995
|
+
const ignore = ['node_modules', '.git', '.display-case', 'dist']
|
|
996
|
+
if (subscribe && watchSrc) {
|
|
997
|
+
await subscribe(
|
|
998
|
+
srcDir,
|
|
999
|
+
(err, events) => {
|
|
1000
|
+
if (err) return
|
|
1001
|
+
if (events.some((e) => watched.test(e.path)))
|
|
1002
|
+
scheduleRebuild('change detected')
|
|
1003
|
+
},
|
|
1004
|
+
{ ignore },
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Dev, showcasing a *different* package: also watch Display Case's own UI
|
|
1009
|
+
// source so editing the chrome hot-reloads even when `pkgDir` is elsewhere.
|
|
1010
|
+
if (subscribe && watchHere) {
|
|
1011
|
+
await subscribe(
|
|
1012
|
+
HERE,
|
|
1013
|
+
(err, events) => {
|
|
1014
|
+
if (err) return
|
|
1015
|
+
if (events.some((e) => /\.(tsx?|css)$/.test(e.path)))
|
|
1016
|
+
scheduleRebuild('app source changed')
|
|
1017
|
+
},
|
|
1018
|
+
{ ignore },
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return server
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Build the manifest once and return it (used by `--print-manifest`, and by the
|
|
1027
|
+
* dev server's per-rebuild subprocess). Load errors are written to stderr so the
|
|
1028
|
+
* JSON stays the sole thing on stdout; a spawning parent can relay them.
|
|
1029
|
+
*/
|
|
1030
|
+
export async function getManifest(pkgDir: string): Promise<Manifest> {
|
|
1031
|
+
const { config } = await resolveConfig(pkgDir)
|
|
1032
|
+
const files = await discoverCaseFiles(pkgDir, config)
|
|
1033
|
+
const { modules, errors } = await loadModules(files)
|
|
1034
|
+
for (const e of errors) console.error(` ✗ ${relPath(e.file)}: ${e.error}`)
|
|
1035
|
+
const hasPrimer = primerFile(pkgDir, config) !== null
|
|
1036
|
+
return buildManifest(modules, config, hasPrimer).manifest
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
export { slugify }
|