@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,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { StyleEngine } from '../index'
|
|
3
|
+
import { renderWithStyles } from './collect-styles'
|
|
4
|
+
|
|
5
|
+
/** A stub engine that wraps the tree in a marker element and emits a tagged
|
|
6
|
+
* `<style>` whose body echoes a per-instance counter — so a fresh instance per
|
|
7
|
+
* render is observable (isolation), as is the wrap actually being applied. */
|
|
8
|
+
function markerEngine(key: string, counter: { n: number }): StyleEngine {
|
|
9
|
+
return () => {
|
|
10
|
+
const id = ++counter.n
|
|
11
|
+
return {
|
|
12
|
+
wrap: (node) => (
|
|
13
|
+
<div data-wrapped={key} data-instance={id}>
|
|
14
|
+
{node}
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
collect: (html) =>
|
|
18
|
+
`<style data-engine="${key}" data-instance="${id}" data-saw-wrap="${html.includes(
|
|
19
|
+
`data-wrapped="${key}"`,
|
|
20
|
+
)}"></style>`,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('renderWithStyles', () => {
|
|
26
|
+
test('no engines: plain render, empty headStyles (inert when unused)', () => {
|
|
27
|
+
const { html, headStyles } = renderWithStyles(<p>hi</p>, undefined)
|
|
28
|
+
expect(html).toBe('<p>hi</p>')
|
|
29
|
+
expect(headStyles).toBe('')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('empty engine array is also inert', () => {
|
|
33
|
+
const { html, headStyles } = renderWithStyles(<p>hi</p>, [])
|
|
34
|
+
expect(html).toBe('<p>hi</p>')
|
|
35
|
+
expect(headStyles).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('applies wrap and captures collect output (which saw the wrapped markup)', () => {
|
|
39
|
+
const counter = { n: 0 }
|
|
40
|
+
const { html, headStyles } = renderWithStyles(<p>hi</p>, [
|
|
41
|
+
markerEngine('emotion', counter),
|
|
42
|
+
])
|
|
43
|
+
expect(html).toContain('data-wrapped="emotion"')
|
|
44
|
+
expect(headStyles).toContain('data-engine="emotion"')
|
|
45
|
+
// collect() received the rendered markup including the wrapper.
|
|
46
|
+
expect(headStyles).toContain('data-saw-wrap="true"')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('multiple engines nest in array order and concatenate their output', () => {
|
|
50
|
+
const counter = { n: 0 }
|
|
51
|
+
const { html, headStyles } = renderWithStyles(<p>hi</p>, [
|
|
52
|
+
markerEngine('outer', counter),
|
|
53
|
+
markerEngine('inner', counter),
|
|
54
|
+
])
|
|
55
|
+
// First engine is outermost: <div outer><div inner><p/></div></div>.
|
|
56
|
+
expect(html.indexOf('data-wrapped="outer"')).toBeLessThan(
|
|
57
|
+
html.indexOf('data-wrapped="inner"'),
|
|
58
|
+
)
|
|
59
|
+
expect(headStyles).toContain('data-engine="outer"')
|
|
60
|
+
expect(headStyles).toContain('data-engine="inner"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('factory runs once per render — a fresh, isolated store each time', () => {
|
|
64
|
+
const counter = { n: 0 }
|
|
65
|
+
const engine = markerEngine('x', counter)
|
|
66
|
+
const a = renderWithStyles(<p>a</p>, [engine])
|
|
67
|
+
const b = renderWithStyles(<p>b</p>, [engine])
|
|
68
|
+
// Distinct instance ids prove the factory was re-invoked (not a shared store).
|
|
69
|
+
expect(a.headStyles).toContain('data-instance="1"')
|
|
70
|
+
expect(b.headStyles).toContain('data-instance="2"')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import type { StyleEngine } from '../index'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a tree to markup, applying any configured style engines so render-time
|
|
7
|
+
* (CSS-in-JS) styling is collected and delivered before scripting.
|
|
8
|
+
*
|
|
9
|
+
* Each engine is a factory invoked **once per render** (see {@link StyleEngine}),
|
|
10
|
+
* giving every render an isolated style store — one case's styling can never leak
|
|
11
|
+
* into another's document. Engines wrap the tree in array order (the first is
|
|
12
|
+
* outermost); after `renderToString`, each collector's `collect(html)` output is
|
|
13
|
+
* concatenated into a single `headStyles` string for the document `<head>`.
|
|
14
|
+
*
|
|
15
|
+
* With no engines configured, the tree is rendered unwrapped and `headStyles` is
|
|
16
|
+
* `''`, so the resulting document is byte-identical to its engine-free form.
|
|
17
|
+
*/
|
|
18
|
+
export function renderWithStyles(
|
|
19
|
+
tree: ReactNode,
|
|
20
|
+
engines: StyleEngine[] | undefined,
|
|
21
|
+
): { html: string; headStyles: string } {
|
|
22
|
+
const collectors = (engines ?? []).map((make) => make())
|
|
23
|
+
let wrapped = tree
|
|
24
|
+
// Apply from last to first so the first engine ends up outermost.
|
|
25
|
+
for (let i = collectors.length - 1; i >= 0; i--) {
|
|
26
|
+
wrapped = collectors[i].wrap(wrapped)
|
|
27
|
+
}
|
|
28
|
+
const html = renderToString(wrapped)
|
|
29
|
+
const headStyles = collectors
|
|
30
|
+
.map((collector) => collector.collect(html))
|
|
31
|
+
.join('')
|
|
32
|
+
return { html, headStyles }
|
|
33
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Manifest } from '../core/manifest'
|
|
3
|
+
import { type DocAssets, primerDoc, renderDoc, shellDoc } from './documents'
|
|
4
|
+
|
|
5
|
+
const assets: DocAssets = {
|
|
6
|
+
browser: '/assets/browser-abc123.js',
|
|
7
|
+
render: '/assets/render-def456.js',
|
|
8
|
+
primer: '/assets/primer-ghi789.js',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const manifest: Manifest = {
|
|
12
|
+
title: 'My Showcase',
|
|
13
|
+
components: [],
|
|
14
|
+
primer: true,
|
|
15
|
+
landing: 'primer',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('shellDoc', () => {
|
|
19
|
+
const doc = () =>
|
|
20
|
+
shellDoc({
|
|
21
|
+
title: 'My Showcase',
|
|
22
|
+
tokensCss: '.tok{}',
|
|
23
|
+
globalCss: '.glob{}',
|
|
24
|
+
vitrineCss: '.vit{}',
|
|
25
|
+
theme: 'dark',
|
|
26
|
+
markup: '<header>chrome</header>',
|
|
27
|
+
ssr: true,
|
|
28
|
+
manifest,
|
|
29
|
+
a11y: false,
|
|
30
|
+
assets,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('is a themed HTML document carrying the title and pre-rendered markup', () => {
|
|
34
|
+
const html = doc()
|
|
35
|
+
expect(html.startsWith('<!doctype html>')).toBe(true)
|
|
36
|
+
expect(html).toContain('data-theme="dark"')
|
|
37
|
+
expect(html).toContain('<title>My Showcase</title>')
|
|
38
|
+
expect(html).toContain('<header>chrome</header>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('inlines all of the supplied CSS layers', () => {
|
|
42
|
+
const html = doc()
|
|
43
|
+
expect(html).toContain('.tok{}')
|
|
44
|
+
expect(html).toContain('.glob{}')
|
|
45
|
+
expect(html).toContain('.vit{}')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('seeds the manifest, theme, and a11y flag for hydration', () => {
|
|
49
|
+
const html = doc()
|
|
50
|
+
expect(html).toContain('window.__dcSeed=')
|
|
51
|
+
expect(html).toContain('"My Showcase"')
|
|
52
|
+
expect(html).toContain('"theme":"dark"')
|
|
53
|
+
expect(html).toContain('"a11y":false')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('marks the root server-rendered and references the browser entry', () => {
|
|
57
|
+
expect(doc()).toContain('data-ssr="1"')
|
|
58
|
+
expect(doc()).toContain('src="/assets/browser-abc123.js"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('marks the root as client-only when ssr is false', () => {
|
|
62
|
+
const html = shellDoc({
|
|
63
|
+
title: 'X',
|
|
64
|
+
tokensCss: '',
|
|
65
|
+
globalCss: '',
|
|
66
|
+
vitrineCss: '',
|
|
67
|
+
theme: 'light',
|
|
68
|
+
markup: '',
|
|
69
|
+
ssr: false,
|
|
70
|
+
manifest,
|
|
71
|
+
a11y: false,
|
|
72
|
+
assets,
|
|
73
|
+
})
|
|
74
|
+
expect(html).toContain('data-ssr="0"')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('carries no dev live-reload machinery', () => {
|
|
78
|
+
expect(doc()).not.toContain('__livereload')
|
|
79
|
+
expect(doc().toLowerCase()).not.toContain('eventsource')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('renderDoc', () => {
|
|
84
|
+
const doc = (over: Partial<Parameters<typeof renderDoc>[0]> = {}) =>
|
|
85
|
+
renderDoc({
|
|
86
|
+
globalCss: '.g{}',
|
|
87
|
+
vitrineCss: '.vit{}',
|
|
88
|
+
theme: 'light',
|
|
89
|
+
transparent: false,
|
|
90
|
+
fit: false,
|
|
91
|
+
markup: '<button>x</button>',
|
|
92
|
+
ssr: true,
|
|
93
|
+
assets,
|
|
94
|
+
...over,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('renders the isolated case markup with both theme attributes', () => {
|
|
98
|
+
const html = doc()
|
|
99
|
+
expect(html).toContain('data-theme="light"')
|
|
100
|
+
expect(html).toContain('data-theme-pref="light"')
|
|
101
|
+
expect(html).toContain('<button>x</button>')
|
|
102
|
+
expect(html).toContain('src="/assets/render-def456.js"')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('inlines the global and Vitrine CSS so a dogfooded case is styled pre-script', () => {
|
|
106
|
+
const html = doc()
|
|
107
|
+
expect(html).toContain('.g{}')
|
|
108
|
+
expect(html).toContain('.vit{}')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a transparent exhibit decorates the body and clears its background', () => {
|
|
112
|
+
const html = doc({ transparent: true })
|
|
113
|
+
expect(html).toContain('data-decorated')
|
|
114
|
+
expect(html).toContain('background:transparent')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a fitted exhibit shrink-wraps the root to its content width', () => {
|
|
118
|
+
expect(doc({ fit: true })).toContain('width:fit-content')
|
|
119
|
+
expect(doc({ fit: false })).not.toContain('width:fit-content')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('reflects the ssr flag on the root', () => {
|
|
123
|
+
expect(doc({ ssr: true })).toContain('data-ssr="1"')
|
|
124
|
+
expect(doc({ ssr: false })).toContain('data-ssr="0"')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('omitting headStyles is byte-identical to passing empty (inert when unused)', () => {
|
|
128
|
+
expect(doc({})).toBe(doc({ headStyles: '' }))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('style-engine output is a discrete tag after the static <style> block', () => {
|
|
132
|
+
const tag = '<style data-emotion="css 1ab2">.x{}</style>'
|
|
133
|
+
const html = doc({ headStyles: tag })
|
|
134
|
+
expect(html).toContain(tag)
|
|
135
|
+
// Placed after the base block closes, before the head closes — not folded
|
|
136
|
+
// into the static <style> (so emotion's data-emotion adoption markers survive).
|
|
137
|
+
expect(html).toContain(`</style>${tag}</head>`)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('primerDoc', () => {
|
|
142
|
+
const doc = () =>
|
|
143
|
+
primerDoc({
|
|
144
|
+
tokensCss: '.tok{}',
|
|
145
|
+
globalCss: '.glob{}',
|
|
146
|
+
vitrineCss: '.vit{}',
|
|
147
|
+
theme: 'dark',
|
|
148
|
+
markup: '<article>primer</article>',
|
|
149
|
+
ssr: true,
|
|
150
|
+
assets,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('is the themed primer reading page with its markup and entry', () => {
|
|
154
|
+
const html = doc()
|
|
155
|
+
expect(html).toContain('<title>Primer</title>')
|
|
156
|
+
expect(html).toContain('data-theme="dark"')
|
|
157
|
+
expect(html).toContain('data-theme-pref="dark"')
|
|
158
|
+
expect(html).toContain('<article>primer</article>')
|
|
159
|
+
expect(html).toContain('src="/assets/primer-ghi789.js"')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('inlines the token, global, and Vitrine CSS and marks the ssr root', () => {
|
|
163
|
+
const html = doc()
|
|
164
|
+
expect(html).toContain('.tok{}')
|
|
165
|
+
expect(html).toContain('.glob{}')
|
|
166
|
+
expect(html).toContain('.vit{}')
|
|
167
|
+
expect(html).toContain('data-ssr="1"')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('style-engine output is a discrete tag after the static <style> block', () => {
|
|
171
|
+
const tag = '<style data-emotion="css 9zz">.y{}</style>'
|
|
172
|
+
const html = primerDoc({
|
|
173
|
+
tokensCss: '.tok{}',
|
|
174
|
+
globalCss: '.glob{}',
|
|
175
|
+
vitrineCss: '.vit{}',
|
|
176
|
+
theme: 'dark',
|
|
177
|
+
markup: '<article>primer</article>',
|
|
178
|
+
ssr: true,
|
|
179
|
+
headStyles: tag,
|
|
180
|
+
assets,
|
|
181
|
+
})
|
|
182
|
+
expect(html).toContain(`</style>${tag}</head>`)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Manifest } from '../core/manifest'
|
|
2
|
+
import type { Theme } from '../ui/shell-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Production HTML document templates for a published build. They mirror the dev
|
|
6
|
+
* server's documents but drop every development-only inject — no live-reload SSE
|
|
7
|
+
* client, no `process/Bun is not defined` error overlay — and reference the
|
|
8
|
+
* content-hashed asset URLs the production bundle emits (so a host can cache them
|
|
9
|
+
* indefinitely). The React trees themselves are produced by the *shared*
|
|
10
|
+
* renderers (`ssr-shell`, `ssr-render`, `ssr-primer`); only the envelope here
|
|
11
|
+
* differs from dev, by necessity (hashed assets vs. a fixed dev path).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const FONT_LINKS =
|
|
15
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com"/>' +
|
|
16
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>' +
|
|
17
|
+
'<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"/>'
|
|
18
|
+
|
|
19
|
+
/** Content-hashed entry URLs the production build emitted (already base-prefixed). */
|
|
20
|
+
export interface DocAssets {
|
|
21
|
+
browser: string
|
|
22
|
+
render: string
|
|
23
|
+
primer: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The browse shell document: pre-rendered chrome + inlined seed, hydrated by the
|
|
27
|
+
* browser entry. No dev injects. */
|
|
28
|
+
export function shellDoc(opts: {
|
|
29
|
+
title: string
|
|
30
|
+
tokensCss: string
|
|
31
|
+
globalCss: string
|
|
32
|
+
vitrineCss: string
|
|
33
|
+
theme: Theme
|
|
34
|
+
markup: string
|
|
35
|
+
ssr: boolean
|
|
36
|
+
manifest: Manifest
|
|
37
|
+
a11y: boolean
|
|
38
|
+
assets: DocAssets
|
|
39
|
+
}): string {
|
|
40
|
+
const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
|
|
41
|
+
const seed = JSON.stringify({
|
|
42
|
+
manifest: opts.manifest,
|
|
43
|
+
theme: opts.theme,
|
|
44
|
+
a11y: opts.a11y,
|
|
45
|
+
})
|
|
46
|
+
return `<!doctype html><html lang="en" data-theme="${opts.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${opts.title}</title>${FONT_LINKS}<style>${opts.tokensCss}\n${opts.globalCss}\n${reset}\n${opts.vitrineCss}</style></head><body><div id="root" data-ssr="${opts.ssr ? '1' : '0'}">${opts.markup}</div><script>window.__dcSeed=${seed}</script><script type="module" src="${opts.assets.browser}"></script></body></html>`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The isolated case render document. */
|
|
50
|
+
export function renderDoc(opts: {
|
|
51
|
+
globalCss: string
|
|
52
|
+
vitrineCss: string
|
|
53
|
+
theme: Theme
|
|
54
|
+
transparent: boolean
|
|
55
|
+
fit: boolean
|
|
56
|
+
markup: string
|
|
57
|
+
ssr: boolean
|
|
58
|
+
/** Style-engine output, placed after the static <style> block. `''` when none. */
|
|
59
|
+
headStyles?: string
|
|
60
|
+
assets: DocAssets
|
|
61
|
+
}): string {
|
|
62
|
+
const exhibitCenter =
|
|
63
|
+
'body[data-decorated] #root>*{justify-content:center;align-content:center}'
|
|
64
|
+
const bodyAttrs = opts.transparent
|
|
65
|
+
? ' data-decorated style="background:transparent"'
|
|
66
|
+
: ''
|
|
67
|
+
const rootAttrs = `${opts.fit ? ' style="width:fit-content"' : ''} data-ssr="${opts.ssr ? '1' : '0'}"`
|
|
68
|
+
// The Vitrine stylesheet follows globalCss so a dogfooded design-system case
|
|
69
|
+
// paints before scripts; for a non-dogfooding consumer these are inert chrome
|
|
70
|
+
// rules in a dev-time-only preview document (see server.ts renderHtml).
|
|
71
|
+
return `<!doctype html><html lang="en" data-theme="${opts.theme}" data-theme-pref="${opts.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}${opts.globalCss}\n${opts.vitrineCss}</style>${opts.headStyles ?? ''}</head><body${bodyAttrs}><main id="root"${rootAttrs}>${opts.markup}</main><script type="module" src="${opts.assets.render}"></script></body></html>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The primer reading-page document. */
|
|
75
|
+
export function primerDoc(opts: {
|
|
76
|
+
tokensCss: string
|
|
77
|
+
globalCss: string
|
|
78
|
+
vitrineCss: string
|
|
79
|
+
theme: Theme
|
|
80
|
+
markup: string
|
|
81
|
+
ssr: boolean
|
|
82
|
+
/** Style-engine output, placed after the static <style> block. `''` when none. */
|
|
83
|
+
headStyles?: string
|
|
84
|
+
assets: DocAssets
|
|
85
|
+
}): string {
|
|
86
|
+
const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
|
|
87
|
+
return `<!doctype html><html lang="en" data-theme="${opts.theme}" data-theme-pref="${opts.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Primer</title>${FONT_LINKS}<style>${opts.tokensCss}\n${opts.globalCss}\n${reset}\n${opts.vitrineCss}</style>${opts.headStyles ?? ''}</head><body><main id="root" data-ssr="${opts.ssr ? '1' : '0'}">${opts.markup}</main><script type="module" src="${opts.assets.primer}"></script></body></html>`
|
|
88
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
import { type DisplayCaseConfig, defineCases, tweak } from '../index'
|
|
5
|
+
import {
|
|
6
|
+
type CaseTreeState,
|
|
7
|
+
caseTree,
|
|
8
|
+
encodeOverrides,
|
|
9
|
+
resolveTweaks,
|
|
10
|
+
} from './render-node'
|
|
11
|
+
|
|
12
|
+
const schema = {
|
|
13
|
+
label: tweak.text('Save'),
|
|
14
|
+
size: tweak.choice(['sm', 'lg'], 'sm'),
|
|
15
|
+
count: tweak.number(3),
|
|
16
|
+
on: tweak.boolean(false),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// `resolveTweaks` is typed over the wide `TweakSchema`, so its return widens to
|
|
20
|
+
// an imprecise mapped type; assert against the concrete value shape this schema
|
|
21
|
+
// produces.
|
|
22
|
+
interface Resolved {
|
|
23
|
+
label: string
|
|
24
|
+
size: string
|
|
25
|
+
count: number
|
|
26
|
+
on: boolean
|
|
27
|
+
}
|
|
28
|
+
const resolve = (tweaks: Record<string, string>): Resolved =>
|
|
29
|
+
resolveTweaks(schema, tweaks) as unknown as Resolved
|
|
30
|
+
|
|
31
|
+
const state = (over: Partial<CaseTreeState>): CaseTreeState => ({
|
|
32
|
+
componentId: 'button',
|
|
33
|
+
caseId: 'default',
|
|
34
|
+
width: null,
|
|
35
|
+
tweaks: {},
|
|
36
|
+
...over,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const NO_CONFIG: DisplayCaseConfig = {} as DisplayCaseConfig
|
|
40
|
+
|
|
41
|
+
describe('resolveTweaks', () => {
|
|
42
|
+
test('falls back to each descriptor default when the key is absent', () => {
|
|
43
|
+
expect(resolve({})).toEqual({
|
|
44
|
+
label: 'Save',
|
|
45
|
+
size: 'sm',
|
|
46
|
+
count: 3,
|
|
47
|
+
on: false,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('decodes booleans from "1"/"true" and treats anything else as false', () => {
|
|
52
|
+
expect(resolve({ on: '1' }).on).toBe(true)
|
|
53
|
+
expect(resolve({ on: 'true' }).on).toBe(true)
|
|
54
|
+
expect(resolve({ on: '0' }).on).toBe(false)
|
|
55
|
+
expect(resolve({ on: 'nope' }).on).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('coerces numbers and passes text/choice through verbatim', () => {
|
|
59
|
+
const v = resolve({ count: '42', label: 'Go', size: 'lg' })
|
|
60
|
+
expect(v.count).toBe(42)
|
|
61
|
+
expect(v.label).toBe('Go')
|
|
62
|
+
expect(v.size).toBe('lg')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('encodeOverrides', () => {
|
|
67
|
+
test('returns an empty map for no overrides', () => {
|
|
68
|
+
expect(encodeOverrides()).toEqual({})
|
|
69
|
+
expect(encodeOverrides(undefined)).toEqual({})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('serializes booleans to "1"/"0" and stringifies the rest', () => {
|
|
73
|
+
expect(
|
|
74
|
+
encodeOverrides({ on: true, off: false, count: 7, label: 'Go' }),
|
|
75
|
+
).toEqual({
|
|
76
|
+
on: '1',
|
|
77
|
+
off: '0',
|
|
78
|
+
count: '7',
|
|
79
|
+
label: 'Go',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('round-trips numbers and booleans back through resolveTweaks', () => {
|
|
84
|
+
const decoded = resolve(encodeOverrides({ count: 42, on: true }))
|
|
85
|
+
expect(decoded.count).toBe(42)
|
|
86
|
+
expect(decoded.on).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('caseTree', () => {
|
|
91
|
+
const Noop = () => {}
|
|
92
|
+
|
|
93
|
+
test('renders the not-found node when no such case exists', () => {
|
|
94
|
+
const html = renderToStaticMarkup(
|
|
95
|
+
caseTree(
|
|
96
|
+
[],
|
|
97
|
+
NO_CONFIG,
|
|
98
|
+
state({ componentId: 'ghost', caseId: 'x' }),
|
|
99
|
+
Noop,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
expect(html).toContain('dc-render-missing')
|
|
103
|
+
expect(html).toContain('No such case: ghost/x')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('invokes a simple thunk case', () => {
|
|
107
|
+
const modules = [
|
|
108
|
+
defineCases('Button', {
|
|
109
|
+
Default: () => <button type="button">Hi</button>,
|
|
110
|
+
}),
|
|
111
|
+
]
|
|
112
|
+
const html = renderToStaticMarkup(
|
|
113
|
+
caseTree(modules, NO_CONFIG, state({}), Noop),
|
|
114
|
+
)
|
|
115
|
+
expect(html).toContain('<button')
|
|
116
|
+
expect(html).toContain('Hi')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('passes resolved tweak values into a tweaked case render', () => {
|
|
120
|
+
const modules = [
|
|
121
|
+
defineCases('Button', {
|
|
122
|
+
Default: {
|
|
123
|
+
tweaks: { label: tweak.text('Save') },
|
|
124
|
+
render: (v) => <span>{v.label}</span>,
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
]
|
|
128
|
+
const html = renderToStaticMarkup(
|
|
129
|
+
caseTree(
|
|
130
|
+
modules,
|
|
131
|
+
NO_CONFIG,
|
|
132
|
+
state({ tweaks: { label: 'Custom' } }),
|
|
133
|
+
Noop,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
expect(html).toContain('<span>Custom</span>')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('constrains the case to a max-width wrapper when width is set', () => {
|
|
140
|
+
const modules = [defineCases('Button', { Default: () => <i>x</i> })]
|
|
141
|
+
const html = renderToStaticMarkup(
|
|
142
|
+
caseTree(modules, NO_CONFIG, state({ width: 320 }), Noop),
|
|
143
|
+
)
|
|
144
|
+
expect(html).toContain('max-width:320px')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('wraps the case in the configured decorator', () => {
|
|
148
|
+
const modules = [defineCases('Button', { Default: () => <i>x</i> })]
|
|
149
|
+
const config = {
|
|
150
|
+
decorator: ({ children }: { children: ReactNode }) => (
|
|
151
|
+
<div className="deco">{children}</div>
|
|
152
|
+
),
|
|
153
|
+
} as DisplayCaseConfig
|
|
154
|
+
const html = renderToStaticMarkup(
|
|
155
|
+
caseTree(modules, config, state({}), Noop),
|
|
156
|
+
)
|
|
157
|
+
expect(html).toContain('class="deco"')
|
|
158
|
+
expect(html).toContain('<i>x</i>')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { StrictMode } from 'react'
|
|
3
|
+
import { findCase, slugify } from '../core/catalog'
|
|
4
|
+
import type {
|
|
5
|
+
CaseModule,
|
|
6
|
+
DisplayCaseConfig,
|
|
7
|
+
FlowStep,
|
|
8
|
+
GotoFn,
|
|
9
|
+
TweakedCase,
|
|
10
|
+
TweakSchema,
|
|
11
|
+
TweakValues,
|
|
12
|
+
} from '../index'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The pure, DOM-free construction of a single case's React tree. Both the
|
|
16
|
+
* server (pre-rendering the isolated `/render` document to markup) and the
|
|
17
|
+
* client (hydrating, then driving in-place swaps/tweaks) build the tree through
|
|
18
|
+
* this one function, so the two can never disagree on markup — the prerequisite
|
|
19
|
+
* for hydration without mismatch. It touches no `window`/`document`: the
|
|
20
|
+
* document-level effects of a render (the theme on `<html>`, the body
|
|
21
|
+
* background, the mount width) are applied by the caller, not here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The slice of render state the React tree depends on (no theme/fit/transparent —
|
|
25
|
+
* those are document-level, applied outside the tree). */
|
|
26
|
+
export interface CaseTreeState {
|
|
27
|
+
componentId: string
|
|
28
|
+
caseId: string
|
|
29
|
+
width: number | null
|
|
30
|
+
tweaks: Record<string, string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decode the string tweak map (from the address) into typed render values. */
|
|
34
|
+
export function resolveTweaks(
|
|
35
|
+
schema: TweakSchema,
|
|
36
|
+
tweaks: Record<string, string>,
|
|
37
|
+
): TweakValues<TweakSchema> {
|
|
38
|
+
const values: Record<string, string | number | boolean> = {}
|
|
39
|
+
for (const [key, desc] of Object.entries(schema)) {
|
|
40
|
+
const raw = tweaks[key]
|
|
41
|
+
if (raw === undefined) {
|
|
42
|
+
values[key] = desc.default
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
switch (desc.kind) {
|
|
46
|
+
case 'boolean':
|
|
47
|
+
values[key] = raw === '1' || raw === 'true'
|
|
48
|
+
break
|
|
49
|
+
case 'number':
|
|
50
|
+
values[key] = Number(raw)
|
|
51
|
+
break
|
|
52
|
+
default:
|
|
53
|
+
values[key] = raw
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return values as TweakValues<TweakSchema>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Encode a step's `goto` overrides into the string tweak map used in URLs. */
|
|
60
|
+
export function encodeOverrides(
|
|
61
|
+
overrides?: Record<string, string | number | boolean>,
|
|
62
|
+
): Record<string, string> {
|
|
63
|
+
const out: Record<string, string> = {}
|
|
64
|
+
if (!overrides) return out
|
|
65
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
66
|
+
if (typeof v === 'boolean') out[k] = v ? '1' : '0'
|
|
67
|
+
else out[k] = String(v)
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A `goto` that does nothing — used when pre-rendering on the server, where a
|
|
73
|
+
* flow step's initial paint is rendered but no interaction can occur. */
|
|
74
|
+
export const NOOP_GOTO: GotoFn = () => {}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the full React tree for a case: the case's node (simple thunk, tweaked
|
|
78
|
+
* render, or flow step), optionally width-constrained, wrapped in the configured
|
|
79
|
+
* decorator and `StrictMode`. A missing case yields the not-found node verbatim
|
|
80
|
+
* (no `StrictMode` wrapper), matching what the catalog reports. `goto` is wired
|
|
81
|
+
* into flow steps; pass {@link NOOP_GOTO} on the server.
|
|
82
|
+
*/
|
|
83
|
+
export function caseTree(
|
|
84
|
+
modules: CaseModule[],
|
|
85
|
+
config: DisplayCaseConfig,
|
|
86
|
+
state: CaseTreeState,
|
|
87
|
+
goto: GotoFn,
|
|
88
|
+
): ReactNode {
|
|
89
|
+
const found = findCase(modules, state.componentId, state.caseId)
|
|
90
|
+
if (!found) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="dc-render-missing">
|
|
93
|
+
No such case: {state.componentId}/{state.caseId}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let node: ReactNode
|
|
99
|
+
if (typeof found.case === 'function') {
|
|
100
|
+
node = found.case()
|
|
101
|
+
} else if (found.module.isFlow) {
|
|
102
|
+
const step = found.case as FlowStep
|
|
103
|
+
const values = resolveTweaks(step.tweaks ?? {}, state.tweaks)
|
|
104
|
+
node = step.render({ values, goto })
|
|
105
|
+
} else {
|
|
106
|
+
const tweaked = found.case as TweakedCase
|
|
107
|
+
node = tweaked.render(resolveTweaks(tweaked.tweaks, state.tweaks))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const wrapped = state.width ? (
|
|
111
|
+
<div style={{ maxWidth: `${state.width}px`, margin: '0 auto' }}>{node}</div>
|
|
112
|
+
) : (
|
|
113
|
+
node
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const Decorator = config.decorator
|
|
117
|
+
return (
|
|
118
|
+
<StrictMode>
|
|
119
|
+
{Decorator ? (
|
|
120
|
+
<Decorator
|
|
121
|
+
level={found.module.level}
|
|
122
|
+
sourcePath={found.module.sourcePath}
|
|
123
|
+
area={found.module.area}>
|
|
124
|
+
{wrapped}
|
|
125
|
+
</Decorator>
|
|
126
|
+
) : (
|
|
127
|
+
wrapped
|
|
128
|
+
)}
|
|
129
|
+
</StrictMode>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { slugify }
|