diff --git a/.changepacks/changepack_log_ci_hardening.json b/.changepacks/changepack_log_ci_hardening.json
new file mode 100644
index 000000000..b03f09a46
--- /dev/null
+++ b/.changepacks/changepack_log_ci_hardening.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "CI and test infrastructure: the workflow always runs the latest releases (the ubuntu-latest runner, the newest major version of every action, and the latest stable Rust, Bun, Node.js, wasm-pack, cargo-binstall, cargo-tarpaulin and changepacks), runs pull requests read-only and moves npm publishing and Pages deployment into jobs that run only on pushes to main, never creates or commits e2e baselines, and sets timeouts. The wasm package build now starts from an empty pkg/ and verify:dist rejects files of the removed lite export, its README describes the real API, state and errors, and a Bun test checks the JS/WASM interface. The README benchmark tables are generated from one checked result file in both languages with the CSS size in its own column.",
+ "date": "2026-10-01T00:00:00.000Z"
+}
\ No newline at end of file
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 0e6edbd21..2cad8f11a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -7,9 +7,17 @@ on:
pull_request:
branches:
- main
+
+# Pull requests and every job below run read-only. Only the `release` and
+# `deploy-pages` jobs get write permissions, and they run on pushes to main
+# alone, so no step a pull request controls can publish, tag, comment or deploy.
permissions:
contents: read
+# CI always runs the latest releases: the `ubuntu-latest` runner, the newest
+# major version of every action, and the latest stable Rust, Bun, Node.js,
+# wasm-pack, cargo-binstall, cargo-tarpaulin and changepacks.
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -17,11 +25,15 @@ concurrency:
jobs:
benchmark:
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
- name: Cache cargo registry + target
uses: actions/cache@v6
@@ -34,7 +46,7 @@ jobs:
key: cargo-benchmark-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
with:
- bun-version: "1.4.0"
+ bun-version: latest
name: Install bun
- name: Cache bun dependencies
@@ -44,16 +56,24 @@ jobs:
key: bun-benchmark-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- uses: jetli/wasm-pack-action@v0.4.0
with:
- version: "latest"
+ version: latest
- name: Install Node.js
uses: actions/setup-node@v7
with:
registry-url: "https://registry.npmjs.org"
- node-version: 24
+ node-version: latest
- run: bun install
- run: bun run build
- name: Benchmark
run: bun run benchmark
+ - name: Upload the measured run
+ uses: actions/upload-artifact@v7
+ if: ${{ !cancelled() }}
+ with:
+ name: benchmark-run
+ path: benchmark-run.json
+ if-no-files-found: ignore
+ retention-days: 30
- name: Profile Devup UI Turbopack
env:
DEVUP_UI_PROFILE: "1"
@@ -67,16 +87,20 @@ jobs:
# `publish` runs it against the deployed vinext/@devup-ui/vite-plugin build.
# This job runs the SAME 128 assertions and the SAME screenshot baselines
# against a Next 16 + Turbopack build, so @devup-ui/next-plugin's production
- # CSS path stays gated at 64 routes — the scale where extraction-wave races
+ # CSS path stays gated at 64 routes - the scale where extraction-wave races
# actually reproduce. It runs in parallel so it stays off the publish critical
# path, and its artifact is never deployed.
landing-next-e2e:
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
- name: Cache cargo registry + target
uses: actions/cache@v6
@@ -89,7 +113,7 @@ jobs:
key: cargo-landing-next-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
with:
- bun-version: "1.4.0"
+ bun-version: latest
name: Install bun
- name: Cache bun dependencies
@@ -99,11 +123,11 @@ jobs:
key: bun-landing-next-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- uses: jetli/wasm-pack-action@v0.4.0
with:
- version: "latest"
+ version: latest
- name: Install Node.js
uses: actions/setup-node@v7
with:
- node-version: 24
+ node-version: latest
- run: bun install
- run: bun run build
- name: Build Landing (Next)
@@ -138,6 +162,65 @@ jobs:
path: playwright-report/
retention-days: 1
+ # The packages take React ^18 || ^19 as a peer. This builds a React 18.3 app
+ # with the Vite plugin and the UI kit, then checks refs, context and events
+ # in Chromium, so React-19-only APIs cannot slip back in.
+ react-18:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
+
+ - name: Cache cargo registry + target
+ uses: actions/cache@v6
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: cargo-react-18-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ name: Install bun
+
+ - name: Cache bun dependencies
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: bun-react-18-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
+ - uses: jetli/wasm-pack-action@v0.4.0
+ with:
+ version: latest
+ - name: Install Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: latest
+ - run: bun install
+ - run: bun run build
+ - name: Type-check and build the React 18 app
+ working-directory: apps/react-18
+ run: |
+ bun ../../node_modules/@typescript/native/bin/tsc
+ bun run build
+ - name: Cache Playwright Browsers
+ uses: actions/cache@v6
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-react-18-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
+ - name: Install Playwright Browsers
+ run: bunx playwright install chromium --with-deps
+ - name: Run React 18 E2E Tests
+ working-directory: apps/react-18
+ run: bun run test:e2e
+
# @devup-ui/vite-plugin must keep the CSS every server page needs when the
# build is vinext/RSC. The fixture shares one Devup UI header between server
# list pages and a client page, and the tests read the styles the browser
@@ -146,11 +229,16 @@ jobs:
vinext-rsc-css-e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
+ permissions:
+ contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
- name: Cache cargo registry + target
uses: actions/cache@v6
@@ -163,7 +251,7 @@ jobs:
key: cargo-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
with:
- bun-version: "1.4.0"
+ bun-version: latest
name: Install bun
- name: Cache bun dependencies
@@ -173,11 +261,11 @@ jobs:
key: bun-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- uses: jetli/wasm-pack-action@v0.4.0
with:
- version: "latest"
+ version: latest
- name: Install Node.js
uses: actions/setup-node@v7
with:
- node-version: 24
+ node-version: latest
- run: bun install
- run: bun run build
- name: Cache Playwright Browsers
@@ -197,24 +285,27 @@ jobs:
DEVUP_SINGLE_CSS=1 bun run --filter vinext-rsc-css-repro build
- name: Run E2E Tests (singleCss)
run: bun run test:e2e:rsc-css
+
+ # Validation: runs on pull requests and on pushes to main, read-only. The job
+ # id stays `publish` because it is the status check the repository requires;
+ # the privileged release steps live in `release` and `deploy-pages` below.
publish:
runs-on: ubuntu-latest
- permissions:
- contents: write
- id-token: write
- pages: write
- pull-requests: write
+ timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# changepacks diffs HEAD against the previous release commit. The
# default shallow fetch grafts away every parent, so that lookup dies
- # with "Could not find a merge-base" and the release never publishes.
+ # with "Could not find a merge-base".
fetch-depth: 0
fetch-tags: true
- uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
- name: Install cargo-binstall
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
@@ -233,7 +324,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
- bun-version: "1.4.0"
+ bun-version: latest
name: Install bun
- name: Cache bun dependencies
@@ -244,16 +335,18 @@ jobs:
- uses: jetli/wasm-pack-action@v0.4.0
with:
- version: "latest"
+ version: latest
- name: Install Node.js
uses: actions/setup-node@v7
with:
registry-url: "https://registry.npmjs.org"
- node-version: 24
+ node-version: latest
- run: bun install
- run: bun run build
- name: Verify built package entry points
run: bun run verify:dist
+ - name: Verify the README benchmark tables
+ run: bun render-benchmark-readme.js --check
- name: Verify publish tarballs
shell: bash
run: |
@@ -294,10 +387,16 @@ jobs:
rm -rf .rustfmt.toml
# Formatting with the temporary coverage config and then with the
# default config is not a reversible round trip. Restore the exact
- # checked-in sources so changepacks can safely switch commits.
+ # checked-in sources so the checkout is clean again.
git restore --source=HEAD --staged --worktree -- .
cargo fmt --all -- --check
git diff --exit-code
+ - name: Check the changepacks of this change
+ if: github.event_name == 'pull_request'
+ run: |
+ # changepacks compares the change against a local main, which a pull request checkout does not have
+ git fetch --no-tags origin main:refs/heads/main
+ bunx @changepacks/cli@latest check --format json
- name: Build Landing
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -313,21 +412,17 @@ jobs:
key: playwright-publish-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- name: Install Playwright Browsers
run: bunx playwright install chromium --with-deps
- - name: Check for Existing E2E Snapshots
- id: check-snapshots
+ # A run only compares against the screenshots checked into the repository:
+ # it never creates or commits them. A new visual test needs its baselines
+ # generated by a maintainer (`bun run test:e2e:update`) and committed.
+ - name: Require the E2E baselines
run: |
- SNAPSHOT_COUNT=$(find e2e -name '*.png' -path '*-snapshots/*' 2>/dev/null | wc -l)
- echo "count=$SNAPSHOT_COUNT" >> $GITHUB_OUTPUT
- echo "Found $SNAPSHOT_COUNT existing snapshot(s)"
- - name: Generate E2E Snapshots
- if: steps.check-snapshots.outputs.count == '0'
- run: bunx playwright test --update-snapshots
- - name: Commit E2E Snapshots
- if: steps.check-snapshots.outputs.count == '0' && github.event_name == 'pull_request'
- uses: stefanzweifel/git-auto-commit-action@v7
- with:
- commit_message: "chore: generate e2e visual snapshots from CI"
- file_pattern: "e2e/**/*-snapshots/*.png"
+ SNAPSHOT_COUNT=$(find e2e -name '*-linux.png' -path '*-snapshots/*' 2>/dev/null | wc -l)
+ echo "Found $SNAPSHOT_COUNT linux baseline snapshot(s)"
+ if [ "$SNAPSHOT_COUNT" -eq 0 ]; then
+ echo "No checked-in baselines under e2e/**/*-snapshots; generate them with 'bun run test:e2e:update' and commit them." >&2
+ exit 1
+ fi
- name: Run E2E Tests
run: bun run test:e2e
- name: Build Landing (singleCss)
@@ -345,14 +440,12 @@ jobs:
name: playwright-report-singlecss
path: playwright-report/
retention-days: 1
- - name: Upload artifact
+ - name: Upload the site for deployment
uses: actions/upload-pages-artifact@v5
with:
path: ./apps/landing/dist/client
retention-days: 1
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- - uses: actions/deploy-pages@v5
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- name: Upload to codecov.io
uses: codecov/codecov-action@v7
with:
@@ -360,19 +453,94 @@ jobs:
fail_ci_if_error: true
files: ./coverage/lcov.info
- # Publishes through npm OIDC trusted publishing. The job's
- # `id-token: write` permission lets npm exchange GitHub's short-lived OIDC
- # token, and public packages receive provenance automatically.
- #
- # PREREQUISITE: every @devup-ui/* package must be registered as a Trusted
- # Publisher on npmjs.com against repo dev-five-git/devup-ui and workflow
- # file `publish.yml`. npm matches that filename exactly, so renaming or
- # moving this file breaks publishing until the entries are updated.
- #
- # The publish command itself is selected in .changepacks/config.json.
- # Bun packs each workspace so dependency ranges are materialized, then
- # npm publishes that tarball with OIDC (Bun cannot do OIDC itself:
- # oven-sh/bun#22423).
+ # Pages deployment: the only job with `pages` and `id-token` write, and it
+ # only runs for a push to main whose validation passed.
+ deploy-pages:
+ needs: [publish]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ steps:
+ - uses: actions/deploy-pages@v5
+
+ # Publishes through npm OIDC trusted publishing. The job's `id-token: write`
+ # permission lets npm exchange GitHub's short-lived OIDC token, and public
+ # packages receive provenance automatically.
+ #
+ # PREREQUISITE: every @devup-ui/* package must be registered as a Trusted
+ # Publisher on npmjs.com against repo dev-five-git/devup-ui and workflow
+ # file `publish.yml`. npm matches that filename exactly, so renaming or
+ # moving this file breaks publishing until the entries are updated.
+ #
+ # The publish command itself is selected in .changepacks/config.json.
+ # Bun packs each workspace so dependency ranges are materialized, then
+ # npm publishes that tarball with OIDC (Bun cannot do OIDC itself:
+ # oven-sh/bun#22423).
+ #
+ # It runs only for a push to main whose validation and end-to-end jobs
+ # passed, and is the only job that can write contents, open pull requests or
+ # mint an OIDC token for npm.
+ release:
+ needs: [publish, landing-next-e2e, react-18]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ permissions:
+ contents: write
+ id-token: write
+ pull-requests: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ # changepacks diffs HEAD against the previous release commit. The
+ # default shallow fetch grafts away every parent, so that lookup dies
+ # with "Could not find a merge-base" and the release never publishes.
+ fetch-depth: 0
+ fetch-tags: true
+
+ - uses: actions-rust-lang/setup-rust-toolchain@v2
+ with:
+ toolchain: stable
+ components: clippy, rustfmt
+
+ - name: Cache cargo registry + target
+ uses: actions/cache@v6
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ name: Install bun
+
+ - name: Cache bun dependencies
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: bun-release-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
+
+ - uses: jetli/wasm-pack-action@v0.4.0
+ with:
+ version: latest
+ - name: Install Node.js
+ uses: actions/setup-node@v7
+ with:
+ registry-url: "https://registry.npmjs.org"
+ node-version: latest
+ - run: bun install
+ - run: bun run build
+ - name: Verify built package entry points
+ run: bun run verify:dist
- name: Verify clean publish checkout
run: git diff --exit-code
- uses: changepacks/action@main
diff --git a/.gitignore b/.gitignore
index eb80866ac..7e5ea71be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ playwright-report
.playwright-mcp
.omo
/.changepacks/publish.tgz
+benchmark-run.json
diff --git a/README.md b/README.md
index 556be5e8e..49e4e4606 100644
--- a/README.md
+++ b/README.md
@@ -79,31 +79,35 @@ npm install @devup-ui/webpack-plugin
## Comparison Benchmarks
-[Latest CI benchmark](https://github.com/dev-five-git/devup-ui/actions/runs/33254030962) on `ubuntu-latest` with Next.js 16.3.3. All Next.js builds use the native TypeScript 7 CLI for type checking.
+
+[Latest CI benchmark](https://github.com/dev-five-git/devup-ui/actions/runs/36702867475) (commit `8d6c2f62`, Devup UI 1.0.44) on `ubuntu-24.04` with Next.js 16.3.3. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, `benchmark-results.json`.
+
+Build Size is every byte of the build output directory (`.next`, or `dist` for vinext); CSS Size is the emitted `.css` files alone, measured separately.
Webpack values are one cold build:
-| Library | Version | Build Time | Build Size |
-| --------------------------- | ------- | ---------- | ----------------- |
-| tailwindcss | 4.3.3 | 12.81s | 66,453,190 bytes |
-| styleX | 0.19.0 | 27.61s | 95,413,403 bytes |
-| vanilla-extract | 1.21.2 | 11.96s | 67,689,923 bytes |
-| kuma-ui | 1.6.4 | 13.07s | 74,747,637 bytes |
-| panda-css | 1.12.0 | 13.20s | 70,970,208 bytes |
-| chakra-ui | 3.37.0 | 19.98s | 206,589,898 bytes |
-| mui | 9.4.0 | 13.78s | 100,603,186 bytes |
-| **devup-ui (per-file CSS)** | 1.0.40 | **10.73s** | 66,535,095 bytes |
-| **devup-ui (single CSS)** | 1.0.40 | **10.81s** | 66,535,321 bytes |
+| Library | Version | Build Time | Build Size | CSS Size |
+| --------------------------- | ------- | ---------- | ----------------- | ------------ |
+| tailwindcss | 4.3.3 | 16.67s | 67,332,642 bytes | 5,852 bytes |
+| styleX | 0.19.0 | 38.08s | 96,291,287 bytes | 425 bytes |
+| vanilla-extract | 1.21.2 | 16.09s | 68,565,036 bytes | 294 bytes |
+| kuma-ui | 1.6.4 | 17.91s | 75,627,783 bytes | 340 bytes |
+| panda-css | 1.12.0 | 18.16s | 71,849,418 bytes | 15,615 bytes |
+| chakra-ui | 3.37.0 | 27.22s | 207,426,274 bytes | 0 bytes |
+| mui | 9.4.0 | 18.78s | 101,473,260 bytes | 0 bytes |
+| **devup-ui (per-file CSS)** | 1.0.44 | **14.27s** | 67,424,897 bytes | 562 bytes |
+| **devup-ui (single CSS)** | 1.0.44 | **14.17s** | 67,426,959 bytes | 790 bytes |
Turbopack values are medians of six cold builds in alternating order:
-| Library | Version | Median Build Time | Build Size |
-| -------------------------------------- | ------- | ----------------- | -------------------- |
-| tailwindcss | 4.3.3 | 5.52s | 38,425,132 bytes |
-| **devup-ui (direct APIs, single CSS)** | 1.0.40 | **5.46s** | **36,507,782 bytes** |
-| **devup-ui (static `.css.ts`)** | 1.0.40 | **5.41s** | 36,586,883 bytes |
+| Library | Version | Median Build Time | Build Size | CSS Size |
+| -------------------------------------- | ------- | ----------------- | ---------------- | ----------- |
+| tailwindcss | 4.3.3 | 6.98s | 38,422,141 bytes | 6,197 bytes |
+| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **6.93s** | 36,518,952 bytes | 327 bytes |
+| **devup-ui (static `.css.ts`)** | 1.0.44 | **6.92s** | 36,578,506 bytes | 268 bytes |
-The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one.
+The Turbopack ranges overlap, so the direct-API median is 0.05s (0.7%) ahead of Tailwind on this fixture and the two are effectively at parity. The six cold samples were Tailwind `7.10s, 6.99s, 6.98s, 6.90s, 6.91s, 6.97s`, direct Devup UI `6.98s, 6.83s, 6.95s, 6.91s, 6.90s, 6.94s`, and static `.css.ts` `6.93s, 6.95s, 6.85s, 6.97s, 6.87s, 6.91s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every `.css.ts` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one.
+
## How it works
diff --git a/README_ko.md b/README_ko.md
index 9dadc6606..ff9ca962a 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -79,31 +79,35 @@ npm install @devup-ui/webpack-plugin
## 비교 벤치마크
-[최신 CI 벤치마크](https://github.com/dev-five-git/devup-ui/actions/runs/33239265133)는 `ubuntu-latest`에서 실행했습니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다.
+
+[최신 CI 벤치마크](https://github.com/dev-five-git/devup-ui/actions/runs/36702867475) (커밋 `8d6c2f62`, Devup UI 1.0.44)는 `ubuntu-24.04`에서 Next.js 16.3.3로 실행했습니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 `benchmark-results.json`에서 생성합니다.
+
+빌드 사이즈는 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트이고, CSS 사이즈는 출력된 `.css` 파일만 따로 잰 값입니다.
Webpack 값은 cold build 1회 결과입니다.
-| 라이브러리 | 버전 | 빌드 시간 | 빌드 사이즈 |
-| --------------------------- | ------ | ---------- | -------------------- |
-| tailwindcss | 4.3.3 | 15.55s | 66,479,450 bytes |
-| styleX | 0.19.0 | 34.27s | 95,417,163 bytes |
-| vanilla-extract | 1.21.2 | 14.91s | 67,711,539 bytes |
-| kuma-ui | 1.6.4 | 16.43s | 74,774,187 bytes |
-| panda-css | 1.12.0 | 16.82s | 70,983,831 bytes |
-| chakra-ui | 3.37.0 | 24.79s | 206,598,161 bytes |
-| mui | 9.4.0 | 17.12s | 100,621,370 bytes |
-| **devup-ui (per-file CSS)** | 1.0.40 | **13.43s** | 66,577,087 bytes |
-| **devup-ui (single CSS)** | 1.0.40 | **13.37s** | **66,564,796 bytes** |
+| 라이브러리 | 버전 | 빌드 시간 | 빌드 사이즈 | CSS 사이즈 |
+| --------------------------- | ------ | ---------- | ----------------- | ------------ |
+| tailwindcss | 4.3.3 | 16.67s | 67,332,642 bytes | 5,852 bytes |
+| styleX | 0.19.0 | 38.08s | 96,291,287 bytes | 425 bytes |
+| vanilla-extract | 1.21.2 | 16.09s | 68,565,036 bytes | 294 bytes |
+| kuma-ui | 1.6.4 | 17.91s | 75,627,783 bytes | 340 bytes |
+| panda-css | 1.12.0 | 18.16s | 71,849,418 bytes | 15,615 bytes |
+| chakra-ui | 3.37.0 | 27.22s | 207,426,274 bytes | 0 bytes |
+| mui | 9.4.0 | 18.78s | 101,473,260 bytes | 0 bytes |
+| **devup-ui (per-file CSS)** | 1.0.44 | **14.27s** | 67,424,897 bytes | 562 bytes |
+| **devup-ui (single CSS)** | 1.0.44 | **14.17s** | 67,426,959 bytes | 790 bytes |
Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙값입니다.
-| 라이브러리 | 버전 | 빌드 시간 중앙값 | 빌드 사이즈 |
-| -------------------------------------- | ------ | ---------------- | -------------------- |
-| tailwindcss | 4.3.3 | 6.55s | 38,386,955 bytes |
-| **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** |
-| **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes |
+| 라이브러리 | 버전 | 빌드 시간 중앙값 | 빌드 사이즈 | CSS 사이즈 |
+| -------------------------------------- | ------ | --------- | ---------------- | ----------- |
+| tailwindcss | 4.3.3 | 6.98s | 38,422,141 bytes | 6,197 bytes |
+| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **6.93s** | 36,518,952 bytes | 327 bytes |
+| **devup-ui (static `.css.ts`)** | 1.0.44 | **6.92s** | 36,578,506 bytes | 268 bytes |
-Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 정적 모듈용 `lite` 빠른 경로가 있던 때에 측정한 값입니다. 지금은 모든 `.css.ts`를 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다.
+Turbopack 측정 범위는 서로 겹치므로 direct API 중앙값은 이 fixture에서 Tailwind보다 0.05s(0.7%) 빠르며 사실상 동률입니다. cold 샘플 6개는 Tailwind `7.10s, 6.99s, 6.98s, 6.90s, 6.91s, 6.97s`, direct Devup UI `6.98s, 6.83s, 6.95s, 6.91s, 6.90s, 6.94s`, static `.css.ts` `6.93s, 6.95s, 6.85s, 6.97s, 6.87s, 6.91s`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 `.css.ts`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다.
+
## 작동 원리
diff --git a/apps/react-18/e2e/react-18.spec.ts b/apps/react-18/e2e/react-18.spec.ts
new file mode 100644
index 000000000..3ff8e655d
--- /dev/null
+++ b/apps/react-18/e2e/react-18.spec.ts
@@ -0,0 +1,24 @@
+import { expect, test } from '@playwright/test'
+
+test('Devup UI runs on React 18', async ({ page }) => {
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text())
+ })
+ await page.goto('/')
+
+ await expect(page.getByTestId('version')).toHaveText(/^18\./)
+ await expect(page.getByTestId('refs')).toHaveText(
+ 'button,input,input,input,textarea,input',
+ )
+
+ await page.getByText('select', { exact: true }).click()
+ await page.getByText('second', { exact: true }).click()
+ await expect(page.getByTestId('selected')).toHaveText('second')
+
+ await page.getByTestId('increase').click()
+ await expect(page.getByTestId('stepper')).toHaveValue('1')
+
+ expect(errors).toEqual([])
+})
diff --git a/apps/react-18/index.html b/apps/react-18/index.html
new file mode 100644
index 000000000..9dfc14d76
--- /dev/null
+++ b/apps/react-18/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Devup UI on React 18
+
+
+
+
+
+
diff --git a/apps/react-18/package.json b/apps/react-18/package.json
new file mode 100644
index 000000000..6044cca14
--- /dev/null
+++ b/apps/react-18/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "react-18-example",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview --port 3098 --strictPort",
+ "test:e2e": "bunx playwright test -c playwright.config.ts",
+ "lint": "bun ../../node_modules/@typescript/native/bin/tsc && eslint"
+ },
+ "dependencies": {
+ "@devup-ui/components": "workspace:^",
+ "@devup-ui/react": "workspace:^",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@devup-ui/vite-plugin": "workspace:^",
+ "@playwright/test": "^1.63.0",
+ "@types/react": "^18.3",
+ "@types/react-dom": "^18.3",
+ "@vitejs/plugin-react": "^6.1",
+ "typescript": "^7.0.2",
+ "vite": "^8.3"
+ }
+}
diff --git a/apps/react-18/playwright.config.ts b/apps/react-18/playwright.config.ts
new file mode 100644
index 000000000..36905cf4c
--- /dev/null
+++ b/apps/react-18/playwright.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://localhost:3098',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command: 'bun run preview',
+ url: 'http://localhost:3098',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+})
diff --git a/apps/react-18/src/App.tsx b/apps/react-18/src/App.tsx
new file mode 100644
index 000000000..49ccf0502
--- /dev/null
+++ b/apps/react-18/src/App.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ Checkbox,
+ Input,
+ Radio,
+ Select,
+ SelectContainer,
+ SelectOption,
+ SelectTrigger,
+ Stepper,
+ StepperDecreaseButton,
+ StepperIncreaseButton,
+ StepperInput,
+ Textarea,
+} from '@devup-ui/components'
+import { Box, styled } from '@devup-ui/react'
+import { useEffect, useRef, useState, version } from 'react'
+
+const Field = styled('input', { color: 'red', p: 1 })
+
+export function App() {
+ const buttonRef = useRef(null)
+ const checkboxRef = useRef(null)
+ const inputRef = useRef(null)
+ const radioRef = useRef(null)
+ const textareaRef = useRef(null)
+ const fieldRef = useRef(null)
+ const [refs, setRefs] = useState('')
+ const [selected, setSelected] = useState('')
+
+ useEffect(() => {
+ setRefs(
+ [buttonRef, checkboxRef, inputRef, radioRef, textareaRef, fieldRef]
+ .map((ref) => ref.current?.tagName.toLowerCase() ?? 'null')
+ .join(','),
+ )
+ }, [])
+
+ return (
+
+ {version}
+ button
+ checkbox
+
+ radio
+
+
+ {refs}
+ setSelected(String(value))}>
+ select
+
+ first
+ second
+
+
+ {selected}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/react-18/src/main.tsx b/apps/react-18/src/main.tsx
new file mode 100644
index 000000000..5922f3e84
--- /dev/null
+++ b/apps/react-18/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import { App } from './App'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/apps/react-18/tsconfig.json b/apps/react-18/tsconfig.json
new file mode 100644
index 000000000..a79fe22b4
--- /dev/null
+++ b/apps/react-18/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "paths": {
+ "react": ["./node_modules/@types/react"],
+ "react/*": ["./node_modules/@types/react/*"],
+ "react-dom": ["./node_modules/@types/react-dom"],
+ "react-dom/*": ["./node_modules/@types/react-dom/*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", "df/*.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/react-18/vite.config.ts b/apps/react-18/vite.config.ts
new file mode 100644
index 000000000..8d2b5e487
--- /dev/null
+++ b/apps/react-18/vite.config.ts
@@ -0,0 +1,16 @@
+import { DevupUI } from '@devup-ui/vite-plugin'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ // The workspace packages keep React 19 for their own tests; a consumer
+ // install resolves their React peer to the app's single copy, as this does
+ resolve: { dedupe: ['react', 'react-dom'] },
+ plugins: [
+ react(),
+ DevupUI({
+ include: ['@devup-ui/components'],
+ singleCss: true,
+ }),
+ ],
+})
diff --git a/benchmark-results.json b/benchmark-results.json
new file mode 100644
index 000000000..5ef26dd7b
--- /dev/null
+++ b/benchmark-results.json
@@ -0,0 +1,105 @@
+{
+ "run": {
+ "id": "36702867475",
+ "url": "https://github.com/dev-five-git/devup-ui/actions/runs/36702867475",
+ "commit": "8d6c2f62",
+ "runner": "ubuntu-24.04",
+ "next": "16.3.3",
+ "devupUi": "1.0.44"
+ },
+ "webpack": [
+ {
+ "label": "tailwindcss",
+ "version": "4.3.3",
+ "seconds": 16.67,
+ "bytes": 67332642,
+ "cssBytes": 5852
+ },
+ {
+ "label": "styleX",
+ "version": "0.19.0",
+ "seconds": 38.08,
+ "bytes": 96291287,
+ "cssBytes": 425
+ },
+ {
+ "label": "vanilla-extract",
+ "version": "1.21.2",
+ "seconds": 16.09,
+ "bytes": 68565036,
+ "cssBytes": 294
+ },
+ {
+ "label": "kuma-ui",
+ "version": "1.6.4",
+ "seconds": 17.91,
+ "bytes": 75627783,
+ "cssBytes": 340
+ },
+ {
+ "label": "panda-css",
+ "version": "1.12.0",
+ "seconds": 18.16,
+ "bytes": 71849418,
+ "cssBytes": 15615
+ },
+ {
+ "label": "chakra-ui",
+ "version": "3.37.0",
+ "seconds": 27.22,
+ "bytes": 207426274,
+ "cssBytes": 0
+ },
+ {
+ "label": "mui",
+ "version": "9.4.0",
+ "seconds": 18.78,
+ "bytes": 101473260,
+ "cssBytes": 0
+ },
+ {
+ "label": "devup-ui (per-file CSS)",
+ "version": "1.0.44",
+ "seconds": 14.27,
+ "bytes": 67424897,
+ "cssBytes": 562,
+ "devup": true
+ },
+ {
+ "label": "devup-ui (single CSS)",
+ "version": "1.0.44",
+ "seconds": 14.17,
+ "bytes": 67426959,
+ "cssBytes": 790,
+ "devup": true
+ }
+ ],
+ "turbopack": [
+ {
+ "label": "tailwindcss",
+ "version": "4.3.3",
+ "samples": [7.1, 6.99, 6.98, 6.9, 6.91, 6.97],
+ "median": 6.98,
+ "bytes": 38422141,
+ "cssBytes": 6197
+ },
+ {
+ "label": "devup-ui (direct APIs, single CSS)",
+ "version": "1.0.44",
+ "samples": [6.98, 6.83, 6.95, 6.91, 6.9, 6.94],
+ "median": 6.93,
+ "bytes": 36518952,
+ "cssBytes": 327,
+ "devup": true
+ },
+ {
+ "label": "devup-ui (static `.css.ts`)",
+ "version": "1.0.44",
+ "samples": [6.93, 6.95, 6.85, 6.97, 6.87, 6.91],
+ "median": 6.92,
+ "bytes": 36578506,
+ "cssBytes": 268,
+ "devup": true
+ }
+ ]
+}
diff --git a/benchmark.js b/benchmark.js
index 0f266988c..3c6c4b2cb 100644
--- a/benchmark.js
+++ b/benchmark.js
@@ -1,4 +1,10 @@
-import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
+import {
+ existsSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs'
import { join } from 'node:path'
import { execSync } from 'child_process'
@@ -65,9 +71,13 @@ function benchmark(target) {
const distDir = join(benchmarkDir, 'dist')
const outputDir = existsSync(distDir) ? distDir : join(benchmarkDir, '.next')
const duration = performance.getEntriesByName(run)[0].duration / 1000
+ const bytes = checkDirSize(outputDir)
+ const cssBytes = checkCssSize(outputDir)
return {
duration,
- result: `${target} ${duration.toFixed(2)}s ${checkDirSize(outputDir).toLocaleString()} bytes (css ${checkCssSize(outputDir).toLocaleString()} bytes)`,
+ bytes,
+ cssBytes,
+ result: `${target} ${duration.toFixed(2)}s ${bytes.toLocaleString()} bytes (css ${cssBytes.toLocaleString()} bytes)`,
}
}
@@ -78,9 +88,17 @@ const turboSamples = new Map([
['vanilla-extract-devup-ui', []],
])
+const measured = []
+
function record(target) {
const sample = benchmark(target)
result.push(sample.result)
+ measured.push({
+ target,
+ seconds: sample.duration,
+ bytes: sample.bytes,
+ cssBytes: sample.cssBytes,
+ })
}
record('tailwind')
@@ -137,3 +155,29 @@ for (const target of turboTargets) {
}
console.info(result.join('\n'))
+
+// The machine-readable run, the CI artifact the README tables are written from
+// (see benchmark-results.json and render-benchmark-readme.js)
+writeFileSync(
+ 'benchmark-run.json',
+ JSON.stringify(
+ {
+ run: {
+ id: process.env.GITHUB_RUN_ID ?? null,
+ commit: process.env.GITHUB_SHA ?? null,
+ },
+ measured,
+ turbopack: Object.fromEntries(
+ turboTargets.map((target) => [
+ target,
+ {
+ samples: turboSamples.get(target),
+ median: median(turboSamples.get(target)),
+ },
+ ]),
+ ),
+ },
+ null,
+ 2,
+ ),
+)
diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md
index 613206dd7..1a0976d80 100644
--- a/bindings/devup-ui-wasm/README.md
+++ b/bindings/devup-ui-wasm/README.md
@@ -1,85 +1,63 @@
-
+# @devup-ui/wasm
-
wasm-pack-template
+The Devup UI compiler as a Node.js WebAssembly module. It is the engine the
+build plugins (`@devup-ui/vite-plugin`, `next-plugin`, `webpack-plugin`,
+`rsbuild-plugin`, `bun-plugin`) call; most projects never import it directly.
-
A template for kick starting a Rust and WebAssembly project using wasm-pack .
+It is built from the Rust crates in `libs/` (`extractor`, `sheet`, `css`) with
+`wasm-pack build --target nodejs`, so it loads with `require`/`import` in Node
+and Bun, not in a browser.
-
-
-
+## Build
-
-
-
Built with 🦀🕸 by The Rust and WebAssembly Working Group
-
-
-
-## About
-
-[**📚 Read this template tutorial! 📚**][template-docs]
-
-This template is designed for compiling Rust libraries into WebAssembly and
-publishing the resulting package to NPM.
-
-Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other
-templates and usages of `wasm-pack`.
-
-[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html
-[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html
-
-## 🚴 Usage
-
-### 🐑 Use `cargo generate` to Clone this Template
-
-[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate)
-
-```
-cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project
-cd my-project
+```sh
+cd bindings/devup-ui-wasm
+bun run build # removes pkg/, runs wasm-pack, writes pkg/package.json
```
-### 🛠️ Build with `wasm-pack build`
+`pkg/` is generated and ignored by git. The build starts from an empty `pkg/`,
+so files of a removed export cannot linger there; `bun run verify:dist` in the
+repository root fails if one does.
-```
-wasm-pack build
-```
+## API
-### 🔬 Test in Headless Browsers with `wasm-pack test`
+Everything is exported from `pkg/index.js` (types in `pkg/index.d.ts`).
-```
-wasm-pack test --headless --firefox
-```
+| Function | Does |
+| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `codeExtract(filename, code, package, cssDir, singleCss, importMainCssInCode, importMainCssInCss, importAliases)` | Compiles one source file and returns an `Output`: the rewritten `code`, its `css`, `cssFile`, source `map`, the `dependencies` it read and `updatedBaseStyle`. Throws on a build error. |
+| `codeExtractWithoutSourceMap(...)` | The same without the source map. |
+| `hasDevupUI(filename, code, package)` | Whether the file imports Devup UI, so a plugin can skip the rest. |
+| `getCss(fileNum, importMainCss)` | The stylesheet collected so far: the whole sheet for `null`, one file's for its number. |
+| `registerTheme(theme)`, `registerShorthands(shorthands)` | Registers the `devup.json` theme and the custom shorthand props before extracting. |
+| `getThemeInterface(package, color, typography, length, shadows, theme)` | The `.d.ts` text that types the registered theme. |
+| `getDefaultTheme()` | The theme variant that applies without `data-theme`. |
+| `setPrefix(prefix)`, `getPrefix()` | Class name prefix. |
+| `setDebug(debug)`, `isDebug()` | Readable class names instead of base-37 ones, with repeated declarations kept (the tests run this way). |
+| `setModuleResolver(resolver)` | `(specifier, importer) => ({ path, code })`: how imports are resolved, so imported stylesheets are evaluated and imported constants inlined. |
+| `setAtomHoist(threshold)`, `importFileRoutes(routes)` | Moves atoms used by `threshold` routes or more into the shared sheet. Call before `codeExtract`. |
+| `exportSheet`, `exportClassMap`, `exportFileMap`, `exportCanonicalMap` and the matching `import*` functions | Save the collected state and load it into another process, as a bundler does between its server and client builds. |
-### 🎁 Publish to NPM with `wasm-pack publish`
+## State
-```
-wasm-pack publish
-```
+The module keeps its state, the sheet, class maps, theme, prefix and debug flag,
+in one global per process, and there is no single reset. Two consequences:
-## 🔋 Batteries Included
+- Register the theme, shorthands, prefix and resolver before the first
+ `codeExtract`, and extract the files of one build in one process.
+- Tests that share a process share that state. Set what a test depends on
+ (`registerTheme`, `setPrefix`, `setDebug`, `importSheet`) at its start, and
+ run files that need an empty sheet in their own process.
-- [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating
- between WebAssembly and JavaScript.
-- [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook)
- for logging panic messages to the developer console.
-- `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you
+## Errors
-## License
-
-Licensed under either of
-
-- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
-- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
+A source the build cannot compile makes `codeExtract` throw an `Error`. Its
+message names the call and says what the build needs, for example
+``path/to/file.tsx:12:5: `css()` cannot use `window.name` at build time: its
+values must be literals, theme tokens or constants`` (the location is included
+whenever the problem has one). A `.css.ts` stylesheet that throws while it runs
+reports that exception.
-at your option.
-
-### Contribution
+## License
-Unless you explicitly state otherwise, any contribution intentionally
-submitted for inclusion in the work by you, as defined in the Apache-2.0
-license, shall be dual licensed as above, without any additional terms or
-conditions.
+Apache-2.0
diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json
index c30850d49..963734de6 100644
--- a/bindings/devup-ui-wasm/package.json
+++ b/bindings/devup-ui-wasm/package.json
@@ -19,7 +19,7 @@
],
"version": "1.0.83",
"scripts": {
- "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js",
+ "build": "node -e \"require('node:fs').rmSync('pkg', { recursive: true, force: true })\" && wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js",
"test": "wasm-pack test --node"
},
"publishConfig": {
diff --git a/bun.lock b/bun.lock
index 412668d4f..1f7b6c2be 100644
--- a/bun.lock
+++ b/bun.lock
@@ -78,6 +78,25 @@
"typescript": "^7.0.2",
},
},
+ "apps/react-18": {
+ "name": "react-18-example",
+ "version": "0.1.0",
+ "dependencies": {
+ "@devup-ui/components": "workspace:^",
+ "@devup-ui/react": "workspace:^",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ },
+ "devDependencies": {
+ "@devup-ui/vite-plugin": "workspace:^",
+ "@playwright/test": "^1.63.0",
+ "@types/react": "^18.3",
+ "@types/react-dom": "^18.3",
+ "@vitejs/plugin-react": "^6.1",
+ "typescript": "^7.0.2",
+ "vite": "^8.3",
+ },
+ },
"apps/rsbuild": {
"name": "rsbuild-example",
"version": "1.0.0",
@@ -2813,6 +2832,8 @@
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
+ "react-18-example": ["react-18-example@workspace:apps/react-18"],
+
"react-docgen": ["react-docgen@8.0.4", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ=="],
"react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="],
@@ -3769,6 +3790,14 @@
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
+ "react-18-example/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
+
+ "react-18-example/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+
+ "react-18-example/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+
+ "react-18-example/react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+
"react-docgen/@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"react-docgen/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
@@ -5617,6 +5646,8 @@
"pkg-dir/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
+ "react-18-example/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
"react-docgen/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"react-docgen/@babel/core/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
diff --git a/packages/plugin-utils/src/__tests__/wasm-abi.test.ts b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts
new file mode 100644
index 000000000..6862abf62
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts
@@ -0,0 +1,254 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+// The compiled module as a plugin loads it: the Rust tests cover the crates, not
+// this boundary (every wrapper is excluded from tarpaulin), so the contract the
+// plugins rely on is checked here, through the JS bindings.
+const wasm = await import('../../../../bindings/devup-ui-wasm/pkg/index.js')
+
+const PACKAGE = '@devup-ui/react'
+
+/** What the module keeps for the process, so a test leaves it as it found it */
+function saveState() {
+ return {
+ sheet: JSON.parse(wasm.exportSheet()),
+ classMap: JSON.parse(wasm.exportClassMap()),
+ fileMap: JSON.parse(wasm.exportFileMap()),
+ canonicalMap: JSON.parse(wasm.exportCanonicalMap()),
+ css: wasm.getCss(null, false),
+ debug: wasm.isDebug(),
+ prefix: wasm.getPrefix(),
+ }
+}
+
+function restoreState(state: ReturnType) {
+ wasm.importSheet(state.sheet)
+ wasm.importClassMap(state.classMap)
+ wasm.importFileMap(state.fileMap)
+ wasm.importCanonicalMap(state.canonicalMap)
+ wasm.setDebug(state.debug)
+ wasm.setPrefix(state.prefix ?? null)
+}
+
+function extract(code: string, filename = 'abi.tsx') {
+ return wasm.codeExtract(filename, code, PACKAGE, 'df', true, false, false, {})
+}
+
+let saved: ReturnType
+
+beforeEach(() => {
+ saved = saveState()
+})
+
+afterEach(() => {
+ restoreState(saved)
+})
+
+describe('@devup-ui/wasm exports', () => {
+ it('exposes exactly the functions the plugins call', () => {
+ // default is the CommonJS module object a Node-style import adds
+ expect(
+ Object.keys(wasm)
+ .filter((name) => name !== 'default')
+ .sort(),
+ ).toEqual([
+ 'Output',
+ 'codeExtract',
+ 'codeExtractWithoutSourceMap',
+ 'exportCanonicalMap',
+ 'exportClassMap',
+ 'exportFileMap',
+ 'exportSheet',
+ 'getCss',
+ 'getDefaultTheme',
+ 'getPrefix',
+ 'getThemeInterface',
+ 'hasDevupUI',
+ 'importCanonicalMap',
+ 'importClassMap',
+ 'importFileMap',
+ 'importFileRoutes',
+ 'importSheet',
+ 'isDebug',
+ 'registerShorthands',
+ 'registerTheme',
+ 'resetBuildState',
+ 'seedFileMap',
+ 'setAtomHoist',
+ 'setDebug',
+ 'setModuleResolver',
+ 'setPrefix',
+ ])
+ })
+})
+
+describe('@devup-ui/wasm extraction', () => {
+ it('compiles a component and hands back the code, the css and what it read', () => {
+ wasm.setDebug(false)
+ const output = extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ )
+
+ expect(output.code).toContain('className=')
+ expect(output.code).not.toContain(' {
+ const output = wasm.codeExtractWithoutSourceMap(
+ 'abi.tsx',
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ PACKAGE,
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+
+ expect(output.code).toContain('className=')
+ expect(output.css).toContain('margin')
+ })
+
+ it('tells a file that uses Devup UI from one that does not', () => {
+ expect(
+ wasm.hasDevupUI('a.tsx', `import { Box } from '${PACKAGE}'`, PACKAGE),
+ ).toBe(true)
+ expect(
+ wasm.hasDevupUI('a.tsx', `import { Box } from 'other'`, PACKAGE),
+ ).toBe(false)
+ })
+
+ it('throws the build error with its location and what the build needs', () => {
+ expect(() =>
+ extract(
+ `import { css } from '${PACKAGE}'\nexport const a = css({ color: window.name })`,
+ ),
+ ).toThrow(
+ /abi\.tsx:2:\d+: `css\(\)` cannot use `window\.name` at build time: its values must be literals/,
+ )
+ })
+
+ it('leaves a file that does not use Devup UI as it is', () => {
+ const code = 'export const a = 1'
+ expect(extract(code).code).toBe(code)
+ })
+
+ it('applies the class name prefix, in debug mode too', () => {
+ wasm.setDebug(false)
+ wasm.setPrefix('du-')
+ expect(wasm.getPrefix()).toBe('du-')
+ const output = extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ )
+
+ expect(output.code).toMatch(/className="du-/)
+ wasm.setPrefix(null)
+ expect(wasm.getPrefix()).toBeUndefined()
+ })
+})
+
+describe('@devup-ui/wasm theme', () => {
+ it('registers a theme and describes it as types', () => {
+ wasm.registerTheme({
+ colors: { default: { primary: '#000' }, dark: { primary: '#fff' } },
+ typography: { heading: { fontSize: '24px' } },
+ })
+ const types = wasm.getThemeInterface(
+ PACKAGE,
+ 'CustomColors',
+ 'DevupThemeTypography',
+ 'CustomLength',
+ 'CustomShadows',
+ 'DevupTheme',
+ )
+
+ expect(types).toContain('$primary')
+ expect(types).toContain('heading')
+ expect(types).toContain('dark')
+ expect(wasm.getDefaultTheme()).toBe('default')
+ })
+
+ it('reads a theme token in a style prop', () => {
+ wasm.registerTheme({ colors: { default: { primary: '#0070f3' } } })
+ wasm.setDebug(false)
+ const output = extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ )
+
+ expect(output.css).toContain('var(--primary)')
+ })
+})
+
+describe('@devup-ui/wasm state', () => {
+ it('seeds deterministic file numbers when paths are unsorted and duplicated', () => {
+ wasm.resetBuildState()
+
+ wasm.seedFileMap(['src/c.tsx', 'src/b.tsx', 'src/a.tsx', 'src/b.tsx'])
+
+ expect(wasm.exportFileMap()).toBe(
+ '{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}',
+ )
+ })
+
+ it('clears maps, styles and prefix when resetting a populated build', () => {
+ wasm.resetBuildState()
+ const emptyCss = wasm.getCss(null, false)
+ const emptySheet = wasm.exportSheet()
+ wasm.setDebug(false)
+ wasm.setPrefix('du-')
+ wasm.seedFileMap(['abi.tsx'])
+ wasm.importCanonicalMap({ 'child.tsx': 'abi.tsx' })
+ const output = extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ )
+ expect(output.code).toMatch(/className="du-/)
+ expect(wasm.exportClassMap()).not.toBe('{}')
+ expect(wasm.exportFileMap()).toBe('{"abi.tsx":0}')
+ expect(wasm.exportCanonicalMap()).toBe('{"child.tsx":"abi.tsx"}')
+ expect(wasm.getCss(null, false)).toContain('background:red')
+
+ wasm.resetBuildState()
+
+ expect(wasm.exportClassMap()).toBe('{}')
+ expect(wasm.exportFileMap()).toBe('{}')
+ expect(wasm.exportCanonicalMap()).toBe('{}')
+ expect(wasm.exportSheet()).toBe(emptySheet)
+ expect(wasm.getCss(null, false)).toBe(emptyCss)
+ expect(wasm.getPrefix()).toBeUndefined()
+ })
+
+ it('puts the process back as a test found it', () => {
+ const before = saveState()
+ wasm.setDebug(!before.debug)
+ wasm.setPrefix('x-')
+ extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ )
+ expect(wasm.getCss(null, false)).not.toBe(before.css)
+
+ restoreState(before)
+
+ expect(wasm.isDebug()).toBe(before.debug)
+ expect(wasm.getPrefix()).toBe(before.prefix)
+ expect(wasm.getCss(null, false)).toBe(before.css)
+ expect(JSON.parse(wasm.exportClassMap())).toEqual(before.classMap)
+ })
+
+ it('carries the collected styles to another module state through the maps', () => {
+ wasm.setDebug(false)
+ extract(
+ `import { Box } from '${PACKAGE}'\nexport const a = `,
+ 'carried.tsx',
+ )
+ const carried = saveState()
+
+ wasm.importSheet(JSON.parse('{}'))
+ restoreState(carried)
+
+ expect(wasm.getCss(null, false)).toBe(carried.css)
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index 8a06a4135..8a642ad24 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -5,6 +5,8 @@ export default defineConfig({
snapshotPathTemplate:
'{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}',
fullyParallel: true,
+ // CI compares against the checked-in baselines and never writes one
+ updateSnapshots: process.env.CI ? 'none' : 'missing',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : 4,
diff --git a/render-benchmark-readme.js b/render-benchmark-readme.js
new file mode 100644
index 000000000..37418e76d
--- /dev/null
+++ b/render-benchmark-readme.js
@@ -0,0 +1,150 @@
+// Writes the benchmark section of README.md and README_ko.md from one checked
+// result file, so both languages publish the same run and the same numbers.
+//
+// bun render-benchmark-readme.js rewrite the sections
+// bun render-benchmark-readme.js --check fail when a README differs from them
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const START = ''
+const END = ''
+
+const results = JSON.parse(readFileSync('benchmark-results.json', 'utf8'))
+
+const bytes = (value) => `${value.toLocaleString('en-US')} bytes`
+const seconds = (value) => `${value.toFixed(2)}s`
+const list = (samples) => samples.map(seconds).join(', ')
+
+const TEXT = {
+ en: {
+ file: 'README.md',
+ intro: ({ run }) =>
+ `[Latest CI benchmark](${run.url}) (commit \`${run.commit}\`, Devup UI ${run.devupUi}) on \`${run.runner}\` with Next.js ${run.next}. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, \`benchmark-results.json\`.`,
+ sizes:
+ 'Build Size is every byte of the build output directory (`.next`, or `dist` for vinext); CSS Size is the emitted `.css` files alone, measured separately.',
+ webpack: 'Webpack values are one cold build:',
+ turbopack:
+ 'Turbopack values are medians of six cold builds in alternating order:',
+ columns: {
+ library: 'Library',
+ version: 'Version',
+ time: 'Build Time',
+ median: 'Median Build Time',
+ size: 'Build Size',
+ css: 'CSS Size',
+ },
+ note: ({ turbopack }) => {
+ const [tailwind, direct, stylesheet] = turbopack
+ const range = (row) => [
+ Math.min(...row.samples),
+ Math.max(...row.samples),
+ ]
+ const overlap =
+ range(tailwind)[0] <= range(direct)[1] &&
+ range(direct)[0] <= range(tailwind)[1]
+ const gap = tailwind.median - direct.median
+ return `${overlap ? 'The Turbopack ranges overlap' : 'The Turbopack ranges do not overlap'}, so the direct-API median is ${Math.abs(gap).toFixed(2)}s (${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? 'ahead of' : 'behind'} Tailwind on this fixture${overlap ? ' and the two are effectively at parity' : ''}. The six cold samples were Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, and static \`.css.ts\` \`${list(stylesheet.samples)}\`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every \`.css.ts\` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one.`
+ },
+ },
+ ko: {
+ file: 'README_ko.md',
+ intro: ({ run }) =>
+ `[최신 CI 벤치마크](${run.url}) (커밋 \`${run.commit}\`, Devup UI ${run.devupUi})는 \`${run.runner}\`에서 Next.js ${run.next}로 실행했습니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 \`benchmark-results.json\`에서 생성합니다.`,
+ sizes:
+ '빌드 사이즈는 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트이고, CSS 사이즈는 출력된 `.css` 파일만 따로 잰 값입니다.',
+ webpack: 'Webpack 값은 cold build 1회 결과입니다.',
+ turbopack:
+ 'Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙값입니다.',
+ columns: {
+ library: '라이브러리',
+ version: '버전',
+ time: '빌드 시간',
+ median: '빌드 시간 중앙값',
+ size: '빌드 사이즈',
+ css: 'CSS 사이즈',
+ },
+ note: ({ turbopack }) => {
+ const [tailwind, direct, stylesheet] = turbopack
+ const range = (row) => [
+ Math.min(...row.samples),
+ Math.max(...row.samples),
+ ]
+ const overlap =
+ range(tailwind)[0] <= range(direct)[1] &&
+ range(direct)[0] <= range(tailwind)[1]
+ const gap = tailwind.median - direct.median
+ return `Turbopack 측정 범위는 ${overlap ? '서로 겹치므로' : '겹치지 않으므로'} direct API 중앙값은 이 fixture에서 Tailwind보다 ${Math.abs(gap).toFixed(2)}s(${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? '빠르며' : '느리며'}${overlap ? ' 사실상 동률입니다' : ''}. cold 샘플 6개는 Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, static \`.css.ts\` \`${list(stylesheet.samples)}\`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 \`.css.ts\`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다.`
+ },
+ },
+}
+
+function table(text, rows, timeColumn, timeOf) {
+ const columns = text.columns
+ const header = [
+ columns.library,
+ columns.version,
+ timeColumn,
+ columns.size,
+ columns.css,
+ ]
+ const body = rows.map((row) => {
+ const bold = (value) => (row.devup ? `**${value}**` : value)
+ return [
+ bold(row.label),
+ row.version,
+ bold(seconds(timeOf(row))),
+ bytes(row.bytes),
+ bytes(row.cssBytes),
+ ]
+ })
+ const widths = header.map((_, column) =>
+ Math.max(...[header, ...body].map((line) => [...line[column]].length)),
+ )
+ const line = (cells) =>
+ `| ${cells.map((cell, column) => cell + ' '.repeat(widths[column] - [...cell].length)).join(' | ')} |`
+ return [
+ line(header),
+ `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`,
+ ...body.map(line),
+ ].join('\n')
+}
+
+function section(text) {
+ return [
+ START,
+ text.intro(results),
+ '',
+ text.sizes,
+ '',
+ text.webpack,
+ '',
+ table(text, results.webpack, text.columns.time, (row) => row.seconds),
+ '',
+ text.turbopack,
+ '',
+ table(text, results.turbopack, text.columns.median, (row) => row.median),
+ '',
+ text.note(results),
+ END,
+ ].join('\n')
+}
+
+let stale = false
+for (const text of Object.values(TEXT)) {
+ const current = readFileSync(text.file, 'utf8').replaceAll('\r\n', '\n')
+ const start = current.indexOf(START)
+ const end = current.indexOf(END)
+ if (start === -1 || end === -1)
+ throw new Error(`${text.file} has no ${START} ... ${END} block`)
+ const next =
+ current.slice(0, start) + section(text) + current.slice(end + END.length)
+ if (next === current) continue
+ if (process.argv.includes('--check')) {
+ console.error(
+ `${text.file} differs from benchmark-results.json: run \`bun render-benchmark-readme.js\``,
+ )
+ stale = true
+ } else {
+ writeFileSync(text.file, next)
+ }
+}
+if (stale) process.exit(1)
diff --git a/verify-dist.ts b/verify-dist.ts
index 68e146cf1..30baeb56b 100644
--- a/verify-dist.ts
+++ b/verify-dist.ts
@@ -79,15 +79,26 @@ async function verifyPackage(pkgDir: string): Promise {
return failures
}
+// Subpaths of a removed export (the `lite` engine) that a stale generated build can leave behind
+const REMOVED_SUBPATHS = ['bindings/devup-ui-wasm/pkg/lite']
+
const packagesDir = resolve(import.meta.dir, 'packages')
const dirs = await readdir(packagesDir, { withFileTypes: true })
-const failures = (
- await Promise.all(
- dirs
- .filter((d) => d.isDirectory())
- .map((d) => verifyPackage(join(packagesDir, d.name))),
- )
-).flat()
+const failures = [
+ ...REMOVED_SUBPATHS.filter((subpath) =>
+ existsSync(resolve(import.meta.dir, subpath)),
+ ).map(
+ (subpath) =>
+ `${subpath}: removed, but still on disk (rebuild from a clean pkg)`,
+ ),
+ ...(
+ await Promise.all(
+ dirs
+ .filter((d) => d.isDirectory())
+ .map((d) => verifyPackage(join(packagesDir, d.name))),
+ )
+ ).flat(),
+]
if (failures.length > 0) {
console.error(`verify-dist: ${failures.length} broken entry point(s)`)