@awarebydefault/display-case 1.0.1 → 1.1.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/docs/configuration.md +5 -1
- package/docs/testing.md +45 -8
- package/package.json +1 -1
- package/src/checks/check-reporter.test.ts +71 -0
- package/src/checks/check-reporter.ts +103 -0
- package/src/checks/check.ts +115 -35
- package/src/cli.ts +5 -1
- package/src/commands/publish.ts +14 -3
- package/src/core/pin-react.test.ts +105 -0
- package/src/core/pin-react.ts +48 -0
- package/src/index.ts +8 -0
- package/src/server/server.ts +11 -20
package/docs/configuration.md
CHANGED
|
@@ -34,7 +34,7 @@ The CLI looks for `display-case.config.ts` then `display-case.config.tsx` in the
|
|
|
34
34
|
| `baselineDir` | `string` | no | `.display-case/baselines` | Where visual-regression baselines are stored. |
|
|
35
35
|
| `tokens` | `{ allow?: string[] }` | no | none | Design-token conformance options for `--tokens`. `allow` lists custom-property names the package may reference but does not itself define (e.g. host-app-provided tokens). See [Testing](testing.md#token-conformance). |
|
|
36
36
|
| `providers` | `{ driver?, diff? }` | no | built-in | Override the visual-regression backend. When unset, the built-in Playwright/axe driver and pixelmatch/pngjs diff are loaded lazily. See [`providers`](#providers). |
|
|
37
|
-
| `check` | `{ defaultPhases?, structure? }` | no | none | Tune the `check` command: which phases run by default, and the structure phase's rules and severities. See [`check`](#check). |
|
|
37
|
+
| `check` | `{ defaultPhases?, concurrency?, structure? }` | no | none | Tune the `check` command: which phases run by default, how many variants the a11y/visual phases scan concurrently, and the structure phase's rules and severities. See [`check`](#check). |
|
|
38
38
|
| `a11y` | `{ enabled?, themes?, exclude?, startup? }` | no | off | Live accessibility surfacing in the running browse chrome. See [`a11y`](#a11y). |
|
|
39
39
|
|
|
40
40
|
### `title`
|
|
@@ -349,6 +349,9 @@ check: {
|
|
|
349
349
|
// opt out. An opted-out phase still runs when named explicitly (e.g. --visual).
|
|
350
350
|
defaultPhases: { visual: false },
|
|
351
351
|
|
|
352
|
+
// How many variants the a11y/visual phases scan concurrently. Default 4.
|
|
353
|
+
concurrency: 4,
|
|
354
|
+
|
|
352
355
|
structure: {
|
|
353
356
|
// Treat every structure warning as an error for the run (same as --strict).
|
|
354
357
|
strict: false,
|
|
@@ -365,6 +368,7 @@ check: {
|
|
|
365
368
|
```
|
|
366
369
|
|
|
367
370
|
- **`defaultPhases`** — a `Partial<Record<'tokens' | 'a11y' | 'visual' | 'structure', boolean>>`. Drop a phase from the bare `check` run (e.g. `visual` when no baselines are committed) while keeping it available via its flag.
|
|
371
|
+
- **`concurrency`** — how many variants the render phases (a11y/visual) scan at once, each on its own page from a shared browser. Default `4`; override per run with `--concurrency=N`. A custom `providers.driver` must tolerate concurrent `open()` calls (set `1` if it can't). See [Testing → Reporting and concurrency](testing.md#reporting-and-concurrency).
|
|
368
372
|
- **`structure.rules[id]`** — `false` disables the rule; `'warn'`/`'error'` enables it at that severity; an options object (`{ severity?, ignore?, thresholds? }`) enables it with overrides. Unset ⇒ the rule's default. The rule ids, defaults, and escape-hatch markers are listed in [Testing → Structure checks](testing.md#structure-checks).
|
|
369
373
|
- **`structure.strict`** — escalate all structure warnings to errors (the config equivalent of `check --strict`).
|
|
370
374
|
|
package/docs/testing.md
CHANGED
|
@@ -139,10 +139,12 @@ Escapes:
|
|
|
139
139
|
|
|
140
140
|
## Accessibility
|
|
141
141
|
|
|
142
|
-
For every case in every theme, the runner analyzes the page with axe-core and
|
|
142
|
+
For every case in every theme, the runner analyzes the page with axe-core. Each variant is reported as a test — `(pass)` or `(fail)`, with its own timing — in the shape of `bun test`, so a CI log can be grepped and summarized the same way a test run is. A failing variant is followed by each violation and its affected nodes; for colour-contrast, the failing element and the exact measured-vs-required pair, so a finding is fixable without re-running a browser:
|
|
143
143
|
|
|
144
144
|
```
|
|
145
|
-
a11y
|
|
145
|
+
a11y (pass) eyebrow/tones [light] [270.84ms]
|
|
146
|
+
a11y (fail) tweak-control/variants [dark] [412.30ms]
|
|
147
|
+
serious color-contrast: Elements must meet contrast ratio (2 node(s))
|
|
146
148
|
↳ .dcui-tweak-label #8a8073 on #ffffff = 3.87:1 (need 4.5:1) [12.0pt (16px) normal]
|
|
147
149
|
↳ .dcui-tweak-url #8a8073 on #ffffff = 3.87:1 (need 4.5:1) [12.0pt (16px) normal]
|
|
148
150
|
```
|
|
@@ -155,15 +157,17 @@ The isolated render document is a complete page (a `<title>`, `lang`, and a sing
|
|
|
155
157
|
|
|
156
158
|
## Visual regression
|
|
157
159
|
|
|
158
|
-
For every case in every theme, the runner takes a screenshot and compares it to a baseline PNG:
|
|
160
|
+
For every case in every theme, the runner takes a screenshot and compares it to a baseline PNG. Each variant is reported as a `bun test`-style test with its own timing:
|
|
159
161
|
|
|
160
|
-
- **No baseline yet** → the screenshot is recorded as the new baseline (counted as "recorded", not a failure).
|
|
161
|
-
- **Baseline matches** → pass
|
|
162
|
-
- **Baseline differs** →
|
|
163
|
-
- **Dimensions changed** →
|
|
162
|
+
- **No baseline yet** → the screenshot is recorded as the new baseline, reported `(record)` (counted as "recorded", not a failure).
|
|
163
|
+
- **Baseline matches** → `(pass)`.
|
|
164
|
+
- **Baseline differs** → `(fail)`. A `<case>.<theme>.diff.png` is written next to the baseline (its path is printed under the failing line) so you can inspect the change.
|
|
165
|
+
- **Dimensions changed** → `(fail)`. The new render is saved as `<case>.<theme>.actual.png` for inspection.
|
|
164
166
|
|
|
165
167
|
```
|
|
166
|
-
visual
|
|
168
|
+
visual (pass) eyebrow/tones [light] [87.75ms]
|
|
169
|
+
visual (fail) tweak-control/variants [light] [203.86ms]
|
|
170
|
+
differs from baseline → test/visual-baselines/tweak-control/variants.light.diff.png
|
|
167
171
|
```
|
|
168
172
|
|
|
169
173
|
The diff threshold is strict: any differing pixel counts as a change.
|
|
@@ -211,6 +215,39 @@ export default defineConfig({
|
|
|
211
215
|
> [`check.defaultPhases`](configuration.md)`: { visual: false }`; the phase still
|
|
212
216
|
> runs when asked explicitly (`--visual`) and in CI. This repo does exactly that.
|
|
213
217
|
|
|
218
|
+
## Reporting and concurrency
|
|
219
|
+
|
|
220
|
+
The a11y and visual phases report in the shape of `bun test`: one `(pass)` /
|
|
221
|
+
`(fail)` / `(record)` line per variant, each tagged with its own elapsed time
|
|
222
|
+
(high-resolution, via `Bun.nanoseconds()`), then a rolled-up summary — per-phase
|
|
223
|
+
counts, the overall `N pass` / `N fail`, and a `Ran N checks [time]` line whose
|
|
224
|
+
time is the **wall-clock** for the whole render run:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
a11y 42 pass 0 fail
|
|
228
|
+
visual 40 pass 2 fail 3 recorded
|
|
229
|
+
|
|
230
|
+
82 pass
|
|
231
|
+
2 fail
|
|
232
|
+
3 recorded
|
|
233
|
+
Ran 87 checks [12.41s] (concurrency 4)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Because the variants are scanned concurrently, the wall-clock is well below the
|
|
237
|
+
sum of the per-variant times. The fixed `(pass)`/`(fail)` tags are plain text (no
|
|
238
|
+
colour or glyphs to parse), so a CI step can grep and tally them like any test
|
|
239
|
+
run.
|
|
240
|
+
|
|
241
|
+
The render phases scan multiple variants at once — each on its own page from a
|
|
242
|
+
shared browser — to cut wall-clock. The default concurrency is **4**; override it
|
|
243
|
+
per run with `--concurrency=N` or globally with `check.concurrency` in the config.
|
|
244
|
+
A custom [`providers.driver`](configuration.md#providers) must tolerate
|
|
245
|
+
concurrent `open()` calls (set concurrency to `1` if it can't).
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
display-case check . --a11y --visual --concurrency=8
|
|
249
|
+
```
|
|
250
|
+
|
|
214
251
|
## Change-scoped checks
|
|
215
252
|
|
|
216
253
|
The a11y and visual phases re-render every case, which is wasteful in CI when a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awarebydefault/display-case",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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>",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
emptyTally,
|
|
4
|
+
formatDuration,
|
|
5
|
+
summaryLines,
|
|
6
|
+
testLine,
|
|
7
|
+
} from './check-reporter'
|
|
8
|
+
|
|
9
|
+
const MS = 1e6
|
|
10
|
+
const SEC = 1e9
|
|
11
|
+
|
|
12
|
+
describe('check-reporter: formatDuration', () => {
|
|
13
|
+
test('sub-second spans render as milliseconds with two decimals', () => {
|
|
14
|
+
expect(formatDuration(12.345 * MS)).toBe('12.35ms')
|
|
15
|
+
expect(formatDuration(0)).toBe('0.00ms')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('spans of a second or more render as seconds', () => {
|
|
19
|
+
expect(formatDuration(SEC)).toBe('1.00s')
|
|
20
|
+
expect(formatDuration(3.456 * SEC)).toBe('3.46s')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('check-reporter: testLine', () => {
|
|
25
|
+
test('a pass carries the (pass) tag, name, and timing', () => {
|
|
26
|
+
expect(testLine('a11y', 'Button/default [light]', 'pass', 12.34 * MS)).toBe(
|
|
27
|
+
' a11y (pass) Button/default [light] [12.34ms]',
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('phases pad to a common width so tags align', () => {
|
|
32
|
+
const a = testLine('a11y', 'X/y [light]', 'fail', MS)
|
|
33
|
+
const v = testLine('visual', 'X/y [light]', 'fail', MS)
|
|
34
|
+
expect(a.indexOf('(fail)')).toBe(v.indexOf('(fail)'))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a recorded baseline carries the (record) tag', () => {
|
|
38
|
+
expect(testLine('visual', 'X/y [dark]', 'record', MS)).toContain('(record)')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('check-reporter: summaryLines', () => {
|
|
43
|
+
test('rolls up per-phase tallies, totals, and wall-clock', () => {
|
|
44
|
+
const a11y = { ...emptyTally(), pass: 43, fail: 1 }
|
|
45
|
+
const visual = { ...emptyTally(), pass: 42, fail: 2, record: 3 }
|
|
46
|
+
const lines = summaryLines(
|
|
47
|
+
[
|
|
48
|
+
{ phase: 'a11y', tally: a11y },
|
|
49
|
+
{ phase: 'visual', tally: visual },
|
|
50
|
+
],
|
|
51
|
+
2.5 * SEC,
|
|
52
|
+
4,
|
|
53
|
+
)
|
|
54
|
+
const text = lines.join('\n')
|
|
55
|
+
expect(text).toContain('a11y 43 pass 1 fail')
|
|
56
|
+
expect(text).toContain('visual 42 pass 2 fail 3 recorded')
|
|
57
|
+
// 43+1 + 42+2+3 = 91 checks; 85 pass / 3 fail overall.
|
|
58
|
+
expect(text).toContain(' 85 pass')
|
|
59
|
+
expect(text).toContain(' 3 fail')
|
|
60
|
+
expect(text).toContain('Ran 91 checks [2.50s] (concurrency 4)')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a single check is not pluralized', () => {
|
|
64
|
+
const lines = summaryLines(
|
|
65
|
+
[{ phase: 'a11y', tally: { ...emptyTally(), pass: 1 } }],
|
|
66
|
+
MS,
|
|
67
|
+
1,
|
|
68
|
+
)
|
|
69
|
+
expect(lines.join('\n')).toContain('Ran 1 check [')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting for the render-phase (a11y + visual) progress output. Models
|
|
3
|
+
* each scanned variant as a "test" that passes or fails, in the shape of `bun
|
|
4
|
+
* test`'s reporter — a `(pass)`/`(fail)` tag, a stable name, and a per-test
|
|
5
|
+
* timing — so a CI log can be grepped and summarized the same way a test run is.
|
|
6
|
+
*
|
|
7
|
+
* Kept side-effect-free (no console, no I/O) so the formatting is unit-tested;
|
|
8
|
+
* `check.ts` owns the timing (via `Bun.nanoseconds()`), the browser, and the
|
|
9
|
+
* actual `console` writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single scanned variant under one phase — the unit that passes or fails. */
|
|
13
|
+
export type TestStatus = 'pass' | 'fail' | 'record'
|
|
14
|
+
|
|
15
|
+
const STATUS_TAG: Record<TestStatus, string> = {
|
|
16
|
+
pass: '(pass)',
|
|
17
|
+
fail: '(fail)',
|
|
18
|
+
record: '(record)',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Phase labels are padded to a common width so the `(pass)`/`(fail)` tags line
|
|
22
|
+
// up into a scannable column regardless of which phase emitted the line.
|
|
23
|
+
const PHASE_WIDTH = 6
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Human duration from a nanosecond span (`Bun.nanoseconds()` deltas), matching
|
|
27
|
+
* `bun test`'s `[12.34ms]` / `[1.50s]` convention: milliseconds below a second,
|
|
28
|
+
* seconds above.
|
|
29
|
+
*/
|
|
30
|
+
export function formatDuration(ns: number): string {
|
|
31
|
+
const ms = ns / 1e6
|
|
32
|
+
return ms < 1000 ? `${ms.toFixed(2)}ms` : `${(ms / 1000).toFixed(2)}s`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One per-test line: ` a11y (pass) Button/default [light] [12.34ms]`. The
|
|
37
|
+
* `(pass)`/`(fail)`/`(record)` tag is fixed-text so a downstream summarizer can
|
|
38
|
+
* count outcomes without parsing colour or glyphs.
|
|
39
|
+
*/
|
|
40
|
+
export function testLine(
|
|
41
|
+
phase: string,
|
|
42
|
+
name: string,
|
|
43
|
+
status: TestStatus,
|
|
44
|
+
durationNs: number,
|
|
45
|
+
): string {
|
|
46
|
+
return ` ${phase.padEnd(PHASE_WIDTH)} ${STATUS_TAG[status]} ${name} [${formatDuration(durationNs)}]`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per-phase pass/fail/recorded counts feeding the summary block. */
|
|
50
|
+
export interface PhaseTally {
|
|
51
|
+
pass: number
|
|
52
|
+
fail: number
|
|
53
|
+
record: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function emptyTally(): PhaseTally {
|
|
57
|
+
return { pass: 0, fail: 0, record: 0 }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Total tests a tally represents (recorded baselines count as run, not failed). */
|
|
61
|
+
function tallyTotal(t: PhaseTally): number {
|
|
62
|
+
return t.pass + t.fail + t.record
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The closing summary, in `bun test`'s shape: a per-phase line, then the rolled-up
|
|
67
|
+
* `N pass` / `N fail`, then a `Ran …` line carrying the total wall-clock (which is
|
|
68
|
+
* less than the sum of per-test times when the phases run concurrently) and the
|
|
69
|
+
* concurrency the run used.
|
|
70
|
+
*
|
|
71
|
+
* Returns the lines without the trailing canonical `✓ checks passed` verdict —
|
|
72
|
+
* `check.ts` still prints that, so the overall pass/fail signal (and its exit
|
|
73
|
+
* code) is unchanged.
|
|
74
|
+
*/
|
|
75
|
+
export function summaryLines(
|
|
76
|
+
phases: { phase: string; tally: PhaseTally }[],
|
|
77
|
+
totalNs: number,
|
|
78
|
+
concurrency: number,
|
|
79
|
+
): string[] {
|
|
80
|
+
const out: string[] = ['']
|
|
81
|
+
let pass = 0
|
|
82
|
+
let fail = 0
|
|
83
|
+
let record = 0
|
|
84
|
+
let total = 0
|
|
85
|
+
for (const { phase, tally } of phases) {
|
|
86
|
+
pass += tally.pass
|
|
87
|
+
fail += tally.fail
|
|
88
|
+
record += tally.record
|
|
89
|
+
total += tallyTotal(tally)
|
|
90
|
+
const parts = [`${tally.pass} pass`, `${tally.fail} fail`]
|
|
91
|
+
if (tally.record) parts.push(`${tally.record} recorded`)
|
|
92
|
+
out.push(` ${phase.padEnd(PHASE_WIDTH)} ${parts.join(' ')}`)
|
|
93
|
+
}
|
|
94
|
+
out.push('')
|
|
95
|
+
out.push(` ${pass} pass`)
|
|
96
|
+
out.push(` ${fail} fail`)
|
|
97
|
+
if (record) out.push(` ${record} recorded`)
|
|
98
|
+
out.push(
|
|
99
|
+
` Ran ${total} check${total === 1 ? '' : 's'} ` +
|
|
100
|
+
`[${formatDuration(totalNs)}] (concurrency ${concurrency})`,
|
|
101
|
+
)
|
|
102
|
+
return out
|
|
103
|
+
}
|
package/src/checks/check.ts
CHANGED
|
@@ -12,6 +12,12 @@ import type {
|
|
|
12
12
|
RenderDriver,
|
|
13
13
|
} from '../index'
|
|
14
14
|
import { startDisplayCase } from '../server/server'
|
|
15
|
+
import {
|
|
16
|
+
emptyTally,
|
|
17
|
+
type PhaseTally,
|
|
18
|
+
summaryLines,
|
|
19
|
+
testLine,
|
|
20
|
+
} from './check-reporter'
|
|
15
21
|
import { checkSsr } from './ssr-check'
|
|
16
22
|
import { checkStructure } from './structure-check'
|
|
17
23
|
import { checkTokens } from './tokens-check'
|
|
@@ -46,9 +52,41 @@ export interface CheckOptions {
|
|
|
46
52
|
/** Restrict the render phases to components whose import closure touches a
|
|
47
53
|
* file changed since this git ref (CLI `--changed[=ref]`). */
|
|
48
54
|
changedRef?: string
|
|
55
|
+
/** How many variants the render phases (a11y/visual) scan concurrently.
|
|
56
|
+
* CLI `--concurrency=N`; defaults to {@link DEFAULT_CONCURRENCY}. */
|
|
57
|
+
concurrency?: number
|
|
49
58
|
port?: number
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
/** Default render-phase concurrency — modest, so a stock laptop isn't swamped by
|
|
62
|
+
* parallel headless pages while still cutting wall-clock well below serial. */
|
|
63
|
+
const DEFAULT_CONCURRENCY = 4
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run `worker` over `items` with at most `limit` in flight at once. Drives the
|
|
67
|
+
* a11y/visual phases: the browser work is I/O-bound, so concurrent pages overlap
|
|
68
|
+
* almost entirely. JS stays single-threaded, so the shared counters/arrays the
|
|
69
|
+
* workers mutate need no locking — only one worker runs between any two awaits.
|
|
70
|
+
*/
|
|
71
|
+
async function mapPool<T>(
|
|
72
|
+
items: T[],
|
|
73
|
+
limit: number,
|
|
74
|
+
worker: (item: T) => Promise<void>,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
let next = 0
|
|
77
|
+
const lanes = Array.from(
|
|
78
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
79
|
+
async () => {
|
|
80
|
+
while (true) {
|
|
81
|
+
const i = next++
|
|
82
|
+
if (i >= items.length) return
|
|
83
|
+
await worker(items[i])
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
await Promise.all(lanes)
|
|
88
|
+
}
|
|
89
|
+
|
|
52
90
|
interface Target {
|
|
53
91
|
componentId: string
|
|
54
92
|
caseId: string
|
|
@@ -359,43 +397,65 @@ export async function runChecks(
|
|
|
359
397
|
|
|
360
398
|
const driver = await resolveDriver(config)
|
|
361
399
|
const diff = opts.visual ? await resolveDiff(config) : null
|
|
400
|
+
const requested =
|
|
401
|
+
opts.concurrency ?? config.check?.concurrency ?? DEFAULT_CONCURRENCY
|
|
402
|
+
const concurrency =
|
|
403
|
+
Number.isFinite(requested) && requested > 0
|
|
404
|
+
? Math.floor(requested)
|
|
405
|
+
: DEFAULT_CONCURRENCY
|
|
362
406
|
|
|
363
407
|
let a11yViolations = 0
|
|
364
408
|
let visualChanges = 0
|
|
365
|
-
let recorded = 0
|
|
366
409
|
const a11yReport: A11yReportEntry[] = []
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
410
|
+
const a11yTally = emptyTally()
|
|
411
|
+
const visualTally = emptyTally()
|
|
412
|
+
const rel = (p: string) =>
|
|
413
|
+
p.startsWith(`${pkgDir}/`) ? p.slice(pkgDir.length + 1) : p
|
|
414
|
+
|
|
415
|
+
// Each variant is reported as a test — `(pass)`/`(fail)`/`(record)` with its own
|
|
416
|
+
// timing, in `bun test`'s shape — so a CI log can be grepped and summarized. The
|
|
417
|
+
// a11y and visual phases run together on one shared page per variant; the whole
|
|
418
|
+
// set is scanned with bounded concurrency (`mapPool`) since the work is
|
|
419
|
+
// browser-I/O-bound. A variant's lines are buffered and flushed as one block so
|
|
420
|
+
// concurrent variants never interleave mid-test.
|
|
421
|
+
async function scan(t: Target): Promise<void> {
|
|
422
|
+
const ctx: CaseContext = {
|
|
423
|
+
componentId: t.componentId,
|
|
424
|
+
caseId: t.caseId,
|
|
425
|
+
theme: t.theme,
|
|
426
|
+
width: VIEWPORT_WIDTH,
|
|
427
|
+
}
|
|
428
|
+
const name = `${t.componentId}/${t.caseId} [${t.theme}]`
|
|
429
|
+
const out: string[] = []
|
|
430
|
+
const page = await driver.open(t.renderUrl, ctx)
|
|
431
|
+
try {
|
|
378
432
|
if (opts.a11y && a11yThemes.includes(t.theme)) {
|
|
433
|
+
const t0 = Bun.nanoseconds()
|
|
379
434
|
const violations = await page.audit({ exclude: a11yExclude })
|
|
435
|
+
const dur = Bun.nanoseconds() - t0
|
|
380
436
|
if (violations.length) {
|
|
437
|
+
a11yTally.fail++
|
|
381
438
|
a11yReport.push({
|
|
382
439
|
component: t.componentId,
|
|
383
440
|
case: t.caseId,
|
|
384
441
|
theme: t.theme,
|
|
385
442
|
violations,
|
|
386
443
|
})
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
444
|
+
out.push(testLine('a11y', name, 'fail', dur))
|
|
445
|
+
for (const v of violations) {
|
|
446
|
+
a11yViolations++
|
|
447
|
+
const sev = v.impact ? `${v.impact} ` : ''
|
|
448
|
+
out.push(` ${sev}${v.id}: ${v.help} (${v.nodes} node(s))`)
|
|
449
|
+
for (const line of a11yDetailLines(v)) out.push(line)
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
a11yTally.pass++
|
|
453
|
+
out.push(testLine('a11y', name, 'pass', dur))
|
|
395
454
|
}
|
|
396
455
|
}
|
|
397
456
|
|
|
398
457
|
if (opts.visual && diff) {
|
|
458
|
+
const t0 = Bun.nanoseconds()
|
|
399
459
|
const shot = await page.screenshot()
|
|
400
460
|
const file = join(
|
|
401
461
|
baselines,
|
|
@@ -405,36 +465,52 @@ export async function runChecks(
|
|
|
405
465
|
if (opts.update || !(await Bun.file(file).exists())) {
|
|
406
466
|
await mkdir(dirname(file), { recursive: true })
|
|
407
467
|
await Bun.write(file, shot)
|
|
408
|
-
|
|
468
|
+
visualTally.record++
|
|
469
|
+
out.push(testLine('visual', name, 'record', Bun.nanoseconds() - t0))
|
|
409
470
|
} else {
|
|
410
471
|
const baseline = new Uint8Array(await Bun.file(file).arrayBuffer())
|
|
411
472
|
const res = await diff(
|
|
412
473
|
{ baseline, actual: shot },
|
|
413
474
|
{ ...ctx, baselinePath: file },
|
|
414
475
|
)
|
|
476
|
+
const dur = Bun.nanoseconds() - t0
|
|
415
477
|
if (res.changed) {
|
|
416
478
|
visualChanges++
|
|
479
|
+
visualTally.fail++
|
|
480
|
+
let where = ''
|
|
417
481
|
if (res.diffImage) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
)
|
|
482
|
+
const diffPath = file.replace(/\.png$/, '.diff.png')
|
|
483
|
+
await Bun.write(diffPath, res.diffImage)
|
|
484
|
+
where = ` → ${rel(diffPath)}`
|
|
422
485
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
486
|
+
out.push(testLine('visual', name, 'fail', dur))
|
|
487
|
+
out.push(` differs from baseline${where}`)
|
|
488
|
+
} else {
|
|
489
|
+
visualTally.pass++
|
|
490
|
+
out.push(testLine('visual', name, 'pass', dur))
|
|
426
491
|
}
|
|
427
492
|
}
|
|
428
493
|
}
|
|
429
|
-
|
|
494
|
+
} finally {
|
|
430
495
|
await page.dispose()
|
|
431
496
|
}
|
|
497
|
+
if (out.length) console.log(out.join('\n'))
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const startedAt = Bun.nanoseconds()
|
|
501
|
+
try {
|
|
502
|
+
await mapPool(targets, concurrency, scan)
|
|
432
503
|
} finally {
|
|
433
504
|
await driver.close()
|
|
434
505
|
server.stop(true)
|
|
435
506
|
}
|
|
507
|
+
const totalNs = Bun.nanoseconds() - startedAt
|
|
436
508
|
|
|
437
|
-
|
|
509
|
+
const phases: { phase: string; tally: PhaseTally }[] = []
|
|
510
|
+
if (opts.a11y) phases.push({ phase: 'a11y', tally: a11yTally })
|
|
511
|
+
if (opts.visual) phases.push({ phase: 'visual', tally: visualTally })
|
|
512
|
+
for (const line of summaryLines(phases, totalNs, concurrency))
|
|
513
|
+
console.log(line)
|
|
438
514
|
|
|
439
515
|
// Persist the full run (every failing variant, with per-node detail) so an
|
|
440
516
|
// agent or human can read the exact failing colours/elements later without
|
|
@@ -446,15 +522,19 @@ export async function runChecks(
|
|
|
446
522
|
await Bun.write(
|
|
447
523
|
reportPath,
|
|
448
524
|
`${JSON.stringify(
|
|
449
|
-
{
|
|
525
|
+
{
|
|
526
|
+
scannedAt: Date.now(),
|
|
527
|
+
durationMs: Math.round(totalNs / 1e6),
|
|
528
|
+
total: a11yViolations,
|
|
529
|
+
results: a11yReport,
|
|
530
|
+
},
|
|
450
531
|
null,
|
|
451
532
|
2,
|
|
452
533
|
)}\n`,
|
|
453
534
|
)
|
|
454
|
-
|
|
455
|
-
?
|
|
456
|
-
|
|
457
|
-
console.log(` a11y detail → ${rel}${a11yViolations ? '' : ' (clean run)'}`)
|
|
535
|
+
console.log(
|
|
536
|
+
` a11y detail → ${rel(reportPath)}${a11yViolations ? '' : ' (clean run)'}`,
|
|
537
|
+
)
|
|
458
538
|
}
|
|
459
539
|
|
|
460
540
|
const ok =
|
package/src/cli.ts
CHANGED
|
@@ -21,7 +21,7 @@ if (typeof globalThis.Bun === 'undefined') {
|
|
|
21
21
|
*
|
|
22
22
|
* display-case <pkgDir> [--port=N] start the dev server
|
|
23
23
|
* display-case <pkgDir> --print-manifest print the manifest JSON and exit
|
|
24
|
-
* display-case check <pkgDir> [--a11y] [--visual] [--tokens] [--structure] [--ssr] [--update] [--strict] [--only=ids] [--changed[=ref]] [--port=N]
|
|
24
|
+
* display-case check <pkgDir> [--a11y] [--visual] [--tokens] [--structure] [--ssr] [--update] [--strict] [--only=ids] [--changed[=ref]] [--concurrency=N] [--port=N]
|
|
25
25
|
* display-case init <pkgDir> [--agent=claude] [--with-visual] [--dry-run] [--json]
|
|
26
26
|
* display-case uninstall <pkgDir> [--agent=claude] [--dry-run] [--json]
|
|
27
27
|
*
|
|
@@ -182,6 +182,10 @@ if (argv[0] === 'init' || argv[0] === 'uninstall') {
|
|
|
182
182
|
process.env.DISPLAY_CASE_BASE_REF ??
|
|
183
183
|
'origin/main')
|
|
184
184
|
: undefined,
|
|
185
|
+
concurrency: (() => {
|
|
186
|
+
const n = Number.parseInt(option('concurrency') ?? '', 10)
|
|
187
|
+
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
188
|
+
})(),
|
|
185
189
|
port,
|
|
186
190
|
})
|
|
187
191
|
process.exit(ok ? 0 : 1)
|
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({
|
|
@@ -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/index.ts
CHANGED
|
@@ -411,6 +411,14 @@ export interface CheckConfig {
|
|
|
411
411
|
* still be invoked explicitly by naming its flag.
|
|
412
412
|
*/
|
|
413
413
|
defaultPhases?: Partial<Record<CheckPhase, boolean>>
|
|
414
|
+
/**
|
|
415
|
+
* How many variants the render phases (a11y/visual) scan concurrently. The
|
|
416
|
+
* built-in Playwright driver opens one page per variant from a shared browser
|
|
417
|
+
* context, so concurrent pages overlap the browser-bound work. Default 4; CLI
|
|
418
|
+
* `--concurrency=N` overrides. A custom `providers.driver` MUST tolerate
|
|
419
|
+
* concurrent `open()` calls for values above 1 (set this to `1` if it can't).
|
|
420
|
+
*/
|
|
421
|
+
concurrency?: number
|
|
414
422
|
/** Structure-phase rule configuration; each rule is on (at its default severity) unless overridden. */
|
|
415
423
|
structure?: {
|
|
416
424
|
/** Treat every structure warning as an error for the run (CI strict mode). */
|
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]',
|