@awarebydefault/display-case 1.0.0 → 1.0.2
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/package.json +6 -6
- package/src/commands/publish.ts +14 -3
- package/src/core/mdx-plugin.test.ts +26 -0
- package/src/core/mdx-plugin.ts +38 -1
- package/src/core/pin-react.test.ts +105 -0
- package/src/core/pin-react.ts +48 -0
- package/src/server/server.ts +11 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awarebydefault/display-case",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A Bun-native, AI-friendly component showcase — a lightweight alternative to Storybook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jake Uskoski <jake@awarebydefault.com>",
|
|
@@ -73,8 +73,10 @@
|
|
|
73
73
|
"e2e": "playwright test",
|
|
74
74
|
"e2e:headed": "playwright test --headed",
|
|
75
75
|
"e2e:install": "playwright install chromium",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
76
|
+
"changeset": "changeset",
|
|
77
|
+
"changeset:status": "changeset status",
|
|
78
|
+
"changeset:version": "changeset version",
|
|
79
|
+
"changeset:publish": "changeset publish",
|
|
78
80
|
"prepare": "husky"
|
|
79
81
|
},
|
|
80
82
|
"peerDependencies": {
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
},
|
|
94
96
|
"devDependencies": {
|
|
95
97
|
"@biomejs/biome": "^2.5.0",
|
|
98
|
+
"@changesets/cli": "^2.27.11",
|
|
96
99
|
"@commitlint/cli": "^19.6.0",
|
|
97
100
|
"@commitlint/config-conventional": "^19.6.0",
|
|
98
101
|
"@emotion/cache": "^11.14.0",
|
|
@@ -100,14 +103,11 @@
|
|
|
100
103
|
"@emotion/server": "^11.11.0",
|
|
101
104
|
"@fission-ai/openspec": "1.4.1",
|
|
102
105
|
"@playwright/test": "^1.60.0",
|
|
103
|
-
"@semantic-release/changelog": "^6.0.3",
|
|
104
|
-
"@semantic-release/git": "^10.0.1",
|
|
105
106
|
"@types/bun": "^1.2.0",
|
|
106
107
|
"@types/pngjs": "^6.0.5",
|
|
107
108
|
"@types/react": "^19",
|
|
108
109
|
"@types/react-dom": "^19",
|
|
109
110
|
"husky": "^9.1.7",
|
|
110
|
-
"semantic-release": "^25.0.5",
|
|
111
111
|
"typescript": "^5.7.0"
|
|
112
112
|
}
|
|
113
113
|
}
|
package/src/commands/publish.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveConfig,
|
|
11
11
|
} from '../core/discovery'
|
|
12
12
|
import { mdxPlugin } from '../core/mdx-plugin'
|
|
13
|
+
import { pinReact } from '../core/pin-react'
|
|
13
14
|
import type { DisplayCaseConfig } from '../index'
|
|
14
15
|
import { getManifest } from '../server/server'
|
|
15
16
|
|
|
@@ -166,7 +167,9 @@ export async function publish(
|
|
|
166
167
|
|
|
167
168
|
const defines = await buildDefines(pkgDir)
|
|
168
169
|
|
|
169
|
-
// Browser bundle: minified, content-hashed, production React.
|
|
170
|
+
// Browser bundle: minified, content-hashed, production React. pinReact keeps
|
|
171
|
+
// Display Case's render runtime and the consumer's components on one React copy
|
|
172
|
+
// (see pinReact for the dual-React bug it prevents).
|
|
170
173
|
const browserEntries = [BROWSER_ENTRY, renderEntry]
|
|
171
174
|
if (primerEntry) browserEntries.push(primerEntry)
|
|
172
175
|
const browser = await Bun.build({
|
|
@@ -175,7 +178,7 @@ export async function publish(
|
|
|
175
178
|
target: 'browser',
|
|
176
179
|
minify: true,
|
|
177
180
|
sourcemap: 'none',
|
|
178
|
-
plugins: [mdxPlugin()],
|
|
181
|
+
plugins: [mdxPlugin(), pinReact(pkgDir)],
|
|
179
182
|
define: defines,
|
|
180
183
|
naming: {
|
|
181
184
|
entry: '[name]-[hash].[ext]',
|
|
@@ -196,7 +199,15 @@ export async function publish(
|
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
// SSR renderers for the production server: built once (no watching), imported
|
|
199
|
-
// by `prod-server`. React stays external (
|
|
202
|
+
// by `prod-server`. React stays external here (unlike the browser bundle and
|
|
203
|
+
// the dev server's in-process SSR, which pin React to the consumer copy): a
|
|
204
|
+
// published build deploys with its own `bun install`, so the prod process has a
|
|
205
|
+
// single React already. Leaving it external keeps `prod-server`'s own chrome
|
|
206
|
+
// renderer (`ssr-shell`, which needs `react-dom/server` at runtime regardless)
|
|
207
|
+
// and these bundled case renderers on that one copy — bundling React here would
|
|
208
|
+
// instead put a second copy in the prod process for no benefit. The dual-React
|
|
209
|
+
// hazard pinReact addresses comes from a temp/global *tool* install resolving a
|
|
210
|
+
// different React than the consumer's components; a clean deploy has neither.
|
|
200
211
|
const ssrEntries = [ssrEntry]
|
|
201
212
|
if (ssrPrimerEntry) ssrEntries.push(ssrPrimerEntry)
|
|
202
213
|
const ssr = await Bun.build({
|
|
@@ -57,4 +57,30 @@ describe('mdxPlugin', () => {
|
|
|
57
57
|
await rm(dir, { recursive: true, force: true })
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
|
+
|
|
61
|
+
test('emits an absolute, self-resolved markdown-to-jsx specifier', async () => {
|
|
62
|
+
const { run } = captureOnLoad()
|
|
63
|
+
const dir = await makeTempDir()
|
|
64
|
+
try {
|
|
65
|
+
const file = join(dir, 'doc.mdx')
|
|
66
|
+
await Bun.write(file, '# Title\n\nSome prose.\n')
|
|
67
|
+
const result = await run({ path: file })
|
|
68
|
+
// The compiled primer is loaded from the consumer's tree, where a bare
|
|
69
|
+
// `markdown-to-jsx` would not resolve. The plugin anchors it at Display
|
|
70
|
+
// Case's own install so the consumer never needs to declare the dep.
|
|
71
|
+
const resolved = Bun.resolveSync('markdown-to-jsx', import.meta.dir)
|
|
72
|
+
expect(result.contents).toContain(
|
|
73
|
+
`import __Md from ${JSON.stringify(resolved)}`,
|
|
74
|
+
)
|
|
75
|
+
// Never a bare specifier — that is the bug this guards against.
|
|
76
|
+
expect(result.contents).not.toContain(
|
|
77
|
+
"import __Md from 'markdown-to-jsx'",
|
|
78
|
+
)
|
|
79
|
+
expect(result.contents).not.toContain(
|
|
80
|
+
'import __Md from "markdown-to-jsx"',
|
|
81
|
+
)
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(dir, { recursive: true, force: true })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
60
86
|
})
|
package/src/core/mdx-plugin.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import type { BunPlugin } from 'bun'
|
|
2
2
|
import { mdxToTsx } from './mdx-lite'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Absolute path to `markdown-to-jsx`, resolved from Display Case's own location
|
|
6
|
+
* (this module), memoized on first use.
|
|
7
|
+
*
|
|
8
|
+
* The compiled primer module is loaded from inside the *consumer* package's tree
|
|
9
|
+
* (its `primer.mdx` is the bundle entry), so a bare `import 'markdown-to-jsx'`
|
|
10
|
+
* would be resolved relative to the consumer — and `markdown-to-jsx` is a private
|
|
11
|
+
* dependency of `@awarebydefault/display-case`, not hoisted into the consumer's
|
|
12
|
+
* scope, so that resolution fails with `Could not resolve "markdown-to-jsx"`.
|
|
13
|
+
* Emitting an absolute path anchored at Display Case's own install makes the
|
|
14
|
+
* import resolve regardless of the consumer's `node_modules` layout, so authoring
|
|
15
|
+
* a Markdown/MDX primer never requires the consumer to redeclare the dep. It also
|
|
16
|
+
* resolves to the same physical module the `.placard.md` DocPanel imports, so Bun
|
|
17
|
+
* dedupes the two into one bundled copy.
|
|
18
|
+
*/
|
|
19
|
+
let cachedMarkdownSpecifier: string | undefined
|
|
20
|
+
function markdownSpecifier(): string {
|
|
21
|
+
if (cachedMarkdownSpecifier === undefined) {
|
|
22
|
+
try {
|
|
23
|
+
cachedMarkdownSpecifier = Bun.resolveSync(
|
|
24
|
+
'markdown-to-jsx',
|
|
25
|
+
import.meta.dir,
|
|
26
|
+
)
|
|
27
|
+
} catch {
|
|
28
|
+
// Fall back to the bare specifier (matches mdx-lite's own default) for the
|
|
29
|
+
// case where a consumer does carry the dep and resolution from here fails.
|
|
30
|
+
cachedMarkdownSpecifier = 'markdown-to-jsx'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return cachedMarkdownSpecifier
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
/**
|
|
5
37
|
* Bun bundler plugin that compiles `.mdx` to TSX on load, so the Primer entry can
|
|
6
38
|
* `import` an authored `.mdx` document and the components it pulls in.
|
|
@@ -23,7 +55,12 @@ export function mdxPlugin(): BunPlugin {
|
|
|
23
55
|
setup(build) {
|
|
24
56
|
build.onLoad({ filter: /\.mdx$/ }, async (args) => {
|
|
25
57
|
const source = await Bun.file(args.path).text()
|
|
26
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
contents: mdxToTsx(source, {
|
|
60
|
+
markdownSpecifier: markdownSpecifier(),
|
|
61
|
+
}),
|
|
62
|
+
loader: 'tsx',
|
|
63
|
+
}
|
|
27
64
|
})
|
|
28
65
|
},
|
|
29
66
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { makeTempDir } from '../testing/test-helpers'
|
|
5
|
+
import { pinReact } from './pin-react'
|
|
6
|
+
|
|
7
|
+
type OnResolveArgs = { path: string }
|
|
8
|
+
type OnResolveResult = { path: string }
|
|
9
|
+
type OnResolveCb = (args: OnResolveArgs) => OnResolveResult
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drive the plugin without a real Bun build: capture the `onResolve` handler it
|
|
13
|
+
* registers (and its filter), then invoke it directly.
|
|
14
|
+
*/
|
|
15
|
+
function captureOnResolve(pkgDir: string): {
|
|
16
|
+
filter: RegExp
|
|
17
|
+
run: OnResolveCb
|
|
18
|
+
} {
|
|
19
|
+
const calls: Array<{ filter: RegExp; cb: OnResolveCb }> = []
|
|
20
|
+
const build = {
|
|
21
|
+
onResolve: (opts: { filter: RegExp }, cb: OnResolveCb) =>
|
|
22
|
+
calls.push({ filter: opts.filter, cb }),
|
|
23
|
+
}
|
|
24
|
+
const plugin = pinReact(pkgDir)
|
|
25
|
+
plugin.setup(build as unknown as Parameters<typeof plugin.setup>[0])
|
|
26
|
+
if (!calls[0]) throw new Error('plugin registered no onResolve handler')
|
|
27
|
+
return { filter: calls[0].filter, run: calls[0].cb }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('pinReact', () => {
|
|
31
|
+
test('matches react / react-dom and their sub-paths, but not lookalikes', () => {
|
|
32
|
+
const { filter } = captureOnResolve(process.cwd())
|
|
33
|
+
for (const id of [
|
|
34
|
+
'react',
|
|
35
|
+
'react-dom',
|
|
36
|
+
'react-dom/client',
|
|
37
|
+
'react-dom/server',
|
|
38
|
+
'react/jsx-runtime',
|
|
39
|
+
'react/jsx-dev-runtime',
|
|
40
|
+
]) {
|
|
41
|
+
expect(filter.test(id)).toBe(true)
|
|
42
|
+
}
|
|
43
|
+
// Unrelated packages that merely start with "react" must not be captured.
|
|
44
|
+
for (const id of ['react-foo', 'react-router', '@scope/react', 'preact']) {
|
|
45
|
+
expect(filter.test(id)).toBe(false)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('resolves every react specifier from the given pkgDir', () => {
|
|
50
|
+
const dir = process.cwd()
|
|
51
|
+
const { run } = captureOnResolve(dir)
|
|
52
|
+
for (const id of [
|
|
53
|
+
'react',
|
|
54
|
+
'react-dom',
|
|
55
|
+
'react-dom/client',
|
|
56
|
+
'react/jsx-runtime',
|
|
57
|
+
]) {
|
|
58
|
+
expect(run({ path: id }).path).toBe(Bun.resolveSync(id, dir))
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('throws a helpful error when react is not resolvable from pkgDir', async () => {
|
|
63
|
+
const dir = await makeTempDir()
|
|
64
|
+
try {
|
|
65
|
+
const { run } = captureOnResolve(dir)
|
|
66
|
+
expect(() => run({ path: 'react' })).toThrow(
|
|
67
|
+
/Install react and react-dom/,
|
|
68
|
+
)
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(dir, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('bundles the render runtime and a hook component into one browser bundle', async () => {
|
|
75
|
+
const dir = await makeTempDir()
|
|
76
|
+
try {
|
|
77
|
+
// The pairing that splits across two React copies without pinning: the
|
|
78
|
+
// renderer (`react-dom/client`) plus a hook-using component. Bundling one
|
|
79
|
+
// copy per resolved path, and pinning every specifier to a single pkgDir,
|
|
80
|
+
// is what guarantees they share a React (proven by the resolution test
|
|
81
|
+
// above); here we assert the plugin keeps a real build working end to end.
|
|
82
|
+
const entry = join(dir, 'entry.tsx')
|
|
83
|
+
await Bun.write(
|
|
84
|
+
entry,
|
|
85
|
+
[
|
|
86
|
+
"import { createRoot } from 'react-dom/client'",
|
|
87
|
+
"import { useState } from 'react'",
|
|
88
|
+
'export function App() {',
|
|
89
|
+
' const [n] = useState(0)',
|
|
90
|
+
' return n',
|
|
91
|
+
'}',
|
|
92
|
+
'export { createRoot }',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
)
|
|
95
|
+
const result = await Bun.build({
|
|
96
|
+
entrypoints: [entry],
|
|
97
|
+
target: 'browser',
|
|
98
|
+
plugins: [pinReact(process.cwd())],
|
|
99
|
+
})
|
|
100
|
+
expect(result.success).toBe(true)
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(dir, { recursive: true, force: true })
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { BunPlugin } from 'bun'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bun bundler plugin that pins every `react` / `react-dom` specifier to the
|
|
5
|
+
* single copy installed in the **consumer** project (`pkgDir`).
|
|
6
|
+
*
|
|
7
|
+
* Why this is necessary: Display Case's own client runtime (`browser-entry`,
|
|
8
|
+
* `render-mount`) statically imports `react-dom/client` and `react`, whose bare
|
|
9
|
+
* specifiers resolve relative to **where Display Case itself is installed** —
|
|
10
|
+
* while the consumer's `*.case.tsx` files and their deps resolve relative to the
|
|
11
|
+
* **consumer project**. When those two installs differ (the common case for
|
|
12
|
+
* `bunx @awarebydefault/display-case`, which installs the tool — and its peer
|
|
13
|
+
* react/react-dom — into a temp prefix), the browser bundle ends up with *two*
|
|
14
|
+
* React instances. `react-dom` drives one React's dispatcher; the consumer's
|
|
15
|
+
* components read the other's (null) dispatcher → "Invalid hook call … more than
|
|
16
|
+
* one copy of React", and every hook-using component blanks. Hook-free
|
|
17
|
+
* components never touch the dispatcher, so they render and mask the bug.
|
|
18
|
+
*
|
|
19
|
+
* Forcing all react/react-dom resolution to `pkgDir` collapses the two copies to
|
|
20
|
+
* one regardless of how the tool was invoked (bunx temp prefix, global install,
|
|
21
|
+
* npx, pnpm's strict layout, hoisting differences). The renderer
|
|
22
|
+
* (`createRoot`/`hydrateRoot`/`renderToString`) then binds to the same React the
|
|
23
|
+
* consumer's components use.
|
|
24
|
+
*
|
|
25
|
+
* Resolve from `pkgDir`, NOT the package dir — the renderer must bind to the
|
|
26
|
+
* React the consumer's components import, not Display Case's own.
|
|
27
|
+
*/
|
|
28
|
+
export function pinReact(pkgDir: string): BunPlugin {
|
|
29
|
+
return {
|
|
30
|
+
name: 'display-case-pin-react',
|
|
31
|
+
setup(build) {
|
|
32
|
+
// Matches `react`, `react-dom`, and their sub-paths (`react-dom/client`,
|
|
33
|
+
// `react-dom/server`, `react/jsx-runtime`, `react/jsx-dev-runtime`) — but
|
|
34
|
+
// not unrelated packages like `react-foo` or `@scope/react`.
|
|
35
|
+
build.onResolve({ filter: /^(react|react-dom)(\/.*)?$/ }, (args) => {
|
|
36
|
+
try {
|
|
37
|
+
return { path: Bun.resolveSync(args.path, pkgDir) }
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Display Case could not resolve "${args.path}" from ${pkgDir}. ` +
|
|
41
|
+
'Install react and react-dom in the package you point Display Case at.',
|
|
42
|
+
{ cause },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '../core/discovery'
|
|
21
21
|
import type { Manifest } from '../core/manifest'
|
|
22
22
|
import { mdxPlugin } from '../core/mdx-plugin'
|
|
23
|
+
import { pinReact } from '../core/pin-react'
|
|
23
24
|
import type { DisplayCaseConfig } from '../index'
|
|
24
25
|
import type { PrimerHtmlResult } from '../render/ssr-primer'
|
|
25
26
|
import type { CaseRenderer } from '../render/ssr-render'
|
|
@@ -225,8 +226,10 @@ async function rebuild(
|
|
|
225
226
|
outdir,
|
|
226
227
|
target: 'browser',
|
|
227
228
|
// 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
|
-
|
|
229
|
+
// to JS on load; it's a no-op for builds without a primer entry. pinReact
|
|
230
|
+
// collapses Display Case's render runtime and the consumer's components onto
|
|
231
|
+
// a single React copy — see pinReact for the dual-React bug it prevents.
|
|
232
|
+
plugins: [mdxPlugin(), pinReact(pkgDir)],
|
|
230
233
|
// Inline the consumer's public env (BUN_PUBLIC_*) so a `process.env.*` read
|
|
231
234
|
// in bundled code (e.g. the API base URL) doesn't survive as a literal that
|
|
232
235
|
// throws `process is not defined` in the browser. See publicEnvDefines.
|
|
@@ -248,8 +251,10 @@ async function rebuild(
|
|
|
248
251
|
// each rebuild because Bun caches imports by resolved path — a stable name
|
|
249
252
|
// would return the stale renderer after an edit (the same staleness that forces
|
|
250
253
|
// the manifest into a subprocess). The bundle inlines case source from disk, so
|
|
251
|
-
// importing the fresh file yields current modules.
|
|
252
|
-
//
|
|
254
|
+
// importing the fresh file yields current modules. pinReact bundles the
|
|
255
|
+
// consumer's React (instead of leaving it external) so `renderToString` and
|
|
256
|
+
// the consumer's components share one React — the same dual-React hazard the
|
|
257
|
+
// browser bundle faces, here for the in-process server render.
|
|
253
258
|
const ssrEntry = await codegenSsrEntry(pkgDir, files, configPath)
|
|
254
259
|
const ssrOutDir = join(cacheDir(pkgDir), 'ssr')
|
|
255
260
|
const ssrName = `ssr-entry-${++ssrBuildSeq}`
|
|
@@ -257,15 +262,8 @@ async function rebuild(
|
|
|
257
262
|
entrypoints: [ssrEntry],
|
|
258
263
|
outdir: ssrOutDir,
|
|
259
264
|
target: 'bun',
|
|
260
|
-
plugins: [mdxPlugin()],
|
|
265
|
+
plugins: [mdxPlugin(), pinReact(pkgDir)],
|
|
261
266
|
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
267
|
naming: {
|
|
270
268
|
entry: `${ssrName}.[ext]`,
|
|
271
269
|
chunk: '[name]-[hash].[ext]',
|
|
@@ -297,15 +295,8 @@ async function rebuild(
|
|
|
297
295
|
entrypoints: [ssrPrimerEntry],
|
|
298
296
|
outdir: ssrOutDir,
|
|
299
297
|
target: 'bun',
|
|
300
|
-
plugins: [mdxPlugin()],
|
|
298
|
+
plugins: [mdxPlugin(), pinReact(pkgDir)],
|
|
301
299
|
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
300
|
naming: {
|
|
310
301
|
entry: `${ssrPrimerName}.[ext]`,
|
|
311
302
|
chunk: '[name]-[hash].[ext]',
|