@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awarebydefault/display-case",
3
- "version": "1.0.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
- "release": "semantic-release",
77
- "release:dry": "semantic-release --dry-run --no-ci",
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
  }
@@ -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 (resolved at runtime).
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
  })
@@ -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 { contents: mdxToTsx(source), loader: 'tsx' }
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
+ }
@@ -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
- plugins: [mdxPlugin()],
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. React stays external so the
252
- // server resolves it from node_modules at import time.
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]',