@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,25 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { DisplayCaseConfig } from '../index'
|
|
3
|
+
import { makePrimerRenderer } from './ssr-primer'
|
|
4
|
+
|
|
5
|
+
const config: DisplayCaseConfig = { title: 'T', roots: [] }
|
|
6
|
+
|
|
7
|
+
describe('makePrimerRenderer', () => {
|
|
8
|
+
test('renders the MDX content to markup', () => {
|
|
9
|
+
const render = makePrimerRenderer(() => <h1>Primer Heading</h1>, config)
|
|
10
|
+
const result = render()
|
|
11
|
+
expect(result.browserOnly).toBe(false)
|
|
12
|
+
expect(result.html).toContain('Primer Heading')
|
|
13
|
+
expect(result.error).toBeUndefined()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('falls the whole primer back to the client when a specimen needs a browser', () => {
|
|
17
|
+
const render = makePrimerRenderer(() => {
|
|
18
|
+
throw new Error('specimen touched window')
|
|
19
|
+
}, config)
|
|
20
|
+
const result = render()
|
|
21
|
+
expect(result.browserOnly).toBe(true)
|
|
22
|
+
expect(result.html).toBe('')
|
|
23
|
+
expect(result.error).toContain('specimen touched window')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { StrictMode } from 'react'
|
|
3
|
+
import type { DisplayCaseConfig } from '../index'
|
|
4
|
+
import { PrimerRoot } from '../ui/primer'
|
|
5
|
+
import { renderWithStyles } from './collect-styles'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server-side primer rendering — the sibling of {@link makeCaseRenderer} for
|
|
9
|
+
* the `/render/primer` document. The codegen'd SSR-primer entry imports the
|
|
10
|
+
* compiled MDX and binds it here; the server imports that freshly-built bundle
|
|
11
|
+
* each rebuild (same staleness reasoning as the case renderer). The primer's
|
|
12
|
+
* prose and its embedded specimens render to markup once — the theme is a
|
|
13
|
+
* document attribute, not part of the tree, so one render serves both themes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type MDXContent = (props: { components?: unknown }) => ReactNode
|
|
17
|
+
|
|
18
|
+
export interface PrimerHtmlResult {
|
|
19
|
+
html: string
|
|
20
|
+
/** True when the primer could not be rendered outside a browser (a specimen
|
|
21
|
+
* touched a browser-only API under `renderToString`). The whole primer then
|
|
22
|
+
* falls back to client rendering — the same isolation a single case gets. */
|
|
23
|
+
browserOnly: boolean
|
|
24
|
+
error?: string
|
|
25
|
+
/** Render-time (CSS-in-JS) styling collected by the configured style engines,
|
|
26
|
+
* as `<head>` markup. `''` (or absent) when no engine produced styling. */
|
|
27
|
+
headStyles?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function makePrimerRenderer(
|
|
31
|
+
Content: MDXContent,
|
|
32
|
+
config: DisplayCaseConfig,
|
|
33
|
+
): () => PrimerHtmlResult {
|
|
34
|
+
return function renderPrimerToHtml(): PrimerHtmlResult {
|
|
35
|
+
try {
|
|
36
|
+
// Apply any configured style engines around the primer tree, exactly as the
|
|
37
|
+
// case renderer does, so a specimen's render-time CSS-in-JS styling is
|
|
38
|
+
// delivered before scripting too.
|
|
39
|
+
const { html, headStyles } = renderWithStyles(
|
|
40
|
+
<StrictMode>
|
|
41
|
+
<PrimerRoot content={Content} />
|
|
42
|
+
</StrictMode>,
|
|
43
|
+
config.styleEngines,
|
|
44
|
+
)
|
|
45
|
+
return { html, browserOnly: false, headStyles }
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return {
|
|
48
|
+
html: '',
|
|
49
|
+
browserOnly: true,
|
|
50
|
+
error: err instanceof Error ? err.message : String(err),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type DisplayCaseConfig, defineCases, type StyleEngine } from '../index'
|
|
3
|
+
import type { CaseTreeState } from './render-node'
|
|
4
|
+
import { makeCaseRenderer } from './ssr-render'
|
|
5
|
+
|
|
6
|
+
const NO_CONFIG: DisplayCaseConfig = {} as DisplayCaseConfig
|
|
7
|
+
|
|
8
|
+
/** A stub engine emitting a per-render-instance-tagged style tag. */
|
|
9
|
+
function stubEngine(counter: { n: number }): StyleEngine {
|
|
10
|
+
return () => {
|
|
11
|
+
const id = ++counter.n
|
|
12
|
+
return {
|
|
13
|
+
wrap: (node) => node,
|
|
14
|
+
collect: () => `<style data-stub="${id}"></style>`,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const state = (over: Partial<CaseTreeState>): CaseTreeState => ({
|
|
20
|
+
componentId: 'button',
|
|
21
|
+
caseId: 'default',
|
|
22
|
+
width: null,
|
|
23
|
+
tweaks: {},
|
|
24
|
+
...over,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function Boom(): never {
|
|
28
|
+
throw new Error('needs a browser')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('makeCaseRenderer', () => {
|
|
32
|
+
test('renders an SSR-able case to inner markup', () => {
|
|
33
|
+
const render = makeCaseRenderer(
|
|
34
|
+
[
|
|
35
|
+
defineCases('Button', {
|
|
36
|
+
Default: () => <button type="button">Hi</button>,
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
NO_CONFIG,
|
|
40
|
+
)
|
|
41
|
+
const result = render(state({}))
|
|
42
|
+
expect(result.browserOnly).toBe(false)
|
|
43
|
+
expect(result.html).toContain('Hi')
|
|
44
|
+
expect(result.error).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('skips server rendering for a browser-only module without attempting it', () => {
|
|
48
|
+
const render = makeCaseRenderer(
|
|
49
|
+
[
|
|
50
|
+
defineCases(
|
|
51
|
+
'Canvas',
|
|
52
|
+
{ Default: () => <canvas /> },
|
|
53
|
+
{ browserOnly: true },
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
NO_CONFIG,
|
|
57
|
+
)
|
|
58
|
+
const result = render(state({ componentId: 'canvas' }))
|
|
59
|
+
expect(result).toEqual({ html: '', browserOnly: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('catches a render that needs a browser and reports it browser-only with the error', () => {
|
|
63
|
+
const render = makeCaseRenderer(
|
|
64
|
+
[defineCases('Bad', { Default: () => <Boom /> })],
|
|
65
|
+
NO_CONFIG,
|
|
66
|
+
)
|
|
67
|
+
const result = render(state({ componentId: 'bad' }))
|
|
68
|
+
expect(result.browserOnly).toBe(true)
|
|
69
|
+
expect(result.html).toBe('')
|
|
70
|
+
expect(result.error).toContain('needs a browser')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('renders the not-found node for an unknown case (not a browser-only fallback)', () => {
|
|
74
|
+
const render = makeCaseRenderer([], NO_CONFIG)
|
|
75
|
+
const result = render(state({ componentId: 'ghost', caseId: 'x' }))
|
|
76
|
+
expect(result.browserOnly).toBe(false)
|
|
77
|
+
// renderToString interleaves `<!-- -->` markers between text nodes, so match
|
|
78
|
+
// the stable wrapper + ids rather than the contiguous sentence.
|
|
79
|
+
expect(result.html).toContain('dc-render-missing')
|
|
80
|
+
expect(result.html).toContain('No such case:')
|
|
81
|
+
expect(result.html).toContain('ghost')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('without a style engine, headStyles is empty (inert when unused)', () => {
|
|
85
|
+
const render = makeCaseRenderer(
|
|
86
|
+
[
|
|
87
|
+
defineCases('Button', {
|
|
88
|
+
Default: () => <button type="button">Hi</button>,
|
|
89
|
+
}),
|
|
90
|
+
],
|
|
91
|
+
NO_CONFIG,
|
|
92
|
+
)
|
|
93
|
+
expect(render(state({})).headStyles).toBe('')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a configured style engine collects head styling for the render', () => {
|
|
97
|
+
const render = makeCaseRenderer(
|
|
98
|
+
[
|
|
99
|
+
defineCases('Button', {
|
|
100
|
+
Default: () => <button type="button">Hi</button>,
|
|
101
|
+
}),
|
|
102
|
+
],
|
|
103
|
+
{ ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
|
|
104
|
+
)
|
|
105
|
+
const result = render(state({}))
|
|
106
|
+
expect(result.html).toContain('Hi')
|
|
107
|
+
expect(result.headStyles).toContain('data-stub=')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('each render gets its own isolated collector (no cross-case bleed)', () => {
|
|
111
|
+
const render = makeCaseRenderer(
|
|
112
|
+
[
|
|
113
|
+
defineCases('Button', {
|
|
114
|
+
Default: () => <button type="button">Hi</button>,
|
|
115
|
+
}),
|
|
116
|
+
defineCases('Link', { Default: () => <a href="/x">Go</a> }),
|
|
117
|
+
],
|
|
118
|
+
{ ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
|
|
119
|
+
)
|
|
120
|
+
const a = render(state({ componentId: 'button' }))
|
|
121
|
+
const b = render(state({ componentId: 'link' }))
|
|
122
|
+
// Distinct per-render instance ids ⇒ a fresh store per render, not shared.
|
|
123
|
+
expect(a.headStyles).toContain('data-stub="1"')
|
|
124
|
+
expect(b.headStyles).toContain('data-stub="2"')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('a browser-only case runs no engine and emits no head styling', () => {
|
|
128
|
+
const render = makeCaseRenderer(
|
|
129
|
+
[
|
|
130
|
+
defineCases(
|
|
131
|
+
'Canvas',
|
|
132
|
+
{ Default: () => <canvas /> },
|
|
133
|
+
{ browserOnly: true },
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
{ ...NO_CONFIG, styleEngines: [stubEngine({ n: 0 })] },
|
|
137
|
+
)
|
|
138
|
+
const result = render(state({ componentId: 'canvas' }))
|
|
139
|
+
expect(result).toEqual({ html: '', browserOnly: true })
|
|
140
|
+
expect(result.headStyles).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { findCase } from '../core/catalog'
|
|
2
|
+
import type { CaseModule, DisplayCaseConfig } from '../index'
|
|
3
|
+
import { renderWithStyles } from './collect-styles'
|
|
4
|
+
import { type CaseTreeState, caseTree, NOOP_GOTO } from './render-node'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side case rendering. The codegen'd SSR entry (see
|
|
8
|
+
* `codegenSsrEntry`) imports every discovered case module plus the consumer
|
|
9
|
+
* config, hands them here, and exports the resulting `renderCaseToHtml`. The
|
|
10
|
+
* server imports that freshly-built bundle each rebuild — the bundle inlines the
|
|
11
|
+
* case source from disk, so its modules are always current, sidestepping the
|
|
12
|
+
* per-path module cache that forces the manifest into a subprocess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CaseHtmlResult {
|
|
16
|
+
/** Pre-rendered `#root` inner markup, or `''` when the case is browser-only. */
|
|
17
|
+
html: string
|
|
18
|
+
/** True when the case could not be rendered outside a browser (it threw under
|
|
19
|
+
* `renderToString`, or no such case exists to even attempt). */
|
|
20
|
+
browserOnly: boolean
|
|
21
|
+
/** The throw's message, for the server to log once per browser-only case. */
|
|
22
|
+
error?: string
|
|
23
|
+
/** Render-time (CSS-in-JS) styling collected by the configured style engines,
|
|
24
|
+
* as `<head>` markup to place after the document's static styles. `''` (or
|
|
25
|
+
* absent) when no engine is configured or none produced styling. */
|
|
26
|
+
headStyles?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CaseRenderer = (state: CaseTreeState) => CaseHtmlResult
|
|
30
|
+
|
|
31
|
+
export function makeCaseRenderer(
|
|
32
|
+
modules: CaseModule[],
|
|
33
|
+
config: DisplayCaseConfig,
|
|
34
|
+
): CaseRenderer {
|
|
35
|
+
return function renderCaseToHtml(state: CaseTreeState): CaseHtmlResult {
|
|
36
|
+
// A component declared `browserOnly` opts out of server rendering: skip the
|
|
37
|
+
// attempt (no throw, no log) and let the client mount it.
|
|
38
|
+
const found = findCase(modules, state.componentId, state.caseId)
|
|
39
|
+
if (found?.module.browserOnly) return { html: '', browserOnly: true }
|
|
40
|
+
try {
|
|
41
|
+
// Apply any configured style engines around the case tree so render-time
|
|
42
|
+
// CSS-in-JS styling (emotion/MUI, styled-components…) is collected and
|
|
43
|
+
// delivered before scripting. No engines ⇒ a plain render, `headStyles`
|
|
44
|
+
// `''`, document byte-identical to before.
|
|
45
|
+
const { html, headStyles } = renderWithStyles(
|
|
46
|
+
caseTree(modules, config, state, NOOP_GOTO),
|
|
47
|
+
config.styleEngines,
|
|
48
|
+
)
|
|
49
|
+
return { html, browserOnly: false, headStyles }
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// The case — or a component it renders — needs a browser: it touched a
|
|
52
|
+
// browser-only API (window, layout measurement, canvas…) under
|
|
53
|
+
// `renderToString`. Don't fail the document; emit no server markup for
|
|
54
|
+
// this case and let the client mount it. The server records it so later
|
|
55
|
+
// requests skip the server attempt and so the author gets one log line.
|
|
56
|
+
return {
|
|
57
|
+
html: '',
|
|
58
|
+
browserOnly: true,
|
|
59
|
+
error: err instanceof Error ? err.message : String(err),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Manifest } from '../core/manifest'
|
|
3
|
+
import { renderShellToHtml } from './ssr-shell'
|
|
4
|
+
|
|
5
|
+
const manifest: Manifest = {
|
|
6
|
+
title: 'Showcase',
|
|
7
|
+
components: [
|
|
8
|
+
{
|
|
9
|
+
id: 'button',
|
|
10
|
+
name: 'Button',
|
|
11
|
+
level: 'atom',
|
|
12
|
+
isFlow: false,
|
|
13
|
+
caseFile: 'src/Button.case.tsx',
|
|
14
|
+
placardDoc: null,
|
|
15
|
+
cases: [
|
|
16
|
+
{
|
|
17
|
+
id: 'default',
|
|
18
|
+
name: 'Default',
|
|
19
|
+
browseUrl: '/c/button/default',
|
|
20
|
+
renderUrl: '/render/button/default',
|
|
21
|
+
tweaks: null,
|
|
22
|
+
transitions: [],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
primer: false,
|
|
28
|
+
landing: 'library',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('renderShellToHtml', () => {
|
|
32
|
+
test('server-renders the browse chrome to markup', () => {
|
|
33
|
+
const result = renderShellToHtml({
|
|
34
|
+
manifest,
|
|
35
|
+
pathname: '/c/button/default',
|
|
36
|
+
search: '',
|
|
37
|
+
theme: 'dark',
|
|
38
|
+
a11y: false,
|
|
39
|
+
})
|
|
40
|
+
expect(result.ssr).toBe(true)
|
|
41
|
+
expect(result.html.length).toBeGreaterThan(0)
|
|
42
|
+
// The seeded component name reaches the rendered nav.
|
|
43
|
+
expect(result.html).toContain('Button')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('renders the primer landing without throwing when one is configured', () => {
|
|
47
|
+
const result = renderShellToHtml({
|
|
48
|
+
manifest: { ...manifest, primer: true, landing: 'primer' },
|
|
49
|
+
pathname: '/',
|
|
50
|
+
search: '',
|
|
51
|
+
theme: 'light',
|
|
52
|
+
a11y: true,
|
|
53
|
+
})
|
|
54
|
+
expect(result.ssr).toBe(true)
|
|
55
|
+
expect(result.html.length).toBeGreaterThan(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import type { Manifest } from '../core/manifest'
|
|
4
|
+
import { Shell } from '../ui/shell'
|
|
5
|
+
import { parseRoute, type Theme } from '../ui/shell-core'
|
|
6
|
+
import type { ShellSeed } from '../ui/use-shell'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Server-side render of the browse shell. Unlike the case/primer renderers, the
|
|
10
|
+
* shell depends only on the manifest *data* and the request route — not on any
|
|
11
|
+
* consumer case module — so it renders in-process from the in-memory manifest
|
|
12
|
+
* with no per-rebuild bundle. The same `<Shell seed=…>` the client hydrates is
|
|
13
|
+
* rendered here, seeded from the request, so the two agree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ShellHtmlResult {
|
|
17
|
+
/** Pre-rendered `#root` inner markup, or `''` when the shell could not render. */
|
|
18
|
+
html: string
|
|
19
|
+
/** Whether `html` is present, so the client adopts instead of mounting fresh. */
|
|
20
|
+
ssr: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderShellToHtml(args: {
|
|
24
|
+
manifest: Manifest
|
|
25
|
+
pathname: string
|
|
26
|
+
search: string
|
|
27
|
+
theme: Theme
|
|
28
|
+
a11y: boolean
|
|
29
|
+
}): ShellHtmlResult {
|
|
30
|
+
const route = parseRoute(args.pathname, args.search)
|
|
31
|
+
const seed: ShellSeed = {
|
|
32
|
+
manifest: args.manifest,
|
|
33
|
+
route,
|
|
34
|
+
theme: args.theme,
|
|
35
|
+
a11y: args.a11y,
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
<StrictMode>
|
|
40
|
+
<Shell seed={seed} />
|
|
41
|
+
</StrictMode>,
|
|
42
|
+
)
|
|
43
|
+
return { html, ssr: true }
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// The shell is first-party code; a throw here is a defect, not a graceful
|
|
46
|
+
// case. Don't fail the document — serve it empty for the client to mount,
|
|
47
|
+
// and surface the error so it gets fixed.
|
|
48
|
+
console.warn(
|
|
49
|
+
'[display-case] shell server-render failed; the client will render it:',
|
|
50
|
+
err instanceof Error ? err.message : String(err),
|
|
51
|
+
)
|
|
52
|
+
return { html: '', ssr: false }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { BuildDescriptor } from '../commands/publish'
|
|
4
|
+
import type { Manifest } from '../core/manifest'
|
|
5
|
+
import { primerDoc, renderDoc, shellDoc } from '../render/documents'
|
|
6
|
+
import type { PrimerHtmlResult } from '../render/ssr-primer'
|
|
7
|
+
import type { CaseRenderer } from '../render/ssr-render'
|
|
8
|
+
import { renderShellToHtml } from '../render/ssr-shell'
|
|
9
|
+
import type { Theme } from '../ui/shell-core'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The production host for a published build. It serves the pre-rendered shell,
|
|
13
|
+
* isolated case render, and primer documents — rendered on request through the
|
|
14
|
+
* SAME renderers the dev server uses (`ssr-shell`/`ssr-render`/`ssr-primer`) —
|
|
15
|
+
* plus the content-hashed assets, with hosting-appropriate caching, a health
|
|
16
|
+
* endpoint, and base-path support. It carries NONE of the dev machinery: no
|
|
17
|
+
* watcher, no rebuild, no live-reload stream, no on-demand a11y, no dev
|
|
18
|
+
* endpoints. Build once, serve.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ASSET_CACHE = 'public, max-age=31536000, immutable'
|
|
22
|
+
const HTML_CACHE = 'no-cache'
|
|
23
|
+
|
|
24
|
+
interface Loaded {
|
|
25
|
+
buildDir: string
|
|
26
|
+
descriptor: BuildDescriptor
|
|
27
|
+
manifest: Manifest
|
|
28
|
+
renderCase: CaseRenderer
|
|
29
|
+
renderPrimer: (() => PrimerHtmlResult) | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function load(buildDir: string): Promise<Loaded> {
|
|
33
|
+
const descriptor = (await Bun.file(
|
|
34
|
+
join(buildDir, 'dc-build.json'),
|
|
35
|
+
).json()) as BuildDescriptor
|
|
36
|
+
const manifest = (await Bun.file(
|
|
37
|
+
join(buildDir, 'manifest.json'),
|
|
38
|
+
).json()) as Manifest
|
|
39
|
+
const ssrMod = (await import(join(buildDir, 'server', 'ssr-entry.js'))) as {
|
|
40
|
+
renderCaseToHtml: CaseRenderer
|
|
41
|
+
}
|
|
42
|
+
let renderPrimer: (() => PrimerHtmlResult) | null = null
|
|
43
|
+
if (descriptor.hasPrimer) {
|
|
44
|
+
const pMod = (await import(
|
|
45
|
+
join(buildDir, 'server', 'ssr-primer-entry.js')
|
|
46
|
+
)) as { renderPrimerToHtml: () => PrimerHtmlResult }
|
|
47
|
+
renderPrimer = pMod.renderPrimerToHtml
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
buildDir,
|
|
51
|
+
descriptor,
|
|
52
|
+
manifest,
|
|
53
|
+
renderCase: ssrMod.renderCaseToHtml,
|
|
54
|
+
renderPrimer,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseRenderState(url: URL) {
|
|
59
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
60
|
+
const p = url.searchParams
|
|
61
|
+
const tweaks: Record<string, string> = {}
|
|
62
|
+
for (const [k, v] of p) if (k.startsWith('t.')) tweaks[k.slice(2)] = v
|
|
63
|
+
const widthParam = p.get('width')
|
|
64
|
+
// path shape (after base strip): /render/<component>/<case>
|
|
65
|
+
return {
|
|
66
|
+
componentId: parts[1] ?? '',
|
|
67
|
+
caseId: parts[2] ?? '',
|
|
68
|
+
theme: (p.get('theme') === 'dark' ? 'dark' : 'light') as Theme,
|
|
69
|
+
width: widthParam ? Number(widthParam) : null,
|
|
70
|
+
tweaks,
|
|
71
|
+
fit: p.get('fit') === '1',
|
|
72
|
+
transparent: p.get('transparent') === '1',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render the document for an internal path (base already stripped) + query.
|
|
77
|
+
* Returns null for non-document paths (assets/health handled by the caller). */
|
|
78
|
+
function documentFor(loaded: Loaded, path: string, url: URL): string {
|
|
79
|
+
const { descriptor, manifest, renderCase, renderPrimer } = loaded
|
|
80
|
+
const assets = descriptor.assets
|
|
81
|
+
const theme: Theme =
|
|
82
|
+
url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
|
|
83
|
+
|
|
84
|
+
if (path === '/render/primer') {
|
|
85
|
+
let markup = ''
|
|
86
|
+
let ssr = false
|
|
87
|
+
let headStyles: string | undefined
|
|
88
|
+
if (renderPrimer) {
|
|
89
|
+
const r = renderPrimer()
|
|
90
|
+
if (!r.browserOnly) {
|
|
91
|
+
markup = r.html
|
|
92
|
+
ssr = true
|
|
93
|
+
headStyles = r.headStyles
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return primerDoc({
|
|
97
|
+
tokensCss: descriptor.tokensCss,
|
|
98
|
+
globalCss: descriptor.globalCss,
|
|
99
|
+
vitrineCss: descriptor.vitrineCss,
|
|
100
|
+
theme,
|
|
101
|
+
markup,
|
|
102
|
+
ssr,
|
|
103
|
+
headStyles,
|
|
104
|
+
assets,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (path === '/render' || path.startsWith('/render/')) {
|
|
109
|
+
const rs = parseRenderState(url)
|
|
110
|
+
let markup = ''
|
|
111
|
+
let ssr = false
|
|
112
|
+
let headStyles: string | undefined
|
|
113
|
+
if (rs.componentId && rs.caseId) {
|
|
114
|
+
const r = renderCase({
|
|
115
|
+
componentId: rs.componentId,
|
|
116
|
+
caseId: rs.caseId,
|
|
117
|
+
width: rs.width,
|
|
118
|
+
tweaks: rs.tweaks,
|
|
119
|
+
})
|
|
120
|
+
if (!r.browserOnly) {
|
|
121
|
+
markup = r.html
|
|
122
|
+
ssr = true
|
|
123
|
+
headStyles = r.headStyles
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return renderDoc({
|
|
127
|
+
globalCss: descriptor.globalCss,
|
|
128
|
+
vitrineCss: descriptor.vitrineCss,
|
|
129
|
+
theme: rs.theme,
|
|
130
|
+
transparent: rs.transparent,
|
|
131
|
+
fit: rs.fit,
|
|
132
|
+
markup,
|
|
133
|
+
ssr,
|
|
134
|
+
headStyles,
|
|
135
|
+
assets,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Shell: `/`, `/primer`, every `/c/...` deep link.
|
|
140
|
+
const shell = renderShellToHtml({
|
|
141
|
+
manifest,
|
|
142
|
+
pathname: path,
|
|
143
|
+
search: url.search,
|
|
144
|
+
theme,
|
|
145
|
+
a11y: false,
|
|
146
|
+
})
|
|
147
|
+
return shellDoc({
|
|
148
|
+
title: descriptor.title,
|
|
149
|
+
tokensCss: descriptor.tokensCss,
|
|
150
|
+
globalCss: descriptor.globalCss,
|
|
151
|
+
vitrineCss: descriptor.vitrineCss,
|
|
152
|
+
theme,
|
|
153
|
+
markup: shell.html,
|
|
154
|
+
ssr: shell.ssr,
|
|
155
|
+
manifest,
|
|
156
|
+
a11y: false,
|
|
157
|
+
assets,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function startProdServer(
|
|
162
|
+
buildDir: string,
|
|
163
|
+
opts: { port?: number } = {},
|
|
164
|
+
) {
|
|
165
|
+
const loaded = await load(buildDir)
|
|
166
|
+
const base = loaded.descriptor.base
|
|
167
|
+
|
|
168
|
+
const server = Bun.serve({
|
|
169
|
+
port: opts.port ?? 3000,
|
|
170
|
+
async fetch(req) {
|
|
171
|
+
const url = new URL(req.url)
|
|
172
|
+
let path = url.pathname
|
|
173
|
+
if (base && (path === base || path.startsWith(`${base}/`))) {
|
|
174
|
+
path = path.slice(base.length) || '/'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (path === '/health') return new Response('ok')
|
|
178
|
+
|
|
179
|
+
if (path.startsWith('/assets/')) {
|
|
180
|
+
const file = Bun.file(
|
|
181
|
+
join(buildDir, 'assets', path.slice('/assets/'.length)),
|
|
182
|
+
)
|
|
183
|
+
if (!(await file.exists())) {
|
|
184
|
+
return new Response('not found', { status: 404 })
|
|
185
|
+
}
|
|
186
|
+
return new Response(file, { headers: { 'cache-control': ASSET_CACHE } })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Response(documentFor(loaded, path, url), {
|
|
190
|
+
headers: {
|
|
191
|
+
'content-type': 'text/html; charset=utf-8',
|
|
192
|
+
'cache-control': HTML_CACHE,
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
return server
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Crawl every address and write complete HTML files, so the build can be hosted
|
|
202
|
+
* with no running server. Files are keyed by *path* (default theme + tweaks);
|
|
203
|
+
* address-encoded variations resolve on the client after hydration — logged so
|
|
204
|
+
* the boundary is explicit, not silent.
|
|
205
|
+
*/
|
|
206
|
+
export async function writeStaticExport(buildDir: string): Promise<void> {
|
|
207
|
+
const loaded = await load(buildDir)
|
|
208
|
+
const { manifest } = loaded
|
|
209
|
+
const write = async (route: string, file: string) => {
|
|
210
|
+
const url = new URL(`http://static${route}`)
|
|
211
|
+
const html = documentFor(loaded, url.pathname, url)
|
|
212
|
+
const abs = join(buildDir, file)
|
|
213
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
214
|
+
await Bun.write(abs, html)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await write('/', 'index.html')
|
|
218
|
+
if (loaded.descriptor.hasPrimer) {
|
|
219
|
+
await write('/primer', 'primer/index.html')
|
|
220
|
+
await write('/render/primer', 'render/primer/index.html')
|
|
221
|
+
}
|
|
222
|
+
let pages = 2
|
|
223
|
+
for (const c of manifest.components) {
|
|
224
|
+
for (const cs of c.cases) {
|
|
225
|
+
await write(`/c/${c.id}/${cs.id}`, `c/${c.id}/${cs.id}/index.html`)
|
|
226
|
+
await write(
|
|
227
|
+
`/render/${c.id}/${cs.id}`,
|
|
228
|
+
`render/${c.id}/${cs.id}/index.html`,
|
|
229
|
+
)
|
|
230
|
+
pages += 2
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log(
|
|
234
|
+
` static export: ${pages} page(s). Note: query-encoded tweak/theme ` +
|
|
235
|
+
'variations have no per-path file — they resolve on the client after hydration.',
|
|
236
|
+
)
|
|
237
|
+
}
|