diff --git a/.changepacks/changepack_log_bun_plugin_build.json b/.changepacks/changepack_log_bun_plugin_build.json
new file mode 100644
index 000000000..a461c80bf
--- /dev/null
+++ b/.changepacks/changepack_log_bun_plugin_build.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "packages/bun-plugin/package.json": "Patch"
+ },
+ "note": "Bun plugin works with Bun.build: `DevupUI()` (from `@devup-ui/bun-plugin/register`) serves the generated stylesheet to the bundler's CSS loader once every other module is loaded, so the build emits a CSS output with the styles of the whole bundle instead of an empty module; class names are short there and readable under the runtime (`debug` option). Files importing the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), `@stylexjs/stylex`, or a subpath or re-export of these or `@devup-ui/react`, are compiled instead of skipped",
+ "date": "2026-09-30T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json
new file mode 100644
index 000000000..667a75271
--- /dev/null
+++ b/.changepacks/changepack_log_coordinator_port_isolation.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "packages/next-plugin/package.json": "Patch"
+ },
+ "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable",
+ "date": "2026-10-02T12:41:12.3693972Z"
+}
diff --git a/.changepacks/changepack_log_css_snapshot.json b/.changepacks/changepack_log_css_snapshot.json
new file mode 100644
index 000000000..a1c52eeec
--- /dev/null
+++ b/.changepacks/changepack_log_css_snapshot.json
@@ -0,0 +1,9 @@
+{
+ "changes": {
+ "packages/webpack-plugin/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch",
+ "packages/plugin-utils/package.json": "Patch"
+ },
+ "note": "Webpack and Rsbuild builds hold every style in their stylesheets: a stylesheet module is built on its first import, so styles extracted later (the global styles of files webpack or Rsbuild reached late, often outside `src`) were missing from the output. The files the entries reach under `src` are now extracted before the build, the shared base on disk is kept current, and when a stylesheet still changed during the build it is compiled once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet instead of a module-wide string kept only when it grew, which left edits that shortened the CSS and later builds in the same process stale. plugin-utils adds computeReachableFiles",
+ "date": "2026-09-30T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json
new file mode 100644
index 000000000..053739d3d
--- /dev/null
+++ b/.changepacks/changepack_log_deterministic_numbering.json
@@ -0,0 +1,13 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/plugin-utils/package.json": "Patch",
+ "packages/vite-plugin/package.json": "Patch",
+ "packages/webpack-plugin/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch",
+ "packages/next-plugin/package.json": "Patch",
+ "packages/bun-plugin/package.json": "Patch"
+ },
+ "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale",
+ "date": "2026-10-01T00:00:00.000Z"
+}
\ No newline at end of file
diff --git a/.changepacks/changepack_log_plugin_core.json b/.changepacks/changepack_log_plugin_core.json
new file mode 100644
index 000000000..e74f874ac
--- /dev/null
+++ b/.changepacks/changepack_log_plugin_core.json
@@ -0,0 +1,13 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/plugin-utils/package.json": "Patch",
+ "packages/vite-plugin/package.json": "Patch",
+ "packages/webpack-plugin/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch",
+ "packages/bun-plugin/package.json": "Patch",
+ "packages/next-plugin/package.json": "Patch"
+ },
+ "note": "Use bundler project roots and active export conditions for build-time module resolution, resolve inherited tsconfig paths and dotted module specifiers, transform modern modules and compiled MDX, match included packages literally, and refresh empty theme declarations. Skip unrequested source maps, surface resolver faults before stylesheet mutation, and report WASM panic locations. Malformed or cyclic configuration, resolver failures, requested graph/prewarm failures and disabled Rsbuild shared splitting now stop builds with context; optional numbering fallback warns about its deterministic impact. Bun defaults to compact names and retains imported build-time dependency watch edges.",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_plugin_core_ci.json b/.changepacks/changepack_log_plugin_core_ci.json
new file mode 100644
index 000000000..398fa5756
--- /dev/null
+++ b/.changepacks/changepack_log_plugin_core_ci.json
@@ -0,0 +1,10 @@
+{
+ "changes": {
+ "packages/plugin-utils/package.json": "Patch",
+ "packages/vite-plugin/package.json": "Patch",
+ "packages/webpack-plugin/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch"
+ },
+ "note": "Keep raw MDX out of static import graphs unless the caller compiles MDX before extraction, preserving Next production prewarm behavior while MDX-aware plugins opt in. Normalize Webpack inline-loader entry resources before filesystem lookup and skip virtual or pitch-only entries without swallowing real I/O failures. Isolate watched-config regression diagnostics while preserving the prior theme on invalid configuration. No new build-error category is introduced.",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_plugin_core_mdx.json b/.changepacks/changepack_log_plugin_core_mdx.json
new file mode 100644
index 000000000..da179669f
--- /dev/null
+++ b/.changepacks/changepack_log_plugin_core_mdx.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Extract compiled MDX with its real source filename, including JSX-preserving alias imports and exact static computations. Raw or invalid MDX and unsupported extraction extensions now produce located build errors instead of untransformed styling placeholders.",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md
index 613206dd7..e2167ef6c 100644
--- a/bindings/devup-ui-wasm/README.md
+++ b/bindings/devup-ui-wasm/README.md
@@ -1,85 +1,117 @@
-
+# @devup-ui/wasm
-
wasm-pack-template
+Build-time CSS extraction bindings used by the Devup UI bundler plugins.
-
A template for kick starting a Rust and WebAssembly project using wasm-pack .
+## Extraction boundaries
-
-
-
+`codeExtract` generates a source map. `codeExtractWithoutSourceMap` skips map
+generation, including when a module resolver is installed; its `map` getter is
+`undefined`. Both functions return transformed source and watched dependencies.
-
+`setModuleResolver` accepts a synchronous `(specifier, importer)` callback.
+Return `{ path: string, code: string }` for a resolved module, or
+`{ ignored: true }` for an explicitly ignored native alias. An ignored result
+has no source/path and never reads `path`, `code` or `sourceType` getters.
+`null` and `undefined` mean ordinary unresolved imports. Thrown exceptions, property-getter
+and reflection failures, and missing/non-string fields instead fail extraction:
-
Built with 🦀🕸 by The Rust and WebAssembly Working Group
-
-
-
-## About
+```text
+/src/App.tsx:1:1: module resolver cannot use `./tokens` at build time: original cause
+```
-[**📚 Read this template tutorial! 📚**][template-docs]
+The importer includes transitive importers. The resolver contract does not
+provide an import span, so this boundary reports `1:1` rather than guessing a
+position from source text. The first resolver fault takes precedence over the
+extractor's unresolved-module fallback and is checked **before** sheet updates.
+The previous stylesheet contribution therefore survives a failed re-extraction.
+Class/file numbering performed inside extraction is not rolled back.
-This template is designed for compiling Rust libraries into WebAssembly and
-publishing the resulting package to NPM.
+Ignored imports are empty CommonJS modules, not renamed or virtual files. Missing
+named values are `undefined`, defaults follow the existing CommonJS interop, and
+namespace/require values are empty objects. No ignored file becomes a watched
+dependency or is sent through the parser. The `ignored` discriminator must be
+`true` or absent; malformed discriminators retain the same located resolver-fault
+priority and previous-sheet preservation as malformed file results.
-Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other
-templates and usages of `wasm-pack`.
+## Panic diagnostics
-[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
+WASM initialization installs this binding's own `std::panic::set_hook`, with no
+additional dependency or feature flag. It reports Rust's panic payload and
+`file:line:column` location to `console.error` before the trap. A failing host
+console does not replace the original panic. This is diagnostic reporting, not
+panic recovery: with `panic=abort`, a panic still traps and the instance should
+be discarded. Normal resolver/configuration failures use fallible JS errors.
-## 🚴 Usage
+Owned binding code does not unwrap user input or explicitly panic. Sheet mutex
+poisoning is recovered, and no resolver callback is called while holding a sheet
+lock or a thread-local resolver borrow. Panics originating in downstream crates
+remain outside this binding's recovery contract.
-### 🐑 Use `cargo generate` to Clone this Template
+## Verification
-[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate)
+From the repository root:
-```
-cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project
-cd my-project
+```sh
+cargo test -p devup-ui-wasm boundary_ -- --nocapture
```
-### 🛠️ Build with `wasm-pack build`
+After rebuilding `pkg` with this package's `bun run build`, run from the
+repository root:
-```
-wasm-pack build
+```sh
+node bindings/devup-ui-wasm/boundary-regressions.mjs
+node bindings/devup-ui-wasm/ignored-alias-regressions.mjs
```
-### 🔬 Test in Headless Browsers with `wasm-pack test`
+This standalone runner loads the real generated JS and WASM, outside Bun's
+coverage mocks. It exercises both extraction exports, map skipping with and
+without resolution, unresolved null/undefined, valid resolved dependencies,
+callback exceptions, getters, proxy reflection, malformed fields and hostile
+exception objects. Every fault case verifies the previous sheet is unchanged.
+Native tests cover every branch of the internal fault-precedence helper.
-```
-wasm-pack test --headless --firefox
-```
+## Explicit Compiler Source Type
-### 🎁 Publish to NPM with `wasm-pack publish`
+Both extraction functions accept an optional ninth argument after
+`import_aliases`: `sourceType?: 'compiled-mdx'`. Use it for project-compiled MDX
+under a custom extension, without changing the real filename:
+```ts
+const output = codeExtract(
+ 'src/page.mdown', compiledCode, '@devup-ui/react', 'df',
+ true, false, false, {}, 'compiled-mdx',
+)
```
-wasm-pack publish
-```
-
-## 🔋 Batteries Included
-- [`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
+The value means JavaScript with JSX, not TypeScript or raw Markdown. Omission
+keeps filename-derived parsing, including compiled JavaScript/JSX for `.md` and
+`.mdx`. Unknown extensions without an override fail with a located error even
+when the source has no styling import. Invalid source-type values also fail
+before stylesheet publication.
-## License
+Resolved modules may return `{ path, code, sourceType: 'compiled-mdx' }`. The
+child's own type reaches imported constants, reexports, stylesheet evaluation
+and stripping caches; it never inherits the importing file's override. This
+includes an ordinary TSX file importing a constant after JSX in compiled `.md`
+or `.mdown`. Parser/source-type failures preserve the previous stylesheet.
+Existing imported-value coordinate limitations remain owned by #757.
-Licensed under either of
+After rebuilding the bindings, run the real project-compiler/WASM matrix:
-- 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)
+```sh
+node bindings/devup-ui-wasm/source-type-regressions.mjs
+```
-at your option.
+The same JS runner also uses the installed `@mdx-js/mdx` compiler, resolved through
+`@mdx-js/loader` in `apps/landing` (no added dependency). Both default `_jsx`
+output and `jsx: true` output are extracted under real `.mdx` and `.md` filenames.
+It checks CSS, the relevance gate, source-map filename identity and rejection of
+raw Markdown. Compiled MDX is parsed as JSX JavaScript, not TSX. Its source-map
+coordinates refer to compiler output; bundler integrations must compose input
+maps to recover Markdown positions, or identify diagnostics as compiled MDX.
-### Contribution
+Focused native MDX verification:
-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.
+```sh
+cargo test -p extractor mdx_ -- --nocapture
+```
diff --git a/bindings/devup-ui-wasm/boundary-regressions.mjs b/bindings/devup-ui-wasm/boundary-regressions.mjs
new file mode 100644
index 000000000..ac30a81a9
--- /dev/null
+++ b/bindings/devup-ui-wasm/boundary-regressions.mjs
@@ -0,0 +1,224 @@
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+const require = createRequire(import.meta.url)
+const wasm = require('./pkg/index.js')
+const filename = '/src/boundary.tsx'
+const source = `import { Box } from '@devup-ui/react';
+import { PRIMARY } from './tokens';
+export const view = ;`
+const extract = (method, code = source) =>
+ wasm[method](filename, code, '@devup-ui/react', 'df', true, false, false, {})
+
+const loaderPath = require.resolve('@mdx-js/loader', {
+ paths: [fileURLToPath(new URL('../../apps/landing/', import.meta.url))],
+})
+const compilerPath = createRequire(loaderPath).resolve('@mdx-js/mdx')
+const { compile } = await import(pathToFileURL(compilerPath).href)
+const markdown = `import { Box } from '@devup-ui/react'
+
+# Heading
+
+Hello
+`
+for (const jsx of [false, true]) {
+ const compiled = String(
+ await compile({ path: '/src/document.mdx', value: markdown }, { jsx }),
+ )
+ for (const extension of ['mdx', 'md']) {
+ for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) {
+ wasm.resetBuildState()
+ const path = `/src/document.${extension}`
+ assert.equal(wasm.hasDevupUI(path, compiled, '@devup-ui/react'), true)
+ const output = wasm[method](
+ path,
+ compiled,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ assert.match(output.code, /className/)
+ assert.doesNotMatch(output.code, /(?:_jsx\(\s*Box\b|
+ wasm.codeExtract(
+ path,
+ markdown,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ ),
+ (error) => {
+ assert.ok(error instanceof Error)
+ assert.ok(error.message.startsWith(`${path}:`))
+ assert.match(error.message, /:\d+:\d+:/)
+ return true
+ },
+ )
+}
+console.info(
+ 'Real MDX compiler regressions passed (default JSX calls and jsx:true).',
+)
+
+for (const jsx of [false, true]) {
+ const markdown =
+ "import { css } from '@emotion/react'\n\nexport const cls = css({ color: 'red' })\n\n
\n"
+ const compiled = String(
+ await compile({ path: '/src/alias.mdx', value: markdown }, { jsx }),
+ )
+ wasm.resetBuildState()
+ const output = wasm.codeExtract(
+ '/src/alias.mdx',
+ compiled,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ { '@emotion/react': null },
+ )
+ assert.doesNotMatch(output.code, /@emotion\/react/)
+ assert.match(wasm.getCss(undefined, false), /color:red/)
+ output.free()
+}
+
+for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) {
+ wasm.resetBuildState()
+ const direct = extract(
+ method,
+ "import { Box } from '@devup-ui/react'; export const view = ;",
+ )
+ if (method === 'codeExtractWithoutSourceMap')
+ assert.equal(direct.map, undefined)
+ else assert.equal(typeof direct.map, 'string')
+ direct.free()
+ for (const result of [null, undefined]) {
+ wasm.resetBuildState()
+ wasm.setModuleResolver(() => result)
+ const output = extract(method)
+ assert.match(output.code, /--/)
+ if (method === 'codeExtractWithoutSourceMap')
+ assert.equal(output.map, undefined)
+ else assert.equal(typeof output.map, 'string')
+ output.free()
+ }
+
+ wasm.resetBuildState()
+ wasm.setModuleResolver(() => ({
+ path: '/src/tokens.ts',
+ code: "export const PRIMARY = 'blue';",
+ }))
+ const resolved = extract(method)
+ assert.deepEqual(resolved.dependencies, ['/src/tokens.ts'])
+ assert.doesNotMatch(resolved.code, /--/)
+ if (method === 'codeExtractWithoutSourceMap')
+ assert.equal(resolved.map, undefined)
+ resolved.free()
+
+ const faults = [
+ [
+ () => {
+ throw new Error('callback failure')
+ },
+ /callback failure/,
+ ],
+ [
+ () => {
+ throw 'string failure'
+ },
+ /string failure/,
+ ],
+ [
+ () => ({
+ get path() {
+ throw new Error('path getter failure')
+ },
+ }),
+ /path getter failure/,
+ ],
+ [
+ () => ({
+ path: '/src/tokens.ts',
+ get code() {
+ throw new Error('code getter failure')
+ },
+ }),
+ /code getter failure/,
+ ],
+ [
+ () =>
+ new Proxy(
+ {},
+ {
+ get() {
+ throw new Error('reflection failure')
+ },
+ },
+ ),
+ /reflection failure/,
+ ],
+ [() => 7, /Reflect|reflect|object/i],
+ [() => ({}), /path.*must be a string/],
+ [() => ({ path: 7, code: '' }), /path.*must be a string/],
+ [() => ({ path: '/src/tokens.ts', code: null }), /code.*must be a string/],
+ [() => ({ path: '/src/tokens.ts', code: 7 }), /code.*must be a string/],
+ [
+ () => {
+ throw new Proxy(
+ {},
+ {
+ get() {
+ throw new Error('hostile exception')
+ },
+ },
+ )
+ },
+ /unprintable JavaScript exception/,
+ ],
+ ]
+ for (const [resolver, cause] of faults) {
+ wasm.resetBuildState()
+ const initial = extract(
+ method,
+ "import { Box } from '@devup-ui/react'; export const view = ;",
+ )
+ initial.free()
+ const before = wasm.exportSheet()
+ wasm.setModuleResolver(resolver)
+ assert.throws(
+ () => extract(method),
+ (error) => {
+ assert.ok(error instanceof Error)
+ assert.match(
+ error.message,
+ /^\/src\/boundary\.tsx:1:1: module resolver cannot use `\.\/tokens` at build time: /,
+ )
+ assert.match(error.message, cause)
+ return true
+ },
+ )
+ assert.equal(wasm.exportSheet(), before)
+ }
+}
+wasm.resetBuildState()
+console.info(
+ 'WASM boundary regressions passed (real generated JS/WASM, no mocks).',
+)
diff --git a/bindings/devup-ui-wasm/ignored-alias-regressions.mjs b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs
new file mode 100644
index 000000000..76b50fa45
--- /dev/null
+++ b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs
@@ -0,0 +1,235 @@
+import assert from 'node:assert/strict'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { createModuleResolver } from '../../packages/plugin-utils/dist/index.mjs'
+import { verifyIgnoredSemantics } from './ignored-semantics-regressions.mjs'
+
+const wasm = createRequire(import.meta.url)('./pkg/index.js')
+const filename = '/src/ignored.tsx'
+const root = mkdtempSync(join(tmpdir(), 'devup-ignored-'))
+const file = (name, code) => {
+ const path = join(root, name)
+ writeFileSync(path, code)
+ return path
+}
+const barrel = file(
+ 'barrel.js',
+ "export {default as def, missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';",
+)
+const cjs = file(
+ 'cjs.js',
+ "const ns = require('empty'); const {missing} = require('empty'); exports.value = missing; exports.other = ns.absent;",
+)
+const whole = file('whole.js', "module.exports = require('empty');")
+const real = file('real.js', 'module.exports = {};')
+const shared = createModuleResolver({
+ cwd: root,
+ alias: { empty: false, barrel, cjs, whole, real },
+})
+try {
+ for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) {
+ verifyIgnoredSemantics(wasm, method, shared)
+ wasm.resetBuildState()
+ wasm.setModuleResolver(() => ({ ignored: true }))
+ const output = wasm[method](
+ filename,
+ "import { Box } from '@devup-ui/react'; import { missing } from 'empty'; export const view = ",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ assert.deepEqual(output.dependencies, [])
+ assert.doesNotMatch(output.code, /--/)
+ assert.doesNotMatch(wasm.getCss(undefined, false), /color:/)
+ output.free()
+ for (const binding of [
+ "import * as ns from 'empty'; const value = ns.missing;",
+ "import def from 'empty'; const value = def.missing;",
+ "import {value} from 'barrel';",
+ "import {ns} from 'barrel'; const value = ns.missing;",
+ "import {value} from 'cjs';",
+ "import {other as value} from 'cjs';",
+ "import def from 'whole'; const value = def.missing;",
+ ]) {
+ wasm.resetBuildState()
+ wasm.setModuleResolver(shared)
+ const resolved = wasm[method](
+ filename,
+ `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ assert.doesNotMatch(resolved.code, /--/)
+ assert.doesNotMatch(wasm.getCss(undefined, false), /color:/)
+ if (method === 'codeExtractWithoutSourceMap')
+ assert.equal(resolved.map, undefined)
+ else assert.equal(typeof resolved.map, 'string')
+ resolved.free()
+ }
+ wasm.resetBuildState()
+ wasm.setModuleResolver(shared)
+ const evaluated = wasm[method](
+ '/src/ignored.css.ts',
+ "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; import {ns as indirect, def as indirectDefault, value} from 'barrel'; function color() { if (missing !== undefined || value !== undefined || Object.keys(ns).length || Object.keys(def).length || Object.keys(indirect).length || Object.keys(indirectDefault).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ assert.match(wasm.getCss(undefined, false), /color:red/)
+ assert.deepEqual(evaluated.dependencies, [barrel])
+ evaluated.free()
+
+ wasm.resetBuildState()
+ const ignored = {
+ ignored: true,
+ get path() {
+ throw new Error('must not read path')
+ },
+ get code() {
+ throw new Error('must not read code')
+ },
+ get sourceType() {
+ throw new Error('must not read sourceType')
+ },
+ }
+ wasm.setModuleResolver(() => ignored)
+ const bypassed = wasm[method](
+ filename,
+ "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const view = ;",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ assert.deepEqual(bypassed.dependencies, [])
+ bypassed.free()
+
+ for (const result of [
+ true,
+ false,
+ 1,
+ 'ignored',
+ { ignored: false },
+ { ignored: null },
+ { ignored: 1 },
+ { ignored: 'true' },
+ {
+ get ignored() {
+ throw new Error('ignored getter first')
+ },
+ },
+ new Proxy(
+ {},
+ {
+ get() {
+ throw new Error('ignored proxy first')
+ },
+ },
+ ),
+ ]) {
+ wasm.resetBuildState()
+ wasm.setModuleResolver(undefined)
+ const initial = wasm[method](
+ filename,
+ "import {Box} from '@devup-ui/react'; export const view = ;",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ initial.free()
+ const sheet = wasm.exportSheet()
+ let calls = 0
+ wasm.setModuleResolver(() => {
+ calls += 1
+ return result
+ })
+ assert.throws(
+ () =>
+ wasm[method](
+ filename,
+ "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; import {other} from 'later'; export const view = ;",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ ),
+ /module resolver cannot use `empty` at build time/,
+ )
+ assert.equal(calls, 1)
+ assert.equal(wasm.exportSheet(), sheet)
+ }
+
+ wasm.resetBuildState()
+ wasm.setModuleResolver(shared)
+ const previous = wasm[method](
+ filename,
+ "import {Box} from '@devup-ui/react'; export const view = ;",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ previous.free()
+ const sheet = wasm.exportSheet()
+ assert.throws(
+ () =>
+ wasm[method](
+ filename,
+ "import {css} from '@devup-ui/react'; import def from 'empty'; def.color = 'red'; export const cls = css(def);",
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ ),
+ /changed/,
+ )
+ assert.equal(wasm.exportSheet(), sheet)
+ }
+ mkdirSync(join(root, 'src'))
+ const request = 'private-next-instrumentation-client-user'
+ const next = createModuleResolver({
+ cwd: root,
+ alias: {
+ [request]: [
+ join(root, 'src/instrumentation-client'),
+ join(root, 'instrumentation-client'),
+ 'private-next-empty-module',
+ ],
+ 'private-next-empty-module': false,
+ },
+ })
+ assert.deepEqual(next(request, join(root, 'entry.js')), { ignored: true })
+ const instrumentation = file(
+ 'instrumentation-client.js',
+ "export const value = 'blue';",
+ )
+ assert.equal(next(request, join(root, 'entry.js')).path, instrumentation)
+} finally {
+ wasm.setModuleResolver(undefined)
+ rmSync(root, { recursive: true, force: true })
+}
+console.info('Ignored alias actual WASM regressions passed.')
diff --git a/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs
new file mode 100644
index 000000000..62e89c7bb
--- /dev/null
+++ b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict'
+
+export function verifyIgnoredSemantics(wasm, method, resolver) {
+ const extract = (filename, code) => {
+ wasm.resetBuildState()
+ wasm.setModuleResolver(resolver)
+ const output = wasm[method](
+ filename,
+ code,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ const result = {
+ code: output.code,
+ css: wasm.getCss(undefined, false),
+ dependencies: output.dependencies,
+ }
+ output.free()
+ return result
+ }
+ for (const key of [
+ 'constructor',
+ 'toString',
+ '__proto__',
+ 'hasOwnProperty',
+ ]) {
+ const named = extract(
+ '/src/named.css.ts',
+ `import {style} from '@devup-ui/react'; import {${key} as value} from 'empty'; export const cls = style({color: typeof value === 'undefined' ? 'red' : 'blue'});`,
+ )
+ assert.match(named.css, /color:red/, `${method}: named ${key}`)
+ assert.doesNotMatch(named.css, /color:blue/)
+ assert.deepEqual(named.dependencies, [])
+ }
+ for (const binding of [
+ "import {missing as value} from 'empty';",
+ "import def from 'empty'; const value = def.missing;",
+ "import * as ns from 'empty'; const value = ns.missing;",
+ "import {value} from 'barrel';",
+ "import def from 'whole'; const value = def.missing;",
+ 'const record = {}; const value = record.missing;',
+ ]) {
+ const shadowed = extract(
+ '/src/shadowed.tsx',
+ `import {Box} from '@devup-ui/react'; ${binding} export const View = (undefined, read) => ;`,
+ )
+ const argument = /read\(([^)]+)\)/.exec(shadowed.code)?.[1]
+ assert(argument, shadowed.code)
+ const selected = new Function(
+ 'undefined',
+ 'read',
+ 'value',
+ `return read(${argument});`,
+ )('blue', (value) => value ?? 'red', undefined)
+ assert.equal(selected, 'red', `${method}: ${binding}`)
+ }
+ for (const binding of [
+ "import def from 'real';",
+ 'const def = {};',
+ "import def from 'empty';",
+ "import * as def from 'empty';",
+ "import def from 'whole';",
+ ]) {
+ for (const [key, expected] of [
+ ['constructor', 'Object'],
+ ['toString', 'Object.prototype.toString'],
+ ]) {
+ const real = extract(
+ '/src/real.tsx',
+ `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`,
+ )
+ const condition = /className=\{([^?]+)\?/.exec(real.code)?.[1]
+ assert(condition, real.code)
+ assert.equal(
+ new Function('def', `return ${condition};`)({}),
+ true,
+ `${method}: ${binding} ${key}`,
+ )
+ }
+ }
+ for (const binding of [
+ "import def from 'empty';",
+ "import * as def from 'empty';",
+ ]) {
+ const mutated = extract(
+ '/src/mutated.tsx',
+ `import {Box} from '@devup-ui/react'; ${binding} def.color = 'blue'; export const view = ;`,
+ )
+ assert.match(mutated.code, /def\.color/)
+ assert.match(mutated.css, /color:var\(/)
+ assert.deepEqual(mutated.dependencies, [])
+ }
+}
diff --git a/bindings/devup-ui-wasm/source-type-regressions.mjs b/bindings/devup-ui-wasm/source-type-regressions.mjs
new file mode 100644
index 000000000..a017a580d
--- /dev/null
+++ b/bindings/devup-ui-wasm/source-type-regressions.mjs
@@ -0,0 +1,246 @@
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { pathToFileURL } from 'node:url'
+
+const require = createRequire(
+ new URL('../../apps/landing/package.json', import.meta.url),
+)
+const compiler = createRequire(require.resolve('@mdx-js/loader')).resolve(
+ '@mdx-js/mdx',
+)
+const { compile } = await import(pathToFileURL(compiler).href)
+const wasm = createRequire(import.meta.url)('./pkg/index.js')
+const methods = ['codeExtract', 'codeExtractWithoutSourceMap']
+let passed = 0
+
+function check(name, action) {
+ wasm.resetBuildState()
+ action()
+ passed += 1
+ console.info(`PASS ${name}`)
+}
+
+function extract(method, filename, code, sourceType, aliases = {}) {
+ return wasm[method](
+ filename,
+ code,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ aliases,
+ sourceType,
+ )
+}
+
+const markdown =
+ 'import { Box } from "@devup-ui/react"\n\n \n'
+const aliasMarkdown =
+ 'import { css } from "@emotion/react"\n\nexport const cls = css({color: "blue"})\n\n
\n'
+for (const jsx of [false, true]) {
+ const code = String(
+ await compile(
+ { path: '/src/page.mdown', value: markdown },
+ { jsx, format: 'mdx' },
+ ),
+ )
+ const alias = String(
+ await compile(
+ { path: '/src/alias.mdown', value: aliasMarkdown },
+ { jsx, format: 'mdx' },
+ ),
+ )
+ for (const method of methods) {
+ for (const extension of ['md', 'mdx', 'mdown', 'ts']) {
+ const filename = `/src/page.${extension}`
+ check(`${method}:${jsx}:${extension}`, () => {
+ const output = extract(
+ method,
+ filename,
+ code,
+ ['mdown', 'ts'].includes(extension) ? 'compiled-mdx' : undefined,
+ )
+ try {
+ assert.match(wasm.getCss(undefined, false), /background:tomato/)
+ assert.doesNotMatch(output.code, /(?: {
+ const output = extract(
+ method,
+ '/src/alias.mdown',
+ alias,
+ 'compiled-mdx',
+ { '@emotion/react': null },
+ )
+ try {
+ assert.match(wasm.getCss(undefined, false), /color:blue/)
+ } finally {
+ output.free()
+ }
+ })
+ for (const reexport of [false, true]) {
+ check(`${method}:${jsx}:imported:${reexport}`, () => {
+ wasm.setModuleResolver((specifier) =>
+ specifier === './entry' && reexport
+ ? {
+ path: '/src/reexport.js',
+ code: "export {PRIMARY} from './tokens';",
+ }
+ : {
+ path: '/src/tokens.mdown',
+ code: `${code}\nexport const PRIMARY = 'blue';`,
+ sourceType: 'compiled-mdx',
+ },
+ )
+ const output = extract(
+ method,
+ '/src/App.tsx',
+ 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./entry"; export const view = ;',
+ )
+ try {
+ assert.match(wasm.getCss(undefined, false), /color:blue/)
+ assert.doesNotMatch(wasm.getCss(undefined, false), /var\(--/)
+ assert.ok(output.dependencies.includes('/src/tokens.mdown'))
+ } finally {
+ output.free()
+ }
+ })
+ }
+ }
+}
+for (const method of methods) {
+ const stylesheetModule = String(
+ await compile(
+ { path: '/src/value.mdown', value: '# Heading' },
+ { jsx: true, format: 'mdx' },
+ ),
+ )
+ check(`${method}:stylesheet-import`, () => {
+ wasm.setModuleResolver(() => ({
+ path: '/src/value.mdown',
+ code: `${stylesheetModule}\nexport const PRIMARY = 'blue';`,
+ sourceType: 'compiled-mdx',
+ }))
+ const output = extract(
+ method,
+ '/src/theme.css.ts',
+ 'import {style} from "@vanilla-extract/css"; import {PRIMARY} from "./value"; export const cls = style({color: PRIMARY});',
+ undefined,
+ { '@vanilla-extract/css': null },
+ )
+ try {
+ assert.match(wasm.getCss(undefined, false), /color:blue/)
+ } finally {
+ output.free()
+ }
+ })
+ for (const value of ['invalid', '', 7, {}, true]) {
+ check(`${method}:invalid-root:${JSON.stringify(value)}`, () => {
+ const previous = extract(
+ method,
+ '/src/plain.js',
+ 'import {css} from "@devup-ui/react"; export const cls = css({color: "green"});',
+ )
+ previous.free()
+ const sheet = wasm.exportSheet()
+ assert.throws(
+ () =>
+ extract(method, '/src/plain.js', 'export const answer = 42;', value),
+ /\/src\/plain\.js:1:1:.*source.*type/i,
+ )
+ assert.equal(wasm.exportSheet(), sheet)
+ })
+ }
+ check(`${method}:unknown-fastpath`, () => {
+ assert.throws(
+ () => extract(method, '/src/plain.mdown', 'export const answer = 42;'),
+ /\/src\/plain\.mdown:1:1:.*Unknown file extension/,
+ )
+ })
+ for (const value of ['invalid', 7]) {
+ check(`${method}:invalid-child:${value}`, () => {
+ const initial = extract(
+ method,
+ '/src/App.tsx',
+ 'import {Box} from "@devup-ui/react"; export const view = ;',
+ )
+ initial.free()
+ const sheet = wasm.exportSheet()
+ wasm.setModuleResolver(() => ({
+ path: '/src/token.js',
+ code: "export const PRIMARY = 'blue';",
+ sourceType: value,
+ }))
+ assert.throws(
+ () =>
+ extract(
+ method,
+ '/src/App.tsx',
+ 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;',
+ ),
+ /\/src\/App\.tsx:1:1:.*source.*type/i,
+ )
+ assert.equal(wasm.exportSheet(), sheet)
+ })
+ }
+ check(`${method}:getter`, () => {
+ wasm.setModuleResolver(() => ({
+ path: '/src/token.js',
+ code: "export const PRIMARY = 'blue';",
+ get sourceType() {
+ throw new Error('sourceType getter fault')
+ },
+ }))
+ assert.throws(
+ () =>
+ extract(
+ method,
+ '/src/App.tsx',
+ 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;',
+ ),
+ /\/src\/App\.tsx:1:1:.*sourceType getter fault/,
+ )
+ })
+ check(`${method}:root-mode-not-inherited`, () => {
+ wasm.setModuleResolver(() => ({
+ path: '/src/token.ts',
+ code: "export enum Colors { PRIMARY = 'blue' }",
+ }))
+ const output = extract(
+ method,
+ '/src/App.mdown',
+ 'import {Box} from "@devup-ui/react"; import {Colors} from "./token"; export const view = ;',
+ 'compiled-mdx',
+ )
+ try {
+ assert.match(wasm.getCss(undefined, false), /color:blue/)
+ } finally {
+ output.free()
+ }
+ })
+ check(`${method}:unknown-child`, () => {
+ wasm.setModuleResolver(() => ({
+ path: '/src/token.custom',
+ code: "export const PRIMARY = 'blue';",
+ }))
+ assert.throws(
+ () =>
+ extract(
+ method,
+ '/src/App.tsx',
+ 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;',
+ ),
+ /\/src\/token\.custom:1:1:.*Unknown file extension/,
+ )
+ })
+}
+wasm.resetBuildState()
+console.info(JSON.stringify({ passed }))
diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs
index 0a2347ac2..4c819f749 100644
--- a/bindings/devup-ui-wasm/src/lib.rs
+++ b/bindings/devup-ui-wasm/src/lib.rs
@@ -2,10 +2,11 @@ use css::class_map::{set_class_map, with_class_map};
use css::file_map::{
canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map,
};
+#[cfg(test)]
+use extractor::extract;
use extractor::extract_style::extract_style_value::ExtractStyleValue;
use extractor::{
- ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules,
- extract_without_source_map, has_devup_ui,
+ ExtractOption, ImportAlias, ModuleResolution, ModuleResolver, ResolvedModule, has_devup_ui,
};
use rustc_hash::FxHashSet;
use sheet::StyleSheet;
@@ -14,6 +15,8 @@ use std::collections::{BTreeMap, HashMap};
use std::fmt::Display;
use std::sync::{LazyLock, Mutex};
use wasm_bindgen::prelude::*;
+#[cfg(test)]
+mod source_type_tests;
static GLOBAL_STYLE_SHEET: LazyLock> =
LazyLock::new(|| Mutex::new(StyleSheet::default()));
@@ -53,6 +56,52 @@ fn js_error(message: impl Display) -> JsValue {
js_sys::Error::new(&message.to_string()).into()
}
+#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))]
+#[wasm_bindgen]
+extern "C" {
+ #[wasm_bindgen(js_namespace = console, js_name = error, catch)]
+ fn report_panic(message: &str) -> Result<(), JsValue>;
+}
+
+#[cfg(not(tarpaulin_include))]
+fn resolver_cause(value: &JsValue) -> String {
+ if let Some(message) = value.as_string() {
+ return message;
+ }
+ if let Ok(message) = js_sys::Reflect::get(value, &"message".into())
+ && let Some(message) = message.as_string()
+ {
+ return message;
+ }
+ match js_sys::JSON::stringify(value) {
+ Ok(text) => text.as_string().unwrap_or_else(|| "undefined".to_string()),
+ Err(_) => "".to_string(),
+ }
+}
+
+/// Report the panic payload and Rust file:line:column before an aborting WASM trap.
+#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))]
+#[wasm_bindgen(start)]
+pub fn install_panic_hook() {
+ std::panic::set_hook(Box::new(|info| {
+ // A broken host console must not replace the original panic with a JS exception.
+ match report_panic(&info.to_string()) {
+ Ok(()) | Err(_) => {}
+ }
+ }));
+}
+
+/// Prefer a resolver failure over extraction's unresolved-module fallback.
+fn checked_extraction(
+ extracted: Result,
+ fault: Option<&RefCell>>,
+) -> Result {
+ if let Some(message) = fault.and_then(|fault| fault.borrow_mut().take()) {
+ return Err(message);
+ }
+ extracted.map_err(|error| error.to_string())
+}
+
#[wasm_bindgen]
pub struct Output {
code: String,
@@ -243,7 +292,14 @@ pub fn export_sheet() -> Result {
/// Internal function to export class map as JSON string (testable without `JsValue`)
pub fn export_class_map_internal() -> Result {
- with_class_map(serde_json::to_string).map_err(|e| e.to_string())
+ with_class_map(|map| {
+ let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map
+ .iter()
+ .map(|(file, classes)| (file, classes.iter().collect()))
+ .collect();
+ serde_json::to_string(&sorted)
+ })
+ .map_err(|e| e.to_string())
}
#[wasm_bindgen(js_name = "importClassMap")]
@@ -261,7 +317,11 @@ pub fn export_class_map() -> Result {
/// Internal function to export file map as JSON string (testable without `JsValue`)
pub fn export_file_map_internal() -> Result {
- with_file_map(serde_json::to_string).map_err(|e| e.to_string())
+ with_file_map(|map| {
+ let sorted: BTreeMap<&String, &usize> = map.iter().collect();
+ serde_json::to_string(&sorted)
+ })
+ .map_err(|e| e.to_string())
}
#[wasm_bindgen(js_name = "importFileMap")]
@@ -284,7 +344,11 @@ pub fn import_canonical_map_internal(map: HashMap) {
/// Internal function to export the canonical map as JSON string (testable without `JsValue`)
pub fn export_canonical_map_internal() -> Result {
- with_canonical_map(serde_json::to_string).map_err(|e| e.to_string())
+ with_canonical_map(|map| {
+ let sorted: BTreeMap<&String, &String> = map.iter().collect();
+ serde_json::to_string(&sorted)
+ })
+ .map_err(|e| e.to_string())
}
#[wasm_bindgen(js_name = "importCanonicalMap")]
@@ -300,6 +364,34 @@ pub fn export_canonical_map() -> Result {
export_canonical_map_internal().map_err(js_error)
}
+/// Number every file in `files` now, in path order, keeping numbers files
+/// already hold, so class prefixes depend on the paths and not on the order
+/// workers reach files in.
+#[wasm_bindgen(js_name = "seedFileMap")]
+pub fn seed_file_map(files: Vec) {
+ css::file_map::seed_file_numbers(&files);
+}
+
+/// Forget everything one build left in the engine.
+///
+/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting
+/// and the module resolver, so the next build starts from its own options
+/// alone. Theme, shorthands and debug mode are set by every build, and stay.
+pub fn reset_build_state_internal() {
+ css::class_map::reset_class_map();
+ css::file_map::reset_file_map();
+ css::file_map::reset_canonical_map();
+ css::file_routes::set_file_routes(HashMap::new());
+ css::atom_hoist::set_atom_hoist(None);
+ css::set_prefix(None);
+ with_style_sheet_mut(|sheet| *sheet = StyleSheet::default());
+ MODULE_RESOLVER.with_borrow_mut(|current| *current = None);
+}
+
+#[wasm_bindgen(js_name = "resetBuildState")]
+pub fn reset_build_state() {
+ reset_build_state_internal();
+}
/// Set the atom-level hoist threshold.
///
/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct
@@ -345,6 +437,7 @@ pub fn code_extract_internal(
import_main_css_in_code: bool,
import_main_css_in_css: bool,
import_aliases: HashMap,
+ source_type: Option,
) -> Result {
code_extract_internal_impl(
filename,
@@ -357,6 +450,8 @@ pub fn code_extract_internal(
import_aliases,
SourceMapMode::Generate,
None,
+ None,
+ source_type,
)
}
@@ -370,6 +465,7 @@ pub fn code_extract_without_source_map_internal(
import_main_css_in_code: bool,
import_main_css_in_css: bool,
import_aliases: HashMap,
+ source_type: Option,
) -> Result {
code_extract_internal_impl(
filename,
@@ -382,6 +478,8 @@ pub fn code_extract_without_source_map_internal(
import_aliases,
SourceMapMode::Skip,
None,
+ None,
+ source_type,
)
}
@@ -397,6 +495,7 @@ pub fn code_extract_with_modules_internal(
import_main_css_in_code: bool,
import_main_css_in_css: bool,
import_aliases: HashMap,
+ source_type: Option,
resolver: &ModuleResolver,
) -> Result {
code_extract_internal_impl(
@@ -410,6 +509,8 @@ pub fn code_extract_with_modules_internal(
import_aliases,
SourceMapMode::Generate,
Some(resolver),
+ None,
+ source_type,
)
}
@@ -425,6 +526,8 @@ fn code_extract_internal_impl(
import_aliases: HashMap,
source_map: SourceMapMode,
resolver: Option<&ModuleResolver>,
+ resolver_fault: Option<&RefCell>>,
+ source_type: Option,
) -> Result {
let option = ExtractOption {
package: package.to_string(),
@@ -433,19 +536,16 @@ fn code_extract_internal_impl(
import_main_css: import_main_css_in_code,
import_aliases,
};
- let extracted = match (resolver, source_map) {
- (Some(resolver), mode) => extract_with_modules(
- filename,
- code,
- option,
- matches!(mode, SourceMapMode::Generate),
- resolver,
- ),
- (None, SourceMapMode::Generate) => extract(filename, code, option),
- (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option),
- };
+ let extracted = extractor::extract_with_source_type(
+ filename,
+ code,
+ option,
+ matches!(source_map, SourceMapMode::Generate),
+ resolver,
+ source_type,
+ );
- match extracted {
+ match checked_extraction(extracted, resolver_fault) {
Ok(output) => Ok(Output::new(
output.code,
output.styles,
@@ -456,7 +556,7 @@ fn code_extract_internal_impl(
import_main_css_in_css,
output.dependencies,
)),
- Err(error) => Err(error.to_string()),
+ Err(error) => Err(error),
}
}
@@ -475,19 +575,53 @@ fn call_module_resolver(
resolver: &js_sys::Function,
specifier: &str,
importer: &str,
-) -> Option {
+) -> Result, String> {
let module = resolver
.call2(&JsValue::NULL, &specifier.into(), &importer.into())
- .ok()?;
+ .map_err(|error| resolver_cause(&error))?;
+ if module.is_null() || module.is_undefined() {
+ return Ok(None);
+ }
+ if !module.is_object() {
+ return Err("resolver result must be an object".to_string());
+ }
+ let ignored = js_sys::Reflect::get(&module, &"ignored".into()).map_err(|error| {
+ format!(
+ "reading resolver field `ignored`: {}",
+ resolver_cause(&error)
+ )
+ })?;
+ if !ignored.is_undefined() {
+ return match ignored.as_bool() {
+ Some(true) => Ok(Some(ModuleResolution::Ignored)),
+ Some(false) | None => {
+ Err("resolver field `ignored` must be true or undefined".to_string())
+ }
+ };
+ }
let field = |name: &str| {
js_sys::Reflect::get(&module, &name.into())
- .ok()?
+ .map_err(|error| {
+ format!(
+ "reading resolver field `{name}`: {}",
+ resolver_cause(&error)
+ )
+ })?
.as_string()
+ .ok_or_else(|| format!("resolver field `{name}` must be a string"))
};
- Some(ResolvedModule {
+ Ok(Some(ModuleResolution::Resolved(ResolvedModule {
path: field("path")?,
code: field("code")?,
- })
+ source_type: source_type_from_js(
+ js_sys::Reflect::get(&module, &"sourceType".into()).map_err(|error| {
+ format!(
+ "reading resolver field `sourceType`: {}",
+ resolver_cause(&error)
+ )
+ })?,
+ )?,
+ })))
}
/// Extract with the resolver set by `setModuleResolver`, if any
@@ -503,10 +637,29 @@ fn code_extract_js(
import_main_css_in_css: bool,
import_aliases: JsValue,
source_map: SourceMapMode,
+ source_type: JsValue,
) -> Result {
+ let source_type = source_type_from_js(source_type)
+ .map_err(|error| js_error(format!("{filename}:1:1: {error}")))?;
let import_aliases = import_aliases_from_js(import_aliases)?;
+ let fault = std::rc::Rc::new(RefCell::new(None));
+ let resolver_fault = std::rc::Rc::clone(&fault);
let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| {
- move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer)
+ move |specifier: &str, importer: &str| {
+ if resolver_fault.borrow().is_some() {
+ return None;
+ }
+ match call_module_resolver(&resolver, specifier, importer) {
+ Ok(module) => module,
+ Err(cause) => {
+ // ModuleResolver carries no source span, including for transitive imports.
+ *resolver_fault.borrow_mut() = Some(format!(
+ "{importer}:1:1: module resolver cannot use `{specifier}` at build time: {cause}"
+ ));
+ None
+ }
+ }
+ }
});
code_extract_internal_impl(
filename,
@@ -521,10 +674,23 @@ fn code_extract_js(
resolver
.as_ref()
.map(|resolver| resolver as &ModuleResolver),
+ Some(&fault),
+ source_type,
)
.map_err(js_error)
}
+#[cfg(not(tarpaulin_include))]
+fn source_type_from_js(value: JsValue) -> Result, String> {
+ if value.is_undefined() || value.is_null() {
+ return extractor::parse_source_type(None);
+ }
+ let value = value.as_string().ok_or_else(|| {
+ "source type cannot use a non-string at build time: expected `compiled-mdx`".to_string()
+ })?;
+ extractor::parse_source_type(Some(&value))
+}
+
#[cfg(not(tarpaulin_include))]
fn import_aliases_from_js(
import_aliases: JsValue,
@@ -544,7 +710,7 @@ fn import_aliases_from_js(
}
#[cfg(not(tarpaulin_include))]
-#[wasm_bindgen(js_name = "codeExtract")]
+#[wasm_bindgen(js_name = "codeExtract", skip_typescript)]
#[allow(clippy::too_many_arguments)]
pub fn code_extract(
filename: &str,
@@ -555,6 +721,7 @@ pub fn code_extract(
import_main_css_in_code: bool,
import_main_css_in_css: bool,
import_aliases: JsValue,
+ source_type: JsValue,
) -> Result {
code_extract_js(
filename,
@@ -566,11 +733,12 @@ pub fn code_extract(
import_main_css_in_css,
import_aliases,
SourceMapMode::Generate,
+ source_type,
)
}
#[cfg(not(tarpaulin_include))]
-#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap")]
+#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap", skip_typescript)]
#[allow(clippy::too_many_arguments)]
pub fn code_extract_without_source_map(
filename: &str,
@@ -581,6 +749,7 @@ pub fn code_extract_without_source_map(
import_main_css_in_code: bool,
import_main_css_in_css: bool,
import_aliases: JsValue,
+ source_type: JsValue,
) -> Result {
code_extract_js(
filename,
@@ -591,10 +760,17 @@ pub fn code_extract_without_source_map(
import_main_css_in_code,
import_main_css_in_css,
import_aliases,
- SourceMapMode::Generate,
+ SourceMapMode::Skip,
+ source_type,
)
}
+#[wasm_bindgen(typescript_custom_section)]
+const EXTRACTION_TYPES: &str = r#"
+export function codeExtract(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output;
+export function codeExtractWithoutSourceMap(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output;
+"#;
+
/// Internal function to register theme (testable without `JsValue`)
pub fn register_theme_internal(theme: sheet::theme::Theme) {
with_style_sheet_mut(|sheet| sheet.set_theme(theme));
@@ -689,6 +865,53 @@ mod tests {
ct
}
+ #[rstest]
+ #[case(Ok(42), false, None, Ok(42))]
+ #[case(Err("extract failed"), false, None, Err("extract failed"))]
+ #[case(Ok(42), true, None, Ok(42))]
+ #[case(Ok(42), true, Some("resolver failed"), Err("resolver failed"))]
+ #[case(
+ Err("extract failed"),
+ true,
+ Some("resolver failed"),
+ Err("resolver failed")
+ )]
+ fn boundary_fault_takes_precedence_when_resolver_failed(
+ #[case] extracted: Result,
+ #[case] has_resolver: bool,
+ #[case] message: Option<&str>,
+ #[case] expected: Result,
+ ) {
+ let fault = has_resolver.then(|| RefCell::new(message.map(str::to_string)));
+ let result = checked_extraction(extracted, fault.as_ref());
+ assert_eq!(result, expected.map_err(str::to_string));
+ }
+
+ #[test]
+ #[serial]
+ fn boundary_sheet_stays_unchanged_when_resolver_failed() {
+ reset_build_state_internal();
+ let before = export_sheet_internal().unwrap();
+ let fault = RefCell::new(Some("resolver failed".to_string()));
+ let result = code_extract_internal_impl(
+ "boundary.tsx",
+ "import { Box } from '@devup-ui/react'; export const view = ;",
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ SourceMapMode::Skip,
+ None,
+ Some(&fault),
+ None,
+ );
+ assert_eq!(result.err(), Some("resolver failed".to_string()));
+ assert_eq!(export_sheet_internal().unwrap(), before);
+ reset_build_state_internal();
+ }
+
#[test]
#[serial]
fn atom_hoist_splits_global_and_private() {
@@ -726,6 +949,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
code_extract_internal(
@@ -737,6 +961,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
@@ -871,6 +1096,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
}
@@ -921,6 +1147,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
}
@@ -1023,6 +1250,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
}
@@ -1183,9 +1411,12 @@ mod tests {
*sheet = StyleSheet::default();
}
let resolver = |specifier: &str, _: &str| {
- (specifier == "./tokens").then(|| ResolvedModule {
- path: "/src/tokens.ts".to_string(),
- code: "export const PRIMARY = 'red'".to_string(),
+ (specifier == "./tokens").then(|| {
+ ModuleResolution::Resolved(ResolvedModule {
+ source_type: None,
+ path: "/src/tokens.ts".to_string(),
+ code: "export const PRIMARY = 'red'".to_string(),
+ })
})
};
{
@@ -1198,6 +1429,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
&resolver,
)
.unwrap();
@@ -1709,6 +1941,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
)
.unwrap();
}
@@ -1929,6 +2162,60 @@ mod tests {
assert!(json_str.starts_with('{') || json_str.starts_with("[]"));
}
+ #[test]
+ #[serial]
+ fn test_exported_maps_are_canonical_json() {
+ css::class_map::reset_class_map();
+ css::file_map::reset_file_map();
+ css::file_map::reset_canonical_map();
+ css::class_map::set_class_map(HashMap::from([
+ (
+ "b.tsx".to_string(),
+ HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]),
+ ),
+ ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])),
+ ]));
+ assert_eq!(
+ export_class_map_internal().unwrap(),
+ r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"#
+ );
+ seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]);
+ assert_eq!(
+ export_file_map_internal().unwrap(),
+ r#"{"a.tsx":0,"b.tsx":1}"#
+ );
+ import_canonical_map_internal(HashMap::from([
+ ("y".to_string(), "b".to_string()),
+ ("x".to_string(), "a".to_string()),
+ ]));
+ assert_eq!(
+ export_canonical_map_internal().unwrap(),
+ r#"{"x":"a","y":"b"}"#
+ );
+ reset_build_state();
+ assert_eq!(export_class_map_internal().unwrap(), "{}");
+ assert_eq!(export_file_map_internal().unwrap(), "{}");
+ assert_eq!(export_canonical_map_internal().unwrap(), "{}");
+ assert_eq!(get_prefix(), None);
+ }
+
+ #[test]
+ #[serial]
+ fn test_numbers_do_not_depend_on_the_order_files_are_seen() {
+ let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"];
+ let mut maps = Vec::new();
+ for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] {
+ reset_build_state_internal();
+ seed_file_map(files.iter().map(ToString::to_string).collect());
+ for index in order {
+ let _ = css::file_map::get_file_num_by_filename(files[index]);
+ }
+ maps.push(export_file_map_internal().unwrap());
+ }
+ assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#);
+ assert_eq!(maps[0], maps[1]);
+ assert_eq!(maps[0], maps[2]);
+ }
#[test]
#[serial]
fn test_code_extract_internal_success() {
@@ -1947,6 +2234,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
);
assert!(result.is_ok());
@@ -1971,6 +2259,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
);
assert!(result.is_ok());
@@ -1996,6 +2285,7 @@ mod tests {
false,
false,
HashMap::new(),
+ None,
);
assert!(result.is_err());
diff --git a/bindings/devup-ui-wasm/src/source_type_tests.rs b/bindings/devup-ui-wasm/src/source_type_tests.rs
new file mode 100644
index 000000000..d38d3a7b9
--- /dev/null
+++ b/bindings/devup-ui-wasm/src/source_type_tests.rs
@@ -0,0 +1,100 @@
+use super::*;
+use serial_test::serial;
+
+#[test]
+#[serial]
+fn source_type_extracts_compiled_mdx_when_native_variants_use_real_filename() -> Result<(), String>
+{
+ reset_build_state_internal();
+ let code = "import {Box} from '@devup-ui/react'; export const view = ;";
+ let mode = Some(extractor::ExtractSourceType::CompiledMdx);
+ let mapped = code_extract_internal(
+ "page.mdown",
+ code,
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ mode,
+ )?;
+ assert!(mapped.map().is_some_and(|map| map.contains("page.mdown")));
+ let unmapped = code_extract_without_source_map_internal(
+ "page.mdown",
+ code,
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ mode,
+ )?;
+ assert_eq!(unmapped.map(), None);
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "tokens.mdown".to_string(),
+ code: "export function view() {return
;} export const PRIMARY = 'blue';"
+ .to_string(),
+ source_type: mode,
+ }))
+ };
+ let imported = code_extract_with_modules_internal(
+ "page.mdown",
+ "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;",
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ mode,
+ &resolver,
+ )?;
+ assert_eq!(imported.dependencies(), ["tokens.mdown"]);
+ assert!(!with_style_sheet(|sheet| sheet.create_css(None, false)).contains("var(--"));
+ reset_build_state_internal();
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn source_type_preserves_sheet_when_unknown_child_validation_fails() -> Result<(), String> {
+ reset_build_state_internal();
+ code_extract_internal(
+ "page.tsx",
+ "import {Box} from '@devup-ui/react'; export const view = ;",
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ None,
+ )?;
+ let before = export_sheet_internal()?;
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "tokens.custom".to_string(),
+ code: "export const PRIMARY = 'red';".to_string(),
+ source_type: None,
+ }))
+ };
+ let result = code_extract_with_modules_internal(
+ "page.tsx",
+ "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;",
+ "@devup-ui/react",
+ "df".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ None,
+ &resolver,
+ );
+ assert!(result.is_err_and(|error| error.starts_with("tokens.custom:1:1:")));
+ assert_eq!(export_sheet_internal()?, before);
+ reset_build_state_internal();
+ Ok(())
+}
diff --git a/bun.lock b/bun.lock
index a6f7b10d1..906ce66d6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -489,11 +489,11 @@
},
"bindings/devup-ui-wasm": {
"name": "@devup-ui/wasm",
- "version": "1.0.82",
+ "version": "1.0.83",
},
"packages/bun-plugin": {
"name": "@devup-ui/bun-plugin",
- "version": "1.0.21",
+ "version": "1.0.22",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -508,7 +508,7 @@
},
"packages/components": {
"name": "@devup-ui/components",
- "version": "0.1.59",
+ "version": "0.1.60",
"dependencies": {
"@devup-ui/react": "workspace:^",
"clsx": "^2.1",
@@ -535,7 +535,7 @@
},
"packages/eslint-plugin": {
"name": "@devup-ui/eslint-plugin",
- "version": "1.0.21",
+ "version": "1.0.22",
"dependencies": {
"@typescript-eslint/utils": "^8.70",
"typescript-eslint": "^8.70",
@@ -552,7 +552,7 @@
},
"packages/next-plugin": {
"name": "@devup-ui/next-plugin",
- "version": "1.0.89",
+ "version": "1.0.90",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -570,7 +570,7 @@
},
"packages/plugin-utils": {
"name": "@devup-ui/plugin-utils",
- "version": "1.0.16",
+ "version": "1.0.17",
"devDependencies": {
"typescript": "^7.0.2",
},
@@ -611,7 +611,7 @@
},
"packages/rsbuild-plugin": {
"name": "@devup-ui/rsbuild-plugin",
- "version": "1.0.66",
+ "version": "1.0.67",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -627,7 +627,7 @@
},
"packages/vite-plugin": {
"name": "@devup-ui/vite-plugin",
- "version": "1.0.72",
+ "version": "1.0.73",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -642,7 +642,7 @@
},
"packages/webpack-plugin": {
"name": "@devup-ui/webpack-plugin",
- "version": "1.0.70",
+ "version": "1.0.71",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
diff --git a/bun.preload.ts b/bun.preload.ts
new file mode 100644
index 000000000..4d90997be
--- /dev/null
+++ b/bun.preload.ts
@@ -0,0 +1,3 @@
+import { register } from './packages/bun-plugin/src/register'
+
+await register({ debug: true })
diff --git a/bunfig.toml b/bunfig.toml
index 1241b1841..67ea09046 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,11 +1,8 @@
[test]
root = "packages"
-preload = ["./packages/bun-plugin/src/index.ts", "./bun.setup.ts", "bun-test-env-dom"]
+preload = ["./bun.preload.ts", "./bun.setup.ts", "bun-test-env-dom"]
coverage = true
coverageReporter = ["text", "lcov"]
coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 }
coverageSkipTestFiles = true
-# pkg/dist are generated artifacts. bun-plugin/src/plugin.ts registers a Bun
-# plugin at import time; testing its private hooks would require exporting
-# internals that are not part of the public API.
-coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**", "**/bun-plugin/src/plugin.ts"]
+coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"]
diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs
index 2a8c86a94..f0a20a939 100644
--- a/libs/css/src/file_map.rs
+++ b/libs/css/src/file_map.rs
@@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize {
})
}
+/// Give every file in `files` a number now, in path order.
+///
+/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone,
+/// not on which file a worker reaches first; files that appear later (in
+/// development) are numbered after the existing ones.
+pub fn seed_file_numbers(files: &[String]) {
+ let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect();
+ sorted.sort_unstable();
+ sorted.dedup();
+ for file in sorted {
+ let _ = get_file_num_by_filename(&file);
+ }
+}
#[must_use]
pub fn get_filename_by_file_num(file_num: usize) -> String {
with_file_map(|map| {
@@ -196,6 +209,47 @@ mod tests {
use super::*;
+ #[test]
+ #[serial]
+ fn test_seed_file_numbers_numbers_buckets_not_their_members() {
+ reset_file_map();
+ set_canonical_map(std::collections::HashMap::from([(
+ "x.tsx".to_string(),
+ "a.tsx".to_string(),
+ )]));
+ seed_file_numbers(&[
+ "x.tsx".to_string(),
+ "b.tsx".to_string(),
+ "a.tsx".to_string(),
+ ]);
+ assert_eq!(get_file_num_by_filename("a.tsx"), 0);
+ assert_eq!(get_file_num_by_filename("b.tsx"), 1);
+ assert_eq!(get_file_map().len(), 2);
+ reset_canonical_map();
+ reset_file_map();
+ }
+
+ #[test]
+ #[serial]
+ fn test_seed_file_numbers_is_independent_of_order() {
+ reset_file_map();
+ seed_file_numbers(&[
+ "b.tsx".to_string(),
+ "a.tsx".to_string(),
+ "b.tsx".to_string(),
+ ]);
+ assert_eq!(get_file_num_by_filename("a.tsx"), 0);
+ assert_eq!(get_file_num_by_filename("b.tsx"), 1);
+ seed_file_numbers(&[
+ "c.tsx".to_string(),
+ "0.tsx".to_string(),
+ "a.tsx".to_string(),
+ ]);
+ assert_eq!(get_file_num_by_filename("a.tsx"), 0);
+ assert_eq!(get_file_num_by_filename("0.tsx"), 2);
+ assert_eq!(get_file_num_by_filename("c.tsx"), 3);
+ reset_file_map();
+ }
#[test]
#[serial]
fn test_set_and_get_file_map() {
diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs
index 1e8bdd7e3..db8d5be59 100644
--- a/libs/extractor/src/build_time_values.rs
+++ b/libs/extractor/src/build_time_values.rs
@@ -15,7 +15,7 @@ use oxc_ast::ast::{
use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_semantic::{Scoping, SemanticBuilder};
-use oxc_span::{GetSpan, SourceType, Span};
+use oxc_span::{GetSpan, Span};
use oxc_syntax::operator::LogicalOperator;
use oxc_syntax::reference::ReferenceId;
use oxc_syntax::symbol::SymbolId;
@@ -62,9 +62,10 @@ pub(crate) fn has_build_time_values(
filename,
&option.package,
&option.import_aliases,
+ crate::parser_source_type(filename).unwrap_or_default(),
);
let allocator = Allocator::default();
- let Some(mut program) = parse(&allocator, filename, &code) else {
+ let Some(mut program) = parse(&allocator, filename, &code, None) else {
return false;
};
let inlined = crate::imported_constants::inline_constants(
@@ -130,8 +131,13 @@ const UNCERTAIN_MEMBERS: [&str; 9] = [
"toString",
];
-fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> {
- let source_type = SourceType::from_path(filename).ok()?;
+fn parse<'a>(
+ allocator: &'a Allocator,
+ filename: &str,
+ code: &'a str,
+ mode: Option,
+) -> Option> {
+ let source_type = crate::source_type::parser_type(filename, mode).ok()?;
let parsed = Parser::new(allocator, code, source_type).parse();
(!parsed.fatal_error).then_some(parsed.program)
}
@@ -916,12 +922,13 @@ pub(crate) fn evaluate(
option: &ExtractOption,
resolver: Option<&ModuleResolver>,
unknown: &crate::imported_constants::Unknown,
+ mode: Option,
) -> Option<(
String,
Vec,
BTreeSet,
)> {
- let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?;
+ let (mut values, dependencies) = compute(code, filename, option, resolver, unknown, mode)?;
values.sort_unstable_by_key(|(span, _)| span.start);
let mut result = String::with_capacity(code.len());
let mut edits = Vec::with_capacity(values.len());
@@ -968,13 +975,14 @@ fn compute(
option: &ExtractOption,
resolver: Option<&ModuleResolver>,
unknown: &crate::imported_constants::Unknown,
+ mode: Option,
) -> Option<(Vec, BTreeSet)> {
use std::fmt::Write;
use boa_engine::{Context, JsObject, Source};
let allocator = Allocator::default();
- let program = parse(&allocator, filename, code)?;
+ let program = parse(&allocator, filename, code, mode)?;
let is_style = |source: &str| {
source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source)
};
@@ -1059,7 +1067,8 @@ fn compute(
.collect();
let _ = writeln!(module, "[{}];", names.join(", "));
- let script = crate::vanilla_extract::strip_typescript(&module, filename);
+ let script =
+ crate::vanilla_extract::strip_typescript_with_type(&module, filename, mode).ok()?;
let mut context = Context::default();
context
.runtime_limits_mut()
diff --git a/libs/extractor/src/ignored_loader_tests.rs b/libs/extractor/src/ignored_loader_tests.rs
new file mode 100644
index 000000000..89303f3ef
--- /dev/null
+++ b/libs/extractor/src/ignored_loader_tests.rs
@@ -0,0 +1,68 @@
+use super::*;
+
+#[test]
+fn ignored_loader_when_imported_or_reexported_has_empty_namespace() -> Result<(), String> {
+ let resolver = |specifier: &str, _: &str| {
+ match specifier {
+ "empty" => Some(ModuleResolution::Ignored),
+ "barrel" => Some(ModuleResolution::Resolved(crate::ResolvedModule {
+ path: "/src/barrel.js".to_string(),
+ code: "export {default as def, missing as value, __exports__ as hidden} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(),
+ source_type: None,
+ })),
+ "cjs" => Some(ModuleResolution::Resolved(crate::ResolvedModule {
+ path: "/src/cjs.js".to_string(),
+ code: "const first = require('empty'); const second = require('empty'); const {missing} = require('empty'); module.exports = {ok: first === second && Object.keys(first).length === 0 && first.missing === undefined && missing === undefined};".to_string(),
+ source_type: None,
+ })),
+ _ => None,
+ }
+ };
+ let option = ExtractOption::default();
+ let mut loader = ModuleLoader::new(Some(&resolver), &option);
+ let script = module_script(
+ "import def, {missing, __exports__ as hidden} from 'empty'; import * as ns from 'empty'; import * as barrel from 'barrel'; import cjs from 'cjs'; const ok = missing === undefined && hidden === undefined && Object.keys(def).length === 0 && Object.keys(ns).length === 0 && barrel.value === undefined && barrel.hidden === undefined && Object.keys(barrel.ns).length === 0 && Object.keys(barrel.def).length === 0 && Object.keys(barrel).length === 4 && cjs.ok; ok;",
+ "/src/entry.js",
+ &mut loader,
+ true,
+ )?;
+ let value = boa_engine::Context::default()
+ .eval(boa_engine::Source::from_bytes(&format!(
+ "{}{}",
+ loader.prelude(),
+ script.body
+ )))
+ .map_err(|error| error.to_string())?;
+ assert_eq!(value.as_boolean(), Some(true));
+ assert_eq!(
+ loader.dependencies,
+ BTreeSet::from(["/src/barrel.js".to_string(), "/src/cjs.js".to_string()])
+ );
+ assert_eq!(loader.kept_imports, Vec::::new());
+ Ok(())
+}
+
+#[test]
+fn ignored_named_when_key_is_inherited_is_undefined() -> Result<(), String> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let option = ExtractOption::default();
+ for key in ["constructor", "toString", "__proto__", "hasOwnProperty"] {
+ let mut loader = ModuleLoader::new(Some(&resolver), &option);
+ let script = module_script(
+ &format!("import {{ {key} as value }} from 'empty'; typeof value === 'undefined';"),
+ "/src/entry.js",
+ &mut loader,
+ true,
+ )?;
+ let value = boa_engine::Context::default()
+ .eval(boa_engine::Source::from_bytes(&format!(
+ "{}{}",
+ loader.prelude(),
+ script.body
+ )))
+ .map_err(|error| error.to_string())?;
+ assert_eq!(value.as_boolean(), Some(true), "{key}");
+ assert_eq!(loader.dependencies, BTreeSet::new());
+ }
+ Ok(())
+}
diff --git a/libs/extractor/src/ignored_module_tests.rs b/libs/extractor/src/ignored_module_tests.rs
new file mode 100644
index 000000000..84c1193fd
--- /dev/null
+++ b/libs/extractor/src/ignored_module_tests.rs
@@ -0,0 +1,254 @@
+use super::*;
+use serial_test::serial;
+
+#[test]
+#[serial]
+fn ignored_default_when_serialized_is_an_ordinary_empty_object() -> Result<(), String> {
+ let filename = "/src/ignored-literal.ts";
+ let allocator = Allocator::default();
+ let parsed = Parser::new(
+ &allocator,
+ "import ignored from 'empty';",
+ SourceType::from_path(filename).map_err(|error| error.to_string())?,
+ )
+ .parse();
+ assert_eq!(parsed.diagnostics.errors().count(), 0);
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let option = ExtractOption::default();
+ let check =
+ imported_constants::ChangeCheck::new(&parsed.program, filename, &option, Some(&resolver));
+ let literal = check
+ .known("ignored")
+ .ok_or_else(|| "Ignored default import must have a known value".to_string())?;
+ let value = boa_engine::Context::default()
+ .eval(boa_engine::Source::from_bytes(&format!(
+ "const value = {literal}; Object.keys(value).length === 0 && Object.getPrototypeOf(value) === Object.prototype;"
+ )))
+ .map_err(|error| error.to_string())?;
+ assert_eq!(value.as_boolean(), Some(true));
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_object_when_spread_into_style_preserves_static_color() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let output = extract_with_modules(
+ "/src/ignored-spread.ts",
+ "import {css} from '@devup-ui/react'; import ignored from 'empty'; const input = {...ignored, color: 'red'}; export const cls = css(input);",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert_eq!(output.styles.len(), 1);
+ assert!(output.styles.iter().all(|style| matches!(
+ style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red"
+ )));
+ assert!(!output.code.contains("css("), "{}", output.code);
+ assert_eq!(output.dependencies, Vec::::new());
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_object_when_used_as_style_extracts_empty_static_rules() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let output = extract_with_modules(
+ "/src/ignored-style.ts",
+ "import {css} from '@devup-ui/react'; import ignored from 'empty'; export const cls = css(ignored);",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert_eq!(output.styles.len(), 0, "{:?}", output.styles);
+ assert!(!output.code.contains("css("), "{}", output.code);
+ assert!(!output.code.contains("--"), "{}", output.code);
+ assert_eq!(output.dependencies, Vec::::new());
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_named_values_when_resolved_have_no_dynamic_style_or_dependency()
+-> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ for binding in [
+ "import { missing as value } from 'empty';",
+ "import * as ns from 'empty'; const value = ns.missing;",
+ "import def from 'empty'; const value = def.missing;",
+ ] {
+ let output = extract_with_modules(
+ "/src/ignored.tsx",
+ &format!(
+ "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;"
+ ),
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert_eq!(output.dependencies, Vec::::new());
+ assert!(!output.code.contains("--"), "{}", output.code);
+ }
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_stylesheet_bindings_when_evaluated_are_empty_commonjs() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let output = extract_with_modules(
+ "/src/ignored.css.ts",
+ "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; function color() { if (missing !== undefined || Object.keys(ns).length || Object.keys(def).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert_eq!(output.dependencies, Vec::::new());
+ assert!(!output.code.contains("style("));
+ assert!(!output.code.contains("--"));
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_constants_when_required_or_reexported_remain_known() -> Result<(), Box> {
+ let resolver = |specifier: &str, _: &str| {
+ match specifier {
+ "empty" => Some(ModuleResolution::Ignored),
+ "barrel" => Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "/src/barrel.js".to_string(),
+ code: "export {missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(),
+ source_type: None,
+ })),
+ "cjs" => Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "/src/cjs.js".to_string(),
+ code: "module.exports = require('empty');".to_string(),
+ source_type: None,
+ })),
+ "cjsProps" => Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "/src/cjsProps.js".to_string(),
+ code: "const {missing} = require('empty'); exports.value = missing; exports.other = require('empty').absent;".to_string(),
+ source_type: None,
+ })),
+ _ => None,
+ }
+ };
+ for (binding, dependencies) in [
+ ("import {value} from 'barrel';", vec!["/src/barrel.js"]),
+ (
+ "import {ns} from 'barrel'; const value = ns.missing;",
+ vec!["/src/barrel.js"],
+ ),
+ (
+ "import def from 'cjs'; const value = def.missing;",
+ vec!["/src/cjs.js"],
+ ),
+ ("import {value} from 'cjsProps';", vec!["/src/cjsProps.js"]),
+ (
+ "import {other as value} from 'cjsProps';",
+ vec!["/src/cjsProps.js"],
+ ),
+ ] {
+ let output = extract_with_modules(
+ "/src/entry.tsx",
+ &format!(
+ "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;"
+ ),
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert_eq!(output.dependencies, dependencies);
+ assert!(!output.code.contains("--"), "{}", output.code);
+ }
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_object_when_mutated_is_not_inlined_as_empty() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ for binding in ["import def from 'empty';", "import * as def from 'empty';"] {
+ let output = extract_with_modules(
+ "/src/mutated.tsx",
+ &format!(
+ "import {{Box}} from '@devup-ui/react'; {binding} def.color = 'blue'; export const view = ;"
+ ),
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert!(output.code.contains("--"));
+ assert_eq!(output.dependencies, Vec::::new());
+ }
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn real_commonjs_when_empty_keeps_inherited_reads() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "/src/tokens.js".to_string(),
+ code: "module.exports = {};".to_string(),
+ source_type: None,
+ }))
+ };
+ let output = extract_with_modules(
+ "/src/real.tsx",
+ "import {Box} from '@devup-ui/react'; import def from 'tokens'; export const view = ;",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert!(output.code.contains("def.constructor"), "{}", output.code);
+ assert_eq!(output.dependencies, vec!["/src/tokens.js"]);
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn ignored_missing_when_undefined_is_shadowed_keeps_primitive_argument()
+-> Result<(), Box> {
+ let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored);
+ let output = extract_with_modules(
+ "/src/shadowed.tsx",
+ "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const View = (undefined, read) => ;",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert!(output.code.contains("read(void 0)"), "{}", output.code);
+ assert_eq!(output.dependencies, Vec::::new());
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn unresolved_require_when_dynamic_or_shadowed_is_not_an_ignored_constant()
+-> Result<(), Box> {
+ for code in [
+ "module.exports = require(name);",
+ "module.exports = require('missing');",
+ "const require = () => ({}); module.exports = require('empty');",
+ ] {
+ let resolver = |specifier: &str, _: &str| match specifier {
+ "tokens" => Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "/src/tokens.js".to_string(),
+ code: code.to_string(),
+ source_type: None,
+ })),
+ "empty" => Some(ModuleResolution::Ignored),
+ _ => None,
+ };
+ let output = extract_with_modules(
+ "/src/unresolved.tsx",
+ "import {Box} from '@devup-ui/react'; import value from 'tokens'; export const view = ;",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )?;
+ assert!(output.code.contains("--"));
+ }
+ Ok(())
+}
diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs
index 158d0f420..e8fb47d20 100644
--- a/libs/extractor/src/import_alias_visit.rs
+++ b/libs/extractor/src/import_alias_visit.rs
@@ -22,7 +22,7 @@ use oxc_ast_visit::{
walk::{walk_call_expression, walk_jsx_opening_element},
};
use oxc_parser::Parser;
-use oxc_span::{GetSpan, SourceType};
+use oxc_span::GetSpan;
use std::borrow::Cow;
use std::collections::HashMap;
@@ -226,7 +226,14 @@ pub fn transform_import_aliases<'a>(
package: &str,
import_aliases: &HashMap,
) -> Cow<'a, str> {
- transform_import_aliases_with_edits(code, filename, package, import_aliases).0
+ transform_import_aliases_with_edits(
+ code,
+ filename,
+ package,
+ import_aliases,
+ crate::parser_source_type(filename).unwrap_or_default(),
+ )
+ .0
}
/// A replacement of `code[start..end]` by text of `length` bytes
@@ -249,6 +256,7 @@ pub fn transform_import_aliases_with_edits<'a>(
filename: &str,
package: &str,
import_aliases: &HashMap,
+ source_type: oxc_span::SourceType,
) -> (Cow<'a, str>, Vec) {
// Quick check: if no aliases match, return original code
if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) {
@@ -256,7 +264,6 @@ pub fn transform_import_aliases_with_edits<'a>(
}
let allocator = Allocator::default();
- let source_type = SourceType::from_path(filename).unwrap_or_default();
// Parse the code
let parser_ret = Parser::new(&allocator, code, source_type).parse();
@@ -566,7 +573,7 @@ mod tests {
use super::*;
use insta::assert_snapshot;
use oxc_ast::builder::AstBuilder;
- use oxc_span::SPAN;
+ use oxc_span::{SPAN, SourceType};
fn emotion_alias() -> HashMap {
let mut aliases = HashMap::new();
diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs
index a600d1deb..a0501cee8 100644
--- a/libs/extractor/src/imported_constants.rs
+++ b/libs/extractor/src/imported_constants.rs
@@ -15,14 +15,20 @@ use oxc_ast::builder::AstBuilder;
use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut};
use oxc_parser::Parser;
use oxc_semantic::{Scoping, SemanticBuilder};
-use oxc_span::{SPAN, SourceType};
+use oxc_span::SPAN;
+#[cfg(test)]
+#[path = "imported_source_type_tests.rs"]
+mod source_type_tests;
use oxc_syntax::number::NumberBase;
use oxc_syntax::operator::BinaryOperator;
use oxc_syntax::symbol::SymbolId;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::stylex::StylexFunction;
-use crate::{ExtractOption, ModuleResolver};
+use crate::{ExtractOption, ModuleResolution, ModuleResolver};
+
+mod exports;
+use exports::Exports;
#[derive(Clone, Debug)]
enum Constant {
@@ -38,6 +44,8 @@ enum Constant {
Object(Rc>),
/// An object literal every property of which is known, in source order
Record(Rc>),
+ /// An ignored module's empty `CommonJS` object, with ordinary prototype reads
+ IgnoredObject,
Array(Rc>),
/// `StyleX` custom properties by key, which read as their `var()`
Vars(Rc>),
@@ -53,7 +61,10 @@ enum Constant {
impl Constant {
/// Whether code can change what it holds
const fn is_mutable(&self) -> bool {
- matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_))
+ matches!(
+ self,
+ Self::Object(_) | Self::Record(_) | Self::IgnoredObject | Self::Array(_)
+ )
}
/// Whether this value or one it holds is a function
@@ -74,7 +85,8 @@ impl Constant {
Self::Number(number) => Some(crate::utils::js_number_string(*number)),
Self::Null => Some("null".to_string()),
Self::Bool(value) => Some(value.to_string()),
- Self::Undefined => Some("undefined".to_string()),
+ Self::Undefined => Some("(void 0)".to_string()),
+ Self::IgnoredObject => Some("({})".to_string()),
Self::Record(entries) => {
let mut properties = Vec::with_capacity(entries.len());
for (key, value) in entries.iter() {
@@ -479,7 +491,11 @@ pub(crate) fn inline_constants<'a>(
}
(scoping, reads_math)
};
- inlined.dependencies = modules.exports.into_keys().collect();
+ inlined.dependencies = modules
+ .exports
+ .into_keys()
+ .map(|(path, _, _)| path)
+ .collect();
if !symbols.is_empty() || reads_math {
Inline {
ast_builder,
@@ -554,7 +570,12 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> {
/// The modules read for the values of imports
pub(crate) fn dependencies(&self) -> BTreeSet {
- self.modules.borrow().exports.keys().cloned().collect()
+ self.modules
+ .borrow()
+ .exports
+ .keys()
+ .map(|(path, _, _)| path.clone())
+ .collect()
}
pub(crate) fn is_changed(&self, name: &str) -> bool {
@@ -715,35 +736,42 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> {
/// The constant exports of the modules read, by path
struct Modules<'r> {
- resolver: Option<&'r ModuleResolver>,
+ resolver: Option<&'r ModuleResolver<'r>>,
option: &'r ExtractOption,
- exports: FxHashMap>>,
+ exports: FxHashMap>>,
loading: Vec,
}
impl Modules<'_> {
- fn exports(
- &mut self,
- specifier: &str,
- importer: &str,
- ) -> Option>> {
- let module = (self.resolver?)(specifier, importer)?;
- if let Some(exports) = self.exports.get(&module.path) {
- return Some(exports.clone());
+ fn exports(&mut self, specifier: &str, importer: &str) -> Option {
+ let module = match (self.resolver?)(specifier, importer)? {
+ ModuleResolution::Ignored => return Some(Exports::Ignored),
+ ModuleResolution::Resolved(module) => module,
+ };
+ let key = (module.path.clone(), module.code.clone(), module.source_type);
+ if let Some(exports) = self.exports.get(&key) {
+ return Some(Exports::Partial(exports.clone()));
}
if self.loading.contains(&module.path) {
return None;
}
self.loading.push(module.path.clone());
- let exports = Rc::new(self.read(&module.path, &module.code));
+ let exports = Rc::new(self.read(&module.path, &module.code, module.source_type));
self.loading.pop();
- self.exports.insert(module.path, exports.clone());
- Some(exports)
+ self.exports.insert(key, exports.clone());
+ Some(Exports::Partial(exports))
}
- fn read(&mut self, path: &str, code: &str) -> FxHashMap {
+ fn read(
+ &mut self,
+ path: &str,
+ code: &str,
+ mode: Option,
+ ) -> FxHashMap {
let allocator = Allocator::default();
- let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts());
+ let Ok(source_type) = crate::source_type::parser_type(path, mode) else {
+ return FxHashMap::default();
+ };
let program = Parser::new(&allocator, code, source_type).parse().program;
let mut scope = ModuleScope::new(path, &program, Some(code));
let mut exports = FxHashMap::default();
@@ -785,11 +813,8 @@ impl Modules<'_> {
Statement::ExportFromDeclaration(export) => {
if let Some(from) = self.exports(&export.source.value, path) {
for specifier in &export.specifiers {
- if let Some(constant) = from.get(specifier.local.name().as_str()) {
- exports.insert(
- specifier.exported.name().to_string(),
- constant.clone(),
- );
+ if let Some(constant) = from.named(specifier.local.name().as_str()) {
+ exports.insert(specifier.exported.name().to_string(), constant);
}
}
}
@@ -798,10 +823,10 @@ impl Modules<'_> {
if let Some(from) = self.exports(&export.source.value, path) {
match &export.exported {
Some(exported) => {
- exports.insert(exported.name().to_string(), Constant::Object(from));
+ exports.insert(exported.name().to_string(), from.namespace());
}
None => {
- for (name, constant) in from.iter() {
+ for (name, constant) in from.entries() {
if name != "default" {
exports
.entry(name.clone())
@@ -1072,8 +1097,9 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
self.uses = Some(uses.clone());
uses
};
- let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace)));
let value = self.lookup_raw(modules, name);
+ let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace)))
+ && !matches!(value, Some(Constant::IgnoredObject));
let mut change = None;
for found in uses.get(name).into_iter().flatten() {
change = match found {
@@ -1251,8 +1277,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
let (source, imported) = self.imports.get(name)?;
let exports = modules.exports(source, self.path)?;
match imported {
- Imported::Named(export) => exports.get(export).cloned(),
- Imported::Namespace => Some(Constant::Object(exports)),
+ Imported::Named(export) => exports.named(export),
+ Imported::Namespace => Some(exports.namespace()),
}
}
@@ -1456,6 +1482,18 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
member.property.name.as_str(),
),
Expression::CallExpression(call) => match &call.callee {
+ Expression::Identifier(callee)
+ if callee.name == "require" && !self.binds("require") =>
+ {
+ let [Argument::StringLiteral(source)] = call.arguments.as_slice() else {
+ return None;
+ };
+ Some(
+ modules
+ .exports(source.value.as_str(), self.path)?
+ .namespace(),
+ )
+ }
Expression::StaticMemberExpression(callee)
if self.is_global_math(&callee.object) =>
{
@@ -1509,12 +1547,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
(key, value)
}
ObjectPropertyKind::SpreadProperty(spread) => {
- if let Some(Constant::Record(spread)) = self.evaluate(modules, &spread.argument)
- {
- for (key, value) in spread.iter() {
- set_entry(&mut entries, key.clone(), value.clone());
+ match self.evaluate(modules, &spread.argument) {
+ Some(Constant::IgnoredObject) => continue,
+ Some(Constant::Record(spread)) => {
+ for (key, value) in spread.iter() {
+ set_entry(&mut entries, key.clone(), value.clone());
+ }
+ continue;
}
- continue;
+ _ => {}
}
(None, None)
}
@@ -1570,6 +1611,25 @@ fn member_of(object: &Constant, key: &str) -> Option {
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.clone()),
+ Constant::IgnoredObject
+ if !matches!(
+ key,
+ "constructor"
+ | "toString"
+ | "toLocaleString"
+ | "valueOf"
+ | "hasOwnProperty"
+ | "isPrototypeOf"
+ | "propertyIsEnumerable"
+ | "__proto__"
+ | "__defineGetter__"
+ | "__defineSetter__"
+ | "__lookupGetter__"
+ | "__lookupSetter__"
+ ) =>
+ {
+ Some(Constant::Undefined)
+ }
Constant::Array(values) => key
.parse::()
.ok()
@@ -1854,6 +1914,17 @@ impl<'a> Inline<'_, 'a> {
)),
Constant::Null => Some(Expression::new_null_literal(SPAN, builder)),
Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)),
+ Constant::Undefined => Some(Expression::new_unary_expression(
+ SPAN,
+ oxc_syntax::operator::UnaryOperator::Void,
+ Expression::new_numeric_literal(SPAN, 0.0, None, NumberBase::Decimal, builder),
+ builder,
+ )),
+ Constant::IgnoredObject if self.objects => Some(Expression::new_object_expression(
+ SPAN,
+ oxc_allocator::Vec::new_in(builder),
+ builder,
+ )),
Constant::Record(entries) if self.objects => {
let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder);
for (key, value) in entries.iter() {
@@ -1941,7 +2012,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> {
}
fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) {
- self.visit_expression(&mut call.callee);
+ if !matches!(self.constant(&call.callee), Some(Constant::Undefined)) {
+ self.visit_expression(&mut call.callee);
+ }
let objects = self.apis.reads(&call.callee);
let styles = is_style_root(self.style_roots, &call.callee);
self.reading_styles(styles, |inline| {
diff --git a/libs/extractor/src/imported_constants/exports.rs b/libs/extractor/src/imported_constants/exports.rs
new file mode 100644
index 000000000..12aa76779
--- /dev/null
+++ b/libs/extractor/src/imported_constants/exports.rs
@@ -0,0 +1,34 @@
+use super::{Constant, FxHashMap, Rc};
+
+pub(super) enum Exports {
+ Partial(Rc>),
+ Ignored,
+}
+
+impl Exports {
+ pub(super) fn named(&self, name: &str) -> Option {
+ match self {
+ Self::Partial(exports) => exports.get(name).cloned(),
+ Self::Ignored => Some(if name == "default" {
+ Constant::IgnoredObject
+ } else {
+ Constant::Undefined
+ }),
+ }
+ }
+
+ pub(super) fn namespace(self) -> Constant {
+ match self {
+ Self::Partial(exports) => Constant::Object(exports),
+ Self::Ignored => Constant::IgnoredObject,
+ }
+ }
+
+ pub(super) fn entries(&self) -> impl Iterator- {
+ let entries = match self {
+ Self::Partial(exports) => Some(exports),
+ Self::Ignored => None,
+ };
+ entries.into_iter().flat_map(|exports| exports.iter())
+ }
+}
diff --git a/libs/extractor/src/imported_source_type_tests.rs b/libs/extractor/src/imported_source_type_tests.rs
new file mode 100644
index 000000000..9aace6f85
--- /dev/null
+++ b/libs/extractor/src/imported_source_type_tests.rs
@@ -0,0 +1,18 @@
+use super::*;
+
+#[test]
+fn source_type_returns_no_constants_when_an_unvalidated_internal_path_is_unknown() {
+ let option = ExtractOption::default();
+ let mut modules = Modules {
+ resolver: None,
+ option: &option,
+ exports: FxHashMap::default(),
+ loading: Vec::new(),
+ };
+ assert_eq!(
+ modules
+ .read("token.custom", "export const PRIMARY = 'red';", None)
+ .len(),
+ 0
+ );
+}
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 96c6ca430..ee5ce1178 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -219,11 +219,25 @@ pub struct ExtractOutput {
pub struct ResolvedModule {
pub path: String,
pub code: String,
+ pub source_type: Option
,
}
+/// A native file resolution or a successful alias ignore without source.
+pub enum ModuleResolution {
+ Resolved(ResolvedModule),
+ Ignored,
+}
+
+mod source_type;
+pub use source_type::{ExtractSourceType, parse_source_type};
+#[cfg(test)]
+mod ignored_module_tests;
+#[cfg(test)]
+mod source_type_tests;
+
/// Resolves `(specifier, importer)` the way the bundler does; `None` when it
/// cannot
-pub type ModuleResolver = dyn Fn(&str, &str) -> Option;
+pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a;
#[derive(Clone)]
pub struct ExtractOption {
@@ -274,6 +288,26 @@ pub fn extract_with_modules(
extract_with_source_map(filename, code, option, source_map, Some(resolver))
}
+/// Compiled Markdown is JavaScript with optional JSX, never TypeScript.
+fn parser_source_type(filename: &str) -> Result {
+ if is_compiled_mdx(filename) {
+ return Ok(SourceType::jsx());
+ }
+ SourceType::from_path(filename).map_err(|error| {
+ format!("{filename}:1:1: source parser cannot use `{filename}` at build time: {error}")
+ })
+}
+
+/// Whether a compiler supplied JavaScript for a Markdown source filename.
+fn is_compiled_mdx(filename: &str) -> bool {
+ std::path::Path::new(filename)
+ .extension()
+ .and_then(|extension| extension.to_str())
+ .is_some_and(|extension| {
+ extension.eq_ignore_ascii_case("mdx") || extension.eq_ignore_ascii_case("md")
+ })
+}
+
fn extract_with_source_map(
filename: &str,
code: &str,
@@ -281,7 +315,47 @@ fn extract_with_source_map(
source_map: bool,
resolver: Option<&ModuleResolver>,
) -> Result> {
- extract_source(filename, code, None, option, source_map, resolver)
+ extract_with_source_type(filename, code, option, source_map, resolver, None)
+}
+
+/// Extract compiler output under its real filename with an explicit language.
+pub fn extract_with_source_type(
+ filename: &str,
+ code: &str,
+ option: ExtractOption,
+ source_map: bool,
+ resolver: Option<&ModuleResolver>,
+ source_type: Option,
+) -> Result> {
+ let fault = std::cell::RefCell::new(None);
+ let validated = |specifier: &str, importer: &str| {
+ if fault.borrow().is_some() {
+ return None;
+ }
+ let module = resolver?(specifier, importer)?;
+ let validation = match &module {
+ ModuleResolution::Resolved(module) => source_type::validate_module(module),
+ ModuleResolution::Ignored => Ok(()),
+ };
+ if let Err(error) = validation {
+ *fault.borrow_mut() = Some(error);
+ return None;
+ }
+ Some(module)
+ };
+ let output = extract_source(
+ filename,
+ code,
+ None,
+ option,
+ source_map,
+ resolver.map(|_| &validated as &ModuleResolver),
+ source_type,
+ );
+ match fault.into_inner() {
+ Some(error) => Err(error.into()),
+ None => output,
+ }
}
/// `evaluated` is the source `code` was computed from, with the layers of
@@ -293,7 +367,9 @@ fn extract_source(
option: ExtractOption,
source_map: bool,
resolver: Option<&ModuleResolver>,
+ source_mode: Option,
) -> Result> {
+ let source_type = source_type::parser_type(filename, source_mode)?;
// Step 1: Transform import aliases
// e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'`
// e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'`
@@ -302,13 +378,38 @@ fn extract_source(
filename,
&option.package,
&option.import_aliases,
+ source_type,
);
// Step 2: Check if code contains the target package (after transformation)
let has_relevant_import = transformed_code.contains(option.package.as_str())
|| transformed_code.contains(STYLEX_PACKAGE);
+ let compiled_mdx = source_mode.is_some() || is_compiled_mdx(filename);
if !has_relevant_import {
+ // Raw Markdown must not pass through merely because it has no styling import.
+ if compiled_mdx {
+ let allocator = Allocator::default();
+ let parsed = Parser::new(&allocator, code, source_type).parse();
+ if let Some(error) = parsed.diagnostics.errors().next() {
+ let offset = error
+ .labels
+ .first()
+ .map_or(0, oxc_span::LabeledSpan::offset);
+ return Err(located_errors(
+ filename,
+ code,
+ &[],
+ vec![(
+ offset,
+ format!(
+ "source parser cannot use raw or invalid MDX at build time: {error}"
+ ),
+ )],
+ )
+ .into());
+ }
+ }
// skip if not using package
return Ok(ExtractOutput {
styles: FxHashSet::default(),
@@ -326,7 +427,13 @@ fn extract_source(
// otherwise the untouched `transformed_code` is parsed directly (no copy).
let processed_code: Option = if utils::is_vanilla_extract_file(filename) {
// Use transformed code (with imports already pointing to @devup-ui/react)
- match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) {
+ match vanilla_extract::execute_stylesheet_with_type(
+ &transformed_code,
+ filename,
+ &option,
+ resolver,
+ source_mode,
+ ) {
Ok((collected, imports)) => {
dependencies = imports.dependencies;
// Keyframes names are generated, so extract the referenced ones
@@ -344,6 +451,7 @@ fn extract_source(
),
&option,
&referenced,
+ source_mode,
)?
};
let code = vanilla_extract::collected_styles_to_code_with_keyframes(
@@ -396,7 +504,6 @@ fn extract_source(
let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code);
- let source_type = SourceType::from_path(filename)?;
let (bucket, global, css_file) = resolve_css_target(filename, &option);
let import_main = option.import_main_css && !global;
// Presize to the exact final length (1 target + optional main-css entry) and
@@ -412,8 +519,25 @@ fn extract_source(
let ParserReturn {
mut program, // AST
fatal_error, // Parser encountered an error it couldn't recover from
+ diagnostics,
..
} = Parser::new(&allocator, code_to_parse, source_type).parse();
+ if compiled_mdx && let Some(error) = diagnostics.errors().next() {
+ let offset = error
+ .labels
+ .first()
+ .map_or(0, oxc_span::LabeledSpan::offset);
+ return Err(located_errors(
+ filename,
+ code,
+ &[alias_edits.as_slice()],
+ vec![(
+ offset,
+ format!("source parser cannot use raw or invalid MDX at build time: {error}"),
+ )],
+ )
+ .into());
+ }
if fatal_error {
return Err("Parser panicked".into());
}
@@ -456,6 +580,7 @@ fn extract_source(
&option,
resolver,
&inlined.unknown,
+ source_mode,
)
{
let mut output = extract_source(
@@ -465,6 +590,7 @@ fn extract_source(
option,
source_map,
resolver,
+ source_mode,
)?;
let mut files: std::collections::BTreeSet =
output.dependencies.into_iter().collect();
@@ -640,8 +766,9 @@ fn extract_class_map_from_code(
partial_code: &str,
option: &ExtractOption,
style_names: &FxHashSet,
+ source_mode: Option,
) -> Result, Box> {
- let source_type = SourceType::from_path(filename)?;
+ let source_type = source_type::parser_type(filename, source_mode)?;
let (bucket, global, css_file) = resolve_css_target(filename, option);
let css_files = vec![css_file];
let allocator = Allocator::default();
@@ -707,7 +834,7 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool {
return false;
}
- let source_type = match SourceType::from_path(filename) {
+ let source_type = match parser_source_type(filename) {
Ok(st) => st,
Err(_) => return false,
};
@@ -716,10 +843,11 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool {
let ParserReturn {
program,
fatal_error,
+ diagnostics,
..
} = Parser::new(&allocator, code, source_type).parse();
- if fatal_error {
+ if fatal_error || (is_compiled_mdx(filename) && diagnostics.has_errors()) {
return false;
}
@@ -749,6 +877,153 @@ mod tests {
use rstest::rstest;
use serial_test::serial;
+ #[rstest]
+ #[case("page.js")]
+ #[case("page.jsx")]
+ #[case("page.mjs")]
+ #[case("page.cjs")]
+ #[case("page.ts")]
+ #[case("page.tsx")]
+ #[case("page.mts")]
+ #[case("page.cts")]
+ fn mdx_source_type_keeps_other_extensions(#[case] filename: &str) {
+ assert_eq!(
+ parser_source_type(filename),
+ SourceType::from_path(filename).map_err(|error| error.to_string())
+ );
+ }
+
+ #[rstest]
+ #[case("page.mdx")]
+ #[case("page.md")]
+ #[serial]
+ fn mdx_compiler_jsx_calls_extract_with_real_filename(#[case] filename: &str) {
+ reset_class_map();
+ reset_file_map();
+ let source = "import {jsx as _jsx} from 'react/jsx-runtime';\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return _jsx(Box, {bg: 'tomato', children: 'hello'}); }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }";
+ let output = extract(filename, source, ExtractOption::default()).unwrap();
+ assert_ne!(output.styles.len(), 0);
+ assert!(output.code.contains("className"));
+ assert!(!output.code.contains("_jsx(Box"));
+ let map: serde_json::Value = serde_json::from_str(&output.map.unwrap()).unwrap();
+ assert_eq!(map["sources"], serde_json::json!([filename]));
+ }
+
+ #[rstest]
+ #[case("page.mdx")]
+ #[case("page.md")]
+ #[serial]
+ fn mdx_compiler_preserved_jsx_extracts(#[case] filename: &str) {
+ reset_class_map();
+ reset_file_map();
+ let source = "/*@jsxRuntime automatic*/\n/*@jsxImportSource react*/\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return hello ; }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }";
+ let output =
+ extract_without_source_map(filename, source, ExtractOption::default()).unwrap();
+ assert_ne!(output.styles.len(), 0);
+ assert!(output.code.contains("className"));
+ assert!(!output.code.contains(" ")]
+ #[case("page.md", "import {Box} from '@devup-ui/react';\n# Heading")]
+ #[case(
+ "page.mdx",
+ "import {Box} from '@devup-ui/react';\nconst color: string = 'red';\nconst view = ;"
+ )]
+ #[serial]
+ fn mdx_raw_or_typescript_input_fails_with_location(
+ #[case] filename: &str,
+ #[case] source: &str,
+ ) {
+ reset_class_map();
+ reset_file_map();
+ let error = extract(filename, source, ExtractOption::default())
+ .err()
+ .unwrap()
+ .to_string();
+ assert!(error.starts_with(&format!("{filename}:")), "{error}");
+ assert!(error.contains("at build time"), "{error}");
+ assert!(!has_devup_ui(filename, source, "@devup-ui/react"));
+ }
+
+ #[test]
+ #[serial]
+ fn mdx_compiled_without_devup_keeps_source() {
+ let source = "export default function MDXContent() { return Hello ; }";
+ let output = extract("page.mdx", source, ExtractOption::default()).unwrap();
+ assert_eq!(output.code, source);
+ assert_eq!(output.styles.len(), 0);
+ assert_eq!(output.css_file, None);
+ }
+
+ #[test]
+ #[serial]
+ fn mdx_relevant_import_gate_accepts_compiled_jsx() {
+ let source = "import {Box} from '@devup-ui/react'; export default () => ;";
+ assert!(has_devup_ui("page.mdx", source, "@devup-ui/react"));
+ }
+
+ #[test]
+ #[serial]
+ fn mdx_generated_class_map_uses_jsx_source_type() {
+ reset_class_map();
+ reset_file_map();
+ let names = FxHashSet::from_iter(["card".to_string()]);
+ let map = extract_class_map_from_code(
+ "page.mdx",
+ "import {css} from '@devup-ui/react'; export const card = css({bg: 'red'});",
+ &ExtractOption::default(),
+ &names,
+ None,
+ )
+ .unwrap();
+ assert!(map.contains_key("card"));
+ }
+
+ #[test]
+ #[serial]
+ fn mdx_unknown_extension_error_is_located() {
+ let source = "import {Box} from '@devup-ui/react'; const view = ;";
+ let error = extract("page.unknown", source, ExtractOption::default())
+ .err()
+ .unwrap()
+ .to_string();
+ assert!(error.starts_with("page.unknown:1:1:"), "{error}");
+ }
+
#[derive(Debug)]
#[allow(dead_code)]
struct ToBTreeSet {
@@ -4779,7 +5054,7 @@ import clsx from 'clsx'
)
.unwrap_err()
.to_string()
- .starts_with("Unknown file extension")
+ .starts_with("test.wrong:1:1: source parser cannot use `test.wrong` at build time: Unknown file extension")
);
reset_class_map();
@@ -13631,6 +13906,30 @@ globalCss({
assert!(!output.code.contains("css("), "{}", output.code);
}
+ #[test]
+ #[serial]
+ fn test_stylesheets_read_nothing_that_differs_between_builds() {
+ for (read, fails) in [
+ ("Math.random()", true),
+ ("Date.now()", true),
+ ("new Date()", true),
+ ("Date()", true),
+ ("new Date(0).getTime()", false),
+ ("Date.UTC(2020, 0, 1)", false),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let result = extract(
+ "when.css.ts",
+ &format!(
+ "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});"
+ ),
+ ExtractOption::default(),
+ );
+ assert_eq!(result.is_err(), fails, "{read}");
+ }
+ }
+
#[test]
#[serial]
fn test_vanilla_extract_execution_fallback() {
@@ -14355,6 +14654,7 @@ export const card = style({
import_aliases: HashMap::new(),
},
&style_names,
+ None,
)
.unwrap();
@@ -18082,7 +18382,7 @@ export const d = style([cond && base]);",
fn memory_resolver(
files: &'static [(&'static str, &'static str)],
- ) -> impl Fn(&str, &str) -> Option {
+ ) -> impl Fn(&str, &str) -> Option {
move |specifier, importer| {
let directory = importer
.rsplit_once('/')
@@ -18095,9 +18395,12 @@ export const d = style([cond && base]);",
.iter()
.any(|extension| format!("{path}{extension}") == *file)
})
- .map(|(file, code)| ResolvedModule {
- path: (*file).to_string(),
- code: (*code).to_string(),
+ .map(|(file, code)| {
+ ModuleResolution::Resolved(ResolvedModule {
+ source_type: None,
+ path: (*file).to_string(),
+ code: (*code).to_string(),
+ })
})
}
}
diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs
index 4082ff395..de274e408 100644
--- a/libs/extractor/src/module_loader.rs
+++ b/libs/extractor/src/module_loader.rs
@@ -14,13 +14,31 @@ use oxc_span::{GetSpan, SourceType};
use oxc_syntax::symbol::SymbolId;
use rustc_hash::{FxHashMap, FxHashSet};
-use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file};
+use crate::{ExtractOption, ModuleResolution, ModuleResolver, utils::is_vanilla_extract_file};
+#[cfg(test)]
+#[path = "ignored_loader_tests.rs"]
+mod ignored_loader_tests;
/// The object the package's API is bound to while a stylesheet runs
pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__";
/// A console whose calls do nothing, as what a module logs while it runs
/// changes no value
+/// Run before a stylesheet: what differs between builds fails the evaluation,
+/// so a stylesheet never gives different rules for the same source. `new
+/// Date(0)` and other fixed dates stay.
+pub(crate) const DETERMINISM: &str = r#"(() => {
+ const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); };
+ Math.random = refuse("Math.random");
+ const RealDate = Date;
+ globalThis.Date = new Proxy(RealDate, {
+ construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget),
+ apply: refuse("Date()"),
+ get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver),
+ });
+})();
+"#;
+
pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n";
/// Loop iterations an evaluation may run before it fails instead of hanging
@@ -72,11 +90,14 @@ fn is_evaluating(filename: &str) -> bool {
/// Loads what a stylesheet imports, each module once, as JavaScript that
/// defines the module's exports object ahead of the stylesheet
pub(crate) struct ModuleLoader<'r> {
- resolver: Option<&'r ModuleResolver>,
+ resolver: Option<&'r ModuleResolver<'r>>,
option: &'r ExtractOption,
/// Definitions of the loaded modules, each after the ones it uses
definitions: Vec,
- loaded: FxHashMap,
+ loaded: FxHashMap,
+ ignored: FxHashMap<(String, String), String>,
+ ignored_bindings: FxHashSet,
+ helper_emitted: bool,
/// `(path, name)` of the modules being defined, outermost first
loading: Vec<(String, String)>,
/// Names of modules imported before they finished evaluating, whose
@@ -97,6 +118,9 @@ impl<'r> ModuleLoader<'r> {
option,
definitions: Vec::new(),
loaded: FxHashMap::default(),
+ ignored: FxHashMap::default(),
+ ignored_bindings: FxHashSet::default(),
+ helper_emitted: false,
loading: Vec::new(),
pending: FxHashSet::default(),
next_module: 0,
@@ -121,13 +145,31 @@ impl<'r> ModuleLoader<'r> {
let resolver = self
.resolver
.ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?;
- let module = resolver(specifier, importer)
- .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?;
+ let module = match resolver(specifier, importer)
+ .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?
+ {
+ ModuleResolution::Resolved(module) => module,
+ ModuleResolution::Ignored => {
+ let key = (importer.to_string(), specifier.to_string());
+ if let Some(name) = self.ignored.get(&key) {
+ return Ok(name.clone());
+ }
+ let name = format!("__module_{}__", self.next_module);
+ self.next_module += 1;
+ self.definitions.push(format!(
+ "const {name}$ = {{}};\nconst {name} = {{ default: {name}$, __exports__: {name}$ }};\n"
+ ));
+ self.ignored.insert(key, name.clone());
+ self.ignored_bindings.insert(name.clone());
+ return Ok(name);
+ }
+ };
+ let key = (module.path.clone(), module.code.clone(), module.source_type);
let stylesheet = is_vanilla_extract_file(&module.path);
if direct && stylesheet {
self.keep_import(specifier);
}
- if let Some(name) = self.loaded.get(&module.path) {
+ if let Some(name) = self.loaded.get(&key) {
return Ok(name.clone());
}
if let Some((_, name)) = self.loading.iter().find(|(path, _)| *path == module.path) {
@@ -139,8 +181,9 @@ impl<'r> ModuleLoader<'r> {
self.next_module += 1;
// Created before the modules it imports, so a cycle among them can
// reach it; reading it before it starts evaluating is an error
- if self.definitions.is_empty() {
+ if !self.helper_emitted {
self.definitions.push(MODULE_HELPER.to_string());
+ self.helper_emitted = true;
}
self.definitions.push(format!(
"const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n",
@@ -150,15 +193,15 @@ impl<'r> ModuleLoader<'r> {
// The stylesheet importing it is evaluated on its own, so it never
// starts here
self.pending.insert(name.clone());
- self.loaded.insert(module.path, name.clone());
+ self.loaded.insert(key, name.clone());
return Ok(name);
}
self.dependencies.insert(module.path.clone());
self.loading.push((module.path.clone(), name.clone()));
let defined = self.define(&name, module, stylesheet, resolver);
- let (path, _) = self.loading.pop().unwrap_or_default();
+ self.loading.pop();
defined?;
- self.loaded.insert(path, name.clone());
+ self.loaded.insert(key, name.clone());
Ok(name)
}
@@ -172,12 +215,13 @@ impl<'r> ModuleLoader<'r> {
let code = if stylesheet {
// Extracted the way the bundler extracts it, so the names it
// exports are the ones its own CSS uses
- let output = crate::extract_with_source_map(
+ let output = crate::extract_with_source_type(
&module.path,
&module.code,
self.option.clone(),
false,
Some(resolver),
+ module.source_type,
)
.map_err(|error| error.to_string())?;
self.dependencies.extend(output.dependencies);
@@ -185,7 +229,11 @@ impl<'r> ModuleLoader<'r> {
} else {
module.code
};
- let script = crate::vanilla_extract::strip_typescript(&code, &module.path);
+ let script = crate::vanilla_extract::strip_typescript_with_type(
+ &code,
+ &module.path,
+ module.source_type,
+ )?;
let module_script = module_script(&script, &module.path, self, false)?;
// Live bindings: a read before the binding is initialized fails as it
// does in an ES module
@@ -222,6 +270,28 @@ impl<'r> ModuleLoader<'r> {
}
}
+impl ModuleLoader<'_> {
+ fn named(&self, module: &str, name: &str) -> String {
+ if name != "default" && self.ignored_bindings.contains(module) {
+ return "void 0".to_string();
+ }
+ let object = if name == "default" {
+ module.to_string()
+ } else {
+ self.namespace(module)
+ };
+ format!("{object}[{name:?}]")
+ }
+
+ fn namespace(&self, module: &str) -> String {
+ if self.ignored_bindings.contains(module) {
+ format!("{module}.__exports__")
+ } else {
+ module.to_string()
+ }
+ }
+}
+
/// A module turned into a script: imports bound to the loaded modules, export
/// keywords dropped
pub(crate) struct ModuleScript {
@@ -368,14 +438,32 @@ pub(crate) fn module_script(
let mut named = Vec::new();
for specifier in import.specifiers.iter().flatten() {
match specifier {
- ImportDeclarationSpecifier::ImportSpecifier(specifier) => named.push(
- format!("{:?}: {}", specifier.imported.name(), specifier.local.name),
- ),
+ ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
+ if loader.ignored_bindings.contains(module) {
+ let _ = writeln!(
+ body,
+ "const {} = {};",
+ specifier.local.name,
+ loader.named(module, specifier.imported.name().as_str())
+ );
+ } else {
+ named.push(format!(
+ "{:?}: {}",
+ specifier.imported.name(),
+ specifier.local.name
+ ));
+ }
+ }
ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => {
named.push(format!("\"default\": {}", specifier.local.name));
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
- let _ = writeln!(body, "const {} = {module};", specifier.local.name);
+ let _ = writeln!(
+ body,
+ "const {} = {};",
+ specifier.local.name,
+ loader.namespace(module)
+ );
}
}
}
@@ -404,7 +492,7 @@ pub(crate) fn module_script(
for specifier in &export.specifiers {
exports.push((
export_name(&specifier.exported),
- format!("{module}[{:?}]", export_name(&specifier.local)),
+ loader.named(&module, &export_name(&specifier.local)),
));
}
}
@@ -430,8 +518,10 @@ pub(crate) fn module_script(
Statement::ExportAllDeclaration(export) => {
let module = loader.load(export.source.value.as_str(), filename, entry)?;
match &export.exported {
- Some(exported) => exports.push((export_name(exported), module)),
- None => spreads.push(module),
+ Some(exported) => {
+ exports.push((export_name(exported), loader.namespace(&module)));
+ }
+ None => spreads.push(loader.namespace(&module)),
}
}
statement => {
diff --git a/libs/extractor/src/source_type.rs b/libs/extractor/src/source_type.rs
new file mode 100644
index 000000000..fb1e90d28
--- /dev/null
+++ b/libs/extractor/src/source_type.rs
@@ -0,0 +1,132 @@
+use oxc_span::SourceType;
+
+/// The language of compiler output, independent of its original filename.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum ExtractSourceType {
+ /// JavaScript with optional JSX, never TypeScript or raw Markdown.
+ CompiledMdx,
+}
+
+pub(crate) type ModuleCacheKey = (String, String, Option);
+pub(crate) type PreparedFileKey = (String, Option);
+
+/// Parse a supplied source-type literal without accepting unknown modes.
+pub fn parse_source_type(value: Option<&str>) -> Result, String> {
+ match value {
+ None => Ok(None),
+ Some("compiled-mdx") => Ok(Some(ExtractSourceType::CompiledMdx)),
+ Some(value) => Err(format!(
+ "source type cannot use `{value}` at build time: expected `compiled-mdx`"
+ )),
+ }
+}
+
+pub(crate) fn parser_type(
+ filename: &str,
+ mode: Option,
+) -> Result {
+ match mode {
+ Some(ExtractSourceType::CompiledMdx) => Ok(SourceType::jsx()),
+ None => crate::parser_source_type(filename),
+ }
+}
+
+pub(crate) fn validate_module(module: &crate::ResolvedModule) -> Result<(), String> {
+ let source_type = parser_type(&module.path, module.source_type)?;
+ if module.source_type.is_some() || crate::is_compiled_mdx(&module.path) {
+ let allocator = oxc_allocator::Allocator::default();
+ let parsed = oxc_parser::Parser::new(&allocator, &module.code, source_type).parse();
+ if let Some(error) = parsed.diagnostics.errors().next() {
+ let offset = error
+ .labels
+ .first()
+ .map_or(0, oxc_span::LabeledSpan::offset);
+ return Err(crate::located_errors(
+ &module.path,
+ &module.code,
+ &[],
+ vec![(
+ offset,
+ format!("source parser cannot use raw or invalid MDX at build time: {error}"),
+ )],
+ ));
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_modes_when_optional_literal_is_supplied() {
+ assert_eq!(parse_source_type(None), Ok(None));
+ assert_eq!(
+ parse_source_type(Some("compiled-mdx")),
+ Ok(Some(ExtractSourceType::CompiledMdx))
+ );
+ for value in ["", "tsx", "invalid"] {
+ assert!(
+ parse_source_type(Some(value))
+ .is_err_and(|error| error.contains("expected `compiled-mdx`"))
+ );
+ }
+ }
+
+ #[test]
+ fn overrides_language_when_real_extension_is_custom_or_typescript() {
+ for filename in ["page.mdown", "page.ts"] {
+ assert_eq!(
+ parser_type(filename, Some(ExtractSourceType::CompiledMdx)),
+ Ok(SourceType::jsx())
+ );
+ }
+ assert_eq!(
+ parser_type("page.ts", None),
+ SourceType::from_path("page.ts").map_err(|error| error.to_string())
+ );
+ assert!(
+ parser_type("page.mdown", None)
+ .is_err_and(|error| error.starts_with("page.mdown:1:1:"))
+ );
+ }
+
+ #[test]
+ fn retains_exports_when_strip_cache_changes_language() -> Result<(), String> {
+ let code = "export function view() { return
; } export const PRIMARY = 'red';";
+ let filename = "mode-cache.ts";
+ let ordinary = crate::vanilla_extract::strip_typescript_with_type(code, filename, None)?;
+ let compiled = crate::vanilla_extract::strip_typescript_with_type(
+ code,
+ filename,
+ Some(ExtractSourceType::CompiledMdx),
+ )?;
+ assert!(compiled.contains("PRIMARY"));
+ assert_ne!(compiled, ordinary);
+ assert_eq!(
+ crate::vanilla_extract::strip_typescript_with_type(
+ code,
+ filename,
+ Some(ExtractSourceType::CompiledMdx)
+ )?,
+ compiled
+ );
+ assert!(
+ crate::vanilla_extract::strip_typescript_with_type(code, "bad.custom", None).is_err()
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_markdown_when_resolved_module_claims_compiled_output() {
+ let module = crate::ResolvedModule {
+ path: "page.mdown".to_string(),
+ code: "# Heading".to_string(),
+ source_type: Some(ExtractSourceType::CompiledMdx),
+ };
+ assert!(validate_module(&module).is_err_and(
+ |error| error.starts_with("page.mdown:1:") && error.contains("raw or invalid MDX")
+ ));
+ }
+}
diff --git a/libs/extractor/src/source_type_tests.rs b/libs/extractor/src/source_type_tests.rs
new file mode 100644
index 000000000..5624b9ba3
--- /dev/null
+++ b/libs/extractor/src/source_type_tests.rs
@@ -0,0 +1,142 @@
+use super::*;
+use serial_test::serial;
+
+#[test]
+#[serial]
+fn extracts_real_filename_when_compiled_mode_is_supplied() -> Result<(), Box> {
+ let code = "import {Box} from '@devup-ui/react'; export const view = ;";
+ let output = extract_with_source_type(
+ "page.mdown",
+ code,
+ ExtractOption::default(),
+ true,
+ None,
+ Some(ExtractSourceType::CompiledMdx),
+ )?;
+ assert!(output.map.is_some_and(|map| map.contains("page.mdown")));
+ assert!(!output.code.contains(" Result<(), Box> {
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "token.ts".to_string(),
+ code: "export enum Colors { PRIMARY = 'red' }".to_string(),
+ source_type: None,
+ }))
+ };
+ let output = extract_with_source_type(
+ "page.mdown",
+ "import {Box} from '@devup-ui/react'; import {Colors} from './token'; export const view = ;",
+ ExtractOption::default(),
+ false,
+ Some(&resolver),
+ Some(ExtractSourceType::CompiledMdx),
+ )?;
+ assert!(!output.code.contains("--"));
+ assert_eq!(output.dependencies, ["token.ts"]);
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn rejects_child_extension_when_constants_would_fall_back() {
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "token.custom".to_string(),
+ code: "export const PRIMARY = 'red';".to_string(),
+ source_type: None,
+ }))
+ };
+ let result = extract_with_source_type(
+ "page.tsx",
+ "import {Box} from '@devup-ui/react'; import {PRIMARY} from './token'; import {OTHER} from './other'; export const view = ;",
+ ExtractOption::default(),
+ false,
+ Some(&resolver),
+ None,
+ );
+ assert!(result.is_err_and(|error| error.to_string().starts_with("token.custom:1:1:")));
+}
+
+#[test]
+#[serial]
+fn evaluates_stylesheet_when_imported_compiled_jsx_has_trailing_constants()
+-> Result<(), Box> {
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "token.mdown".to_string(),
+ code: "export function view() { return
; } export const PRIMARY = 'blue';"
+ .to_string(),
+ source_type: Some(ExtractSourceType::CompiledMdx),
+ }))
+ };
+ let output = extract_with_source_type(
+ "theme.css.ts",
+ "import {css} from '@devup-ui/react'; import {PRIMARY} from './token'; export const cls = css({color: PRIMARY});",
+ ExtractOption::default(),
+ false,
+ Some(&resolver),
+ None,
+ )?;
+ assert!(!output.code.contains("--"));
+ assert_eq!(output.dependencies, ["token.mdown"]);
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn evaluates_generated_values_when_root_keeps_custom_filename() -> Result<(), Box> {
+ let output = extract_with_source_type(
+ "page.mdown",
+ "import {css} from '@devup-ui/react'; const twice = (x) => x * 2; export const cls = css({width: twice(10)});",
+ ExtractOption::default(),
+ false,
+ None,
+ Some(ExtractSourceType::CompiledMdx),
+ )?;
+ assert!(!output.code.contains("css("));
+ assert!(!output.code.contains("--"));
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn reads_commonjs_exports_when_compiled_jsx_precedes_assignment() -> Result<(), Box> {
+ let resolver = |_: &str, _: &str| {
+ Some(ModuleResolution::Resolved(ResolvedModule {
+ path: "tokens.mdown".to_string(),
+ code: "function view() { return
; } exports.PRIMARY = 'blue';".to_string(),
+ source_type: Some(ExtractSourceType::CompiledMdx),
+ }))
+ };
+ let output = extract_with_source_type(
+ "page.tsx",
+ "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;",
+ ExtractOption::default(),
+ false,
+ Some(&resolver),
+ None,
+ )?;
+ assert!(!output.code.contains("--"));
+ assert_eq!(output.dependencies, ["tokens.mdown"]);
+ Ok(())
+}
+
+#[test]
+#[serial]
+fn generates_class_names_when_explicit_mode_keeps_custom_filename() -> Result<(), Box> {
+ let names = FxHashSet::from_iter(["card".to_string()]);
+ let classes = extract_class_map_from_code(
+ "page.mdown",
+ "import {css} from '@devup-ui/react'; export const card = css({color:'blue'});",
+ &ExtractOption::default(),
+ &names,
+ Some(ExtractSourceType::CompiledMdx),
+ )?;
+ assert!(classes.contains_key("card"));
+ Ok(())
+}
diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs
index 9e736b09a..b4cb7ab4a 100644
--- a/libs/extractor/src/vanilla_extract.rs
+++ b/libs/extractor/src/vanilla_extract.rs
@@ -233,16 +233,29 @@ fn execute_vanilla_extract(
/// Execute vanilla-extract style file and collect styles, loading what it
/// imports through `resolver`
+#[cfg(test)]
pub fn execute_stylesheet(
code: &str,
filename: &str,
option: &crate::ExtractOption,
resolver: Option<&crate::ModuleResolver>,
) -> Result<(CollectedStyles, StylesheetImports), String> {
+ execute_stylesheet_with_type(code, filename, option, resolver, None)
+}
+
+pub(crate) fn execute_stylesheet_with_type(
+ code: &str,
+ filename: &str,
+ option: &crate::ExtractOption,
+ resolver: Option<&crate::ModuleResolver>,
+ mode: Option,
+) -> Result<(CollectedStyles, StylesheetImports), String> {
+ let source_type = crate::source_type::parser_type(filename, mode)?;
+ let cache_key = (filename.to_string(), mode);
let _evaluating = Evaluating::enter(filename);
let mut loader = ModuleLoader::new(resolver, option);
let script = module_script(
- &strip_typescript(code, filename),
+ &strip_typescript_with_type(code, filename, mode)?,
filename,
&mut loader,
true,
@@ -256,7 +269,7 @@ pub fn execute_stylesheet(
let imported = crate::module_loader::evaluating_import();
if imported
&& let Some(collected) = IMPORTED_RUNS.with_borrow(|runs| {
- runs.get(filename)
+ runs.get(&cache_key)
.filter(|(num, source, text, _)| *num == file_num && source == code && *text == run)
.map(|(.., collected)| collected.clone())
})
@@ -276,6 +289,9 @@ pub fn execute_stylesheet(
context
.eval(Source::from_bytes(crate::module_loader::CONSOLE))
.map_err(|e| format!("JS execution error: {e}"))?;
+ context
+ .eval(Source::from_bytes(crate::module_loader::DETERMINISM))
+ .map_err(|e| format!("JS execution error: {e}"))?;
context
.eval(Source::from_bytes(run.as_bytes()))
@@ -284,14 +300,14 @@ pub fn execute_stylesheet(
let mut collected = std::mem::take(&mut collector.borrow_mut().styles);
name_entries(
&mut collected,
- &top_level_bindings(code),
+ &top_level_bindings_with_type(code, source_type),
&mut context,
file_num,
);
if imported {
IMPORTED_RUNS.with_borrow_mut(|runs| {
runs.insert(
- filename.to_string(),
+ cache_key,
(file_num, code.to_string(), run, collected.clone()),
);
});
@@ -299,14 +315,18 @@ pub fn execute_stylesheet(
Ok((collected, imports))
}
+type ImportedRuns =
+ FxHashMap;
+type StrippedSources = FxHashMap;
+
thread_local! {
/// What running each stylesheet other evaluations import last collected,
/// with its file number, source and the script run: every module importing
/// it reuses one run of the same script
- static IMPORTED_RUNS: RefCell> =
+ static IMPORTED_RUNS: RefCell =
RefCell::default();
/// The TypeScript each file last had stripped, with its source
- static STRIPPED: RefCell> = RefCell::default();
+ static STRIPPED: RefCell = RefCell::default();
}
/// A name a top-level variable declaration of the stylesheet binds
@@ -317,11 +337,9 @@ struct Binding {
init: Option,
}
-fn top_level_bindings(code: &str) -> Vec {
+fn top_level_bindings_with_type(code: &str, source_type: SourceType) -> Vec {
let allocator = Allocator::default();
- let program = Parser::new(&allocator, code, SourceType::ts())
- .parse()
- .program;
+ let program = Parser::new(&allocator, code, source_type).parse().program;
let mut bindings = Vec::new();
for statement in &program.body {
let (declaration, exported) = match statement {
@@ -557,25 +575,35 @@ fn replace_placeholders(
/// Convert TypeScript to JavaScript using Oxc Transformer
/// `code` without its TypeScript, stripped once for as long as the file holds
/// it: the modules evaluations import are stripped once, not per evaluation
+#[cfg(test)]
pub(crate) fn strip_typescript(code: &str, filename: &str) -> String {
+ strip_typescript_with_type(code, filename, None).unwrap_or_default()
+}
+
+pub(crate) fn strip_typescript_with_type(
+ code: &str,
+ filename: &str,
+ mode: Option,
+) -> Result {
+ let source_type = crate::source_type::parser_type(filename, mode)?;
+ let key = (filename.to_string(), mode);
if let Some(stripped) = STRIPPED.with_borrow(|stripped| {
stripped
- .get(filename)
+ .get(&key)
.filter(|(source, _)| source == code)
.map(|(_, stripped)| stripped.clone())
}) {
- return stripped;
+ return Ok(stripped);
}
- let stripped = strip(code, filename);
+ let stripped = strip(code, source_type);
STRIPPED.with_borrow_mut(|entries| {
- entries.insert(filename.to_string(), (code.to_string(), stripped.clone()));
+ entries.insert(key, (code.to_string(), stripped.clone()));
});
- stripped
+ Ok(stripped)
}
-fn strip(code: &str, filename: &str) -> String {
+fn strip(code: &str, source_type: SourceType) -> String {
let allocator = Allocator::default();
- let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts());
let mut program = Parser::new(&allocator, code, source_type).parse().program;
let scoping = SemanticBuilder::new()
.with_enum_eval(true)
@@ -1470,9 +1498,12 @@ mod tests {
path.trim_end_matches(".ts")
== specifier.trim_start_matches('.').trim_end_matches(".ts")
})
- .map(|(path, code)| crate::ResolvedModule {
- path: (*path).to_string(),
- code: (*code).to_string(),
+ .map(|(path, code)| {
+ crate::ModuleResolution::Resolved(crate::ResolvedModule {
+ source_type: None,
+ path: (*path).to_string(),
+ code: (*code).to_string(),
+ })
})
};
execute_stylesheet(
@@ -1514,11 +1545,11 @@ mod tests {
cycle_error(
"import { b } from './b'\nexport const x = b",
&[
- ("/b", "import { c } from './c'\nexport const b = c"),
- ("/c", "import { b } from './b'\nexport const c = b"),
+ ("/b.ts", "import { c } from './c'\nexport const b = c"),
+ ("/c.ts", "import { b } from './b'\nexport const c = b"),
]
)
- .contains("Cannot access 'b' of '/b'")
+ .contains("Cannot access 'b' of '/b.ts'")
);
assert!(
cycle_error(
@@ -1539,11 +1570,11 @@ mod tests {
"import { style } from '@devup-ui/react'\nimport { both, reexported } from './b'\nimport { d, withA } from './d.css'\nexport const x = style([d, { color: both, background: reexported }])\nexport const y = typeof withA",
&[
(
- "/b",
+ "/b.ts",
"import { c, lazy } from './c'\nexport const b = 'blue'\nexport const both = c()\nexport { lazy as reexported }"
),
(
- "/c",
+ "/c.ts",
"import bDefault, { b } from './b'\nimport * as all from './b'\nexport const c = () => b + all.b.length\nexport const lazyDefault = () => bDefault\nexport const lazy = 'red'\nexport { b as again }"
),
("/a.css.ts", ""),
diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs
index 9a989df8f..fb99cef61 100644
--- a/libs/sheet/src/lib.rs
+++ b/libs/sheet/src/lib.rs
@@ -502,7 +502,11 @@ impl StyleSheet {
Some(filename)
};
let bucket_scope = if single_css { None } else { Some(filename) };
- for style in styles {
+ // Names are handed out in the order styles are first seen, so the
+ // set is walked in a fixed order, not the hash order.
+ let mut ordered: Vec<&ExtractStyleValue> = styles.iter().collect();
+ ordered.sort_unstable();
+ for style in ordered {
match style {
// A conditional `typography` preset: its class is the atom, and the
// preset's declarations are emitted under the atom's selector.
@@ -2909,6 +2913,44 @@ mod tests {
assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap());
}
+ #[test]
+ #[serial]
+ fn test_class_names_do_not_depend_on_the_order_styles_are_inserted() {
+ use extractor::extract_style::extract_static_style::ExtractStaticStyle;
+ use extractor::extract_style::extract_style_value::ExtractStyleValue;
+
+ let values: Vec = (0..40)
+ .map(|index| {
+ ExtractStyleValue::Static(ExtractStaticStyle::new(
+ ["color", "margin", "padding", "width"][index % 4],
+ &format!("{index}px"),
+ 0,
+ None,
+ ))
+ })
+ .collect();
+ let mut outputs = Vec::new();
+ for reverse in [false, true] {
+ css::class_map::reset_class_map();
+ let mut styles = FxHashSet::default();
+ if reverse {
+ values.iter().rev().for_each(|value| {
+ styles.insert(value.clone());
+ });
+ } else {
+ values.iter().for_each(|value| {
+ styles.insert(value.clone());
+ });
+ }
+ let mut sheet = StyleSheet::default();
+ sheet.update_styles(&styles, "index.tsx", true);
+ outputs.push((
+ sheet.create_css(None, true),
+ css::class_map::get_class_map(),
+ ));
+ }
+ assert_eq!(outputs[0], outputs[1]);
+ }
#[test]
#[serial]
fn test_update_styles_with_typography() {
diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md
index c23855214..8f4064ae6 100644
--- a/packages/bun-plugin/README.md
+++ b/packages/bun-plugin/README.md
@@ -40,9 +40,91 @@ Add the zero-config entry to Bun's preload list:
preload = ["@devup-ui/bun-plugin"]
```
-## Custom Shorthands
+## Bundling with `Bun.build`
+
+```ts
+import { DevupUI } from '@devup-ui/bun-plugin/register'
+
+await Bun.build({
+ entrypoints: ['./src/index.tsx'],
+ outdir: './dist',
+ plugins: [DevupUI()],
+})
+```
+
+The build emits the stylesheet as a CSS output holding the styles of every
+module in the bundle. Imports of the packages Devup UI takes the place of
+(`@emotion/react`, `@emotion/styled`, `styled-components`,
+`@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too.
+
+Class names are short by default in both `Bun.build` and the runtime. Pass
+`debug: true` for readable names (including snapshot tests).
+
+The plugin transforms loaded `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`,
+`.mjs`, and `.cjs` modules, including libraries that import Devup UI, its
+compatibility aliases, or StyleX. Unlike the other bundler plugins, Bun does
+not require an `include` list when bundling loaded uncompiled libraries.
+Under the runtime, use `include: ['your-library']` for uncompiled dependencies;
+`@devup-ui/components` is included automatically. Runtime candidates are
+discovered at setup so ordinary token/CommonJS modules retain Bun's native
+watch/loading behavior; restart Bun after adding a new styling module or
+changing `include`. Precompiled libraries must still publish their extracted
+CSS for consumers to import.
+
+`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such
+as `.mdown` are opt-in. Devup compiles every selected file asynchronously using
+the project's own `@mdx-js/mdx`, with explicit MDX format, before extraction.
+Install that compiler even for plain selected MDX: missing compilation is a
+located installation error, not a silent handoff to another plugin. The same
+list controls loading, graph discovery and numbering. Compiled JavaScript/JSX
+keeps its real filename. Extractor errors use the compiler's source map when
+available, otherwise their locations are labeled `in compiled MDX`.
+
+`root` defaults to `Bun.build`'s root, or the runtime's working directory.
+Relative `devupFile` (default `devup.json`), `distDir` (default `df`) and
+`sourceDirs` resolve against that root. Default numbering roots are the existing
+`src` and `app` directories; use `sourceDirs` for other layouts. Malformed or
+cyclic theme configuration stops initialization with a file-located error.
+Optional file-number seeding failures emit one warning including the cause.
-To configure custom shorthands, preload a local module instead of the
+Build-time imported modules remain explicit side-effect imports in Bun's graph
+so runtime watch mode can reload their importers. `Bun.build` takes CSS only
+after extraction completes. Runtime imports synchronously publish changed CSS
+to disk; identical revisions skip writes, without delaying read-after-import.
+
+## MDX Ownership And Public API Limits
+
+In `Bun.build`, selected MDX entries and selected files reached by literal
+imports in modules Devup processes must go through Devup's own loader. A file
+claimed first by another plugin is a located ownership error naming its target
+and extension. Let Devup compile that extension, or remove it from
+`mdxExtensions`. CSS finalization checks after `defer()`; an independent
+`onEnd` check covers builds without stylesheet imports. Watch rebuilds that
+execute these build callbacks receive the same check.
+
+Bun does not expose loader chaining or another plugin's final module/resolve
+target. A module loaded by another plugin under an extension Devup does not own
+is invisible, including definitely used Devup exports. Such placeholders may
+throw `Cannot run on the runtime` when rendered. Add the extension to
+`mdxExtensions` and let Devup's project compiler load it instead of a separate
+MDX plugin. Devup does not reject an indistinguishable harmless module or claim
+to inspect another loader's final code.
+
+Whole namespace/require-result uses and dynamic imports do not prove which
+export is used. They remain opaque rather than being conservatively rejected.
+Runtime helpers and unused imports are not compile-time-use violations.
+
+The whole-build ownership guarantee does not apply to `Bun.plugin` runtime
+registration: that API has no build `onEnd`/`defer` finalization point. Runtime
+loading still compiles files Devup owns. An `onEnd` rejection can occur after
+output files are written, even with `throw: false`; publish build artifacts only
+after successful completion. Ownership diagnostics use the real importer
+`:1:1` as an honest file-level fallback because this callback has no import
+span; a structured CSS-load diagnostic may identify the virtual stylesheet.
+
+## Custom Shorthands
+
+To configure custom shorthands, preload a local module instead of the
zero-config entry:
```toml
diff --git a/packages/bun-plugin/__regression__/conditional.bun.ts b/packages/bun-plugin/__regression__/conditional.bun.ts
new file mode 100644
index 000000000..77e135f45
--- /dev/null
+++ b/packages/bun-plugin/__regression__/conditional.bun.ts
@@ -0,0 +1,186 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll(
+ '\\',
+ '/',
+)
+const wasm = resolve(
+ import.meta.dir,
+ '../../../bindings/devup-ui-wasm/pkg/index.js',
+).replaceAll('\\', '/')
+const scratch = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'bun-conditions',
+)
+mkdirSync(scratch, { recursive: true })
+
+function run(script: string) {
+ const root = realpathSync.native(mkdtempSync(join(scratch, 'conditional-')))
+ try {
+ // Given: declaration order makes accidentally enabling require observable.
+ const branches = {
+ require: '811px',
+ custom: '821px',
+ browser: '823px',
+ bun: '827px',
+ node: '829px',
+ import: '839px',
+ module: '853px',
+ default: '857px',
+ }
+ const exports = Object.fromEntries(
+ Object.keys(branches).map((name) => [name, `./${name}.js`]),
+ )
+ const files = {
+ 'bunfig.toml': '',
+ 'package.json': '{"type":"module"}',
+ 'wrong.js': "export const width = '863px'",
+ 'node_modules/conditional/package.json': JSON.stringify({
+ type: 'module',
+ exports: {
+ '.': exports,
+ './node-first': {
+ node: './node.js',
+ bun: './bun.js',
+ default: './default.js',
+ },
+ './import-only': {
+ require: './require.js',
+ import: './import.js',
+ default: './default.js',
+ },
+ './module-first': {
+ module: './module.js',
+ import: './import.js',
+ default: './default.js',
+ },
+ './fallback': { default: './default.js' },
+ },
+ }),
+ ...Object.fromEntries(
+ Object.entries(branches).map(([name, width]) => [
+ `node_modules/conditional/${name}.js`,
+ `export const width = '${width}'`,
+ ]),
+ ),
+ }
+ for (const [name, contents] of Object.entries(files)) {
+ mkdirSync(resolve(root, name, '..'), { recursive: true })
+ writeFileSync(join(root, name), contents)
+ }
+ writeFileSync(
+ join(root, 'check.ts'),
+ `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)}; import { setModuleResolver } from ${JSON.stringify(wasm)};\n${script}`,
+ )
+ const result = Bun.spawnSync([process.execPath, 'check.ts'], {
+ cwd: root,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ expect({
+ exitCode: result.exitCode,
+ output: result.stdout.toString() + result.stderr.toString(),
+ }).toEqual({ exitCode: 0, output: '' })
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+}
+
+const builds = [
+ { config: {}, specifier: 'conditional', width: '823px' },
+ { config: { target: 'browser' }, specifier: 'conditional', width: '823px' },
+ { config: { target: 'bun' }, specifier: 'conditional', width: '827px' },
+ { config: { target: 'node' }, specifier: 'conditional', width: '829px' },
+ {
+ config: { conditions: ['custom'] },
+ specifier: 'conditional',
+ width: '821px',
+ },
+ {
+ config: { target: 'node', conditions: 'custom' },
+ specifier: 'conditional',
+ width: '821px',
+ },
+ {
+ config: { target: 'bun' },
+ specifier: 'conditional/node-first',
+ width: '829px',
+ },
+ { config: {}, specifier: 'conditional/import-only', width: '839px' },
+ { config: {}, specifier: 'conditional/module-first', width: '853px' },
+ {
+ config: { conditions: [] },
+ specifier: 'conditional/module-first',
+ width: '853px',
+ },
+ {
+ config: { conditions: ['custom'] },
+ specifier: 'conditional/module-first',
+ width: '853px',
+ },
+ { config: {}, specifier: 'conditional/fallback', width: '857px' },
+] as const
+
+it.each([...builds])(
+ 'matches real Bun.build export selection for $specifier with $config',
+ ({ config, specifier, width }) => {
+ run(`
+ const config = ${JSON.stringify(config)};
+ await Bun.write('oracle.ts', "export { width } from '${specifier}'");
+ const oracle = await Bun.build({ ...config, entrypoints: ['./oracle.ts'] });
+ expect(oracle.success, String(oracle.logs)).toBe(true);
+ const js = oracle.outputs.find(output => output.path.endsWith('.js'));
+ const actual = await import('data:text/javascript;base64,' + Buffer.from(await js.text()).toString('base64'));
+ expect(actual.width).toBe('${width}');
+ // Vanilla Extract evaluates imports through its CommonJS evaluator, but
+ // these requests originated as ESM imports, not source require() calls.
+ await Bun.write('styled.ts', "import { style } from '@vanilla-extract/css'; import { width } from '${specifier}'; export const cls = style({ width });");
+ // When: a later engine user replaces the shared resolver after setup.
+ const result = await Bun.build({ ...config, entrypoints: ['./styled.ts'], plugins: [DevupUI(), { name: 'other-engine', setup() { setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" })); } }] });
+ expect(result.success, String(result.logs)).toBe(true);
+ // Then: extracted compile-time CSS agrees with the actual bundler branch.
+ const css = result.outputs.find(output => output.path.endsWith('.css'));
+ expect(await css.text()).toContain(actual.width);
+ expect(await css.text()).not.toContain('863px');
+ `)
+ },
+)
+
+it.each([
+ 'conditional',
+ 'conditional/node-first',
+ 'conditional/import-only',
+ 'conditional/module-first',
+])(
+ 'matches native runtime imports after a foreign resolver override for %s',
+ (specifier) => {
+ run(`
+ // Given: Bun runtime has bun/node conditions; the fixture also has browser.
+ const actual = await import('${specifier}');
+ expect(require('conditional/import-only').width).toBe('811px');
+ await Bun.write('styled.ts', "import { css } from '@devup-ui/react'; import { width } from '${specifier}'; export const cls = css({ width });");
+ await register();
+ setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" }));
+ // When: the styling module is imported through the real runtime plugin.
+ const { cls } = await import('./styled.ts');
+ // Then: synchronous disk CSS agrees with native resolution, not the override.
+ expect(cls).toBeTruthy();
+ const css = await Bun.file('df/devup-ui/devup-ui.css').text();
+ expect(css).toContain(actual.width);
+ expect(css).not.toContain('863px');
+ `)
+ },
+)
diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts
index 7637396be..7a39652cd 100644
--- a/packages/bun-plugin/__regression__/css-emit.bun.ts
+++ b/packages/bun-plugin/__regression__/css-emit.bun.ts
@@ -69,6 +69,97 @@ await import(cssPath)
},
)
+const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs')
+
+function run(cwd: string, script: string) {
+ writeFileSync(join(cwd, 'bunfig.toml'), '')
+ writeFileSync(join(cwd, 'check.ts'), script)
+ const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], {
+ cwd,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' },
+ })
+ expect(
+ result.exitCode,
+ result.stdout.toString() + result.stderr.toString(),
+ ).toBe(0)
+}
+
+it('bundles the styles of every module into the Bun.build stylesheet', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
+ try {
+ const widths = Array.from({ length: 8 }, (_, i) => 301 + i)
+ for (const width of widths) {
+ writeFileSync(
+ join(cwd, `fixture-${width}.ts`),
+ `import { css } from '@devup-ui/react'
+export const cls = css({ width: '${width}px' })
+`,
+ )
+ }
+ writeFileSync(
+ join(cwd, 'entry.ts'),
+ widths
+ .map((width) => `export { cls as c${width} } from './fixture-${width}'`)
+ .join('\n'),
+ )
+ run(
+ cwd,
+ `import { expect } from 'bun:test'
+import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))}
+
+const result = await Bun.build({
+ entrypoints: ['./entry.ts'],
+ outdir: './out',
+ plugins: [DevupUI()],
+})
+expect(result.success).toBe(true)
+const stylesheets = result.outputs.filter((output) => output.path.endsWith('.css'))
+expect(stylesheets).toHaveLength(1)
+const css = (await stylesheets[0].text()).replace(/\\s+/g, '')
+for (const width of ${JSON.stringify(widths)}) expect(css).toContain('width:' + width + 'px')
+`,
+ )
+ } finally {
+ rmSync(cwd, { recursive: true, force: true })
+ }
+})
+
+it('compiles the packages Devup UI takes the place of, and StyleX', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
+ try {
+ writeFileSync(
+ join(cwd, 'emotion.ts'),
+ `import { css } from '@emotion/react'
+export const cls = css({ width: '201px' })
+`,
+ )
+ writeFileSync(
+ join(cwd, 'stylex.ts'),
+ `import * as stylex from '@stylexjs/stylex'
+export const styles = stylex.create({ box: { width: '203px' } })
+`,
+ )
+ run(
+ cwd,
+ `import { readFileSync } from 'node:fs'
+import { expect } from 'bun:test'
+
+await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))})
+const emotion = await import('./emotion.ts')
+const stylex = await import('./stylex.ts')
+expect(emotion.cls).toBeTruthy()
+expect(stylex.styles).toBeTruthy()
+const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')
+for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px')
+`,
+ )
+ } finally {
+ rmSync(cwd, { recursive: true, force: true })
+ }
+})
+
it('emits vanilla-extract styles through the WASM engine', () => {
const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
try {
diff --git a/packages/bun-plugin/__regression__/modern.bun.ts b/packages/bun-plugin/__regression__/modern.bun.ts
new file mode 100644
index 000000000..4d6f65f7c
--- /dev/null
+++ b/packages/bun-plugin/__regression__/modern.bun.ts
@@ -0,0 +1,211 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll(
+ '\\',
+ '/',
+)
+const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun')
+mkdirSync(scratch, { recursive: true })
+
+function run(
+ script: string,
+ files: Readonly>,
+ compiler = false,
+) {
+ const root = realpathSync.native(mkdtempSync(join(scratch, 'modern-')))
+ try {
+ writeFileSync(join(root, 'bunfig.toml'), '')
+ if (compiler) {
+ const host = createRequire(
+ resolve(import.meta.dir, '../../../apps/landing/package.json'),
+ )
+ const compilerPath = createRequire(
+ host.resolve('@mdx-js/loader'),
+ ).resolve('@mdx-js/mdx')
+ mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true })
+ symlinkSync(
+ dirname(compilerPath),
+ join(root, 'node_modules/@mdx-js/mdx'),
+ 'junction',
+ )
+ }
+ for (const [name, contents] of Object.entries(files)) {
+ mkdirSync(resolve(root, name, '..'), { recursive: true })
+ writeFileSync(join(root, name), contents)
+ }
+ writeFileSync(
+ join(root, 'check.ts'),
+ `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)};\n${script}`,
+ )
+ const result = Bun.spawnSync([process.execPath, 'check.ts'], {
+ cwd: root,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ expect(
+ result.exitCode,
+ result.stdout.toString() + result.stderr.toString(),
+ ).toBe(0)
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+}
+
+it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])(
+ 'extracts real runtime and bundled source when its extension is %s',
+ (extension) => {
+ run(
+ `
+ await register();
+ const { cls } = await import('./local.${extension}');
+ expect(cls).toBeTruthy();
+ const disk = await Bun.file('df/devup-ui/devup-ui.css').text();
+ expect(disk).toContain('width:719px');
+ expect(cls).not.toContain('width');
+ const result = await Bun.build({ entrypoints: ['./local.${extension}'], plugins: [DevupUI()] });
+ expect(result.success, String(result.logs)).toBe(true);
+ const css = result.outputs.find(output => output.path.endsWith('.css'));
+ expect(await css.text()).toContain('719px');
+ `,
+ {
+ [`local.${extension}`]:
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })",
+ },
+ )
+ },
+)
+
+it('uses project paths when invoked from a parent directory', () => {
+ run(
+ `
+ const result = await Bun.build({ root: './project', entrypoints: ['./project/app/page.ts'], plugins: [DevupUI({ distDir: 'generated' })] });
+ expect(result.success, String(result.logs)).toBe(true);
+ expect(await Bun.file('project/generated/theme.d.ts').exists()).toBe(true);
+ expect(await Bun.file('generated/theme.d.ts').exists()).toBe(false);
+ const css = result.outputs.find(output => output.path.endsWith('.css'));
+ expect(await css.text()).toContain('#abcdef');
+ `,
+ {
+ 'project/devup.json':
+ '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}',
+ 'project/app/page.ts':
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '731px' })",
+ },
+ )
+})
+
+it.each(['{', '{"extends":["./devup.json"]}'])(
+ 'fails initialization with a located diagnostic when configuration is invalid: %s',
+ (config) => {
+ run(
+ `
+ let failure;
+ try { await Bun.build({ entrypoints: ['./entry.ts'], plugins: [DevupUI()] }); } catch (cause) { failure = String(cause); }
+ expect(failure).toContain('devup.json');
+ `,
+ { 'devup.json': config, 'entry.ts': 'export const value = 1' },
+ )
+ },
+)
+
+it('reports styling MDX without a compiler instead of extracting raw syntax', () => {
+ run(
+ `
+ let failure;
+ try { await Bun.build({ entrypoints: ['./page.mdx'], plugins: [DevupUI()] }); } catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); }
+ expect(failure).toContain('page.mdx:1:1');
+ expect(failure).toContain('@mdx-js/mdx');
+ `,
+ {
+ 'node_modules/@mdx-js/mdx/package.json': '{"exports":"./missing.js"}',
+ 'page.mdx':
+ "import { Box } from '@devup-ui/react'\n\n# heading\n\n ",
+ },
+ )
+})
+
+it('compiles real MDX before extraction using the project compiler', () => {
+ run(
+ `
+ const result = await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] });
+ expect(result.success, String(result.logs)).toBe(true);
+ const css = result.outputs.find(output => output.path.endsWith('.css'));
+ expect(await css.text()).toContain('743px');
+ const js = result.outputs.find(output => output.path.endsWith('.js'));
+ const code = await js.text();
+ expect(code).toContain('heading');
+ expect(code).not.toContain('@devup-ui/react');
+ `,
+ {
+ 'page.mdx':
+ "import { Box } from '@devup-ui/react'\n\n# heading\n\n ",
+ },
+ true,
+ )
+})
+
+it('labels compiled MDX locations when extraction fails without a compiler source map', () => {
+ run(
+ `
+ let failure;
+ try { await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] }); }
+ catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); }
+ expect(failure).toContain('page.mdx:');
+ expect(failure).toContain('(in compiled MDX)');
+ `,
+ {
+ 'page.mdx':
+ "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading",
+ },
+ true,
+ )
+})
+
+it('restores explicitly configured readable names when another engine user changes debug', () => {
+ const wasm = resolve(
+ import.meta.dir,
+ '../../../bindings/devup-ui-wasm/pkg/index.js',
+ ).replaceAll('\\', '/')
+ run(
+ `
+ await register({ debug: true });
+ const { setDebug } = await import(${JSON.stringify(wasm)});
+ setDebug(false);
+ const { cls } = await import('./local.ts');
+ expect(cls).toContain('width');
+ `,
+ {
+ 'local.ts':
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '761px' })",
+ },
+ )
+})
+
+it('transforms an included uncompiled runtime library', () => {
+ run(
+ `
+ await register({ include: ['library'] });
+ const { cls } = await import('library');
+ expect(cls).toBeTruthy();
+ expect(await Bun.file('df/devup-ui/devup-ui.css').text()).toContain('width:769px');
+ `,
+ {
+ 'node_modules/library/package.json':
+ '{"type":"module","exports":"./index.mjs"}',
+ 'node_modules/library/index.mjs':
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '769px' })",
+ },
+ )
+})
diff --git a/packages/bun-plugin/__regression__/watch.bun.ts b/packages/bun-plugin/__regression__/watch.bun.ts
new file mode 100644
index 000000000..d3a4fb4eb
--- /dev/null
+++ b/packages/bun-plugin/__regression__/watch.bun.ts
@@ -0,0 +1,76 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+it('reextracts an importer when only its build-time token changes under normal Bun watch caching', async () => {
+ const scratch = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'bun',
+ )
+ mkdirSync(scratch, { recursive: true })
+ const root = realpathSync.native(mkdtempSync(join(scratch, 'watch-')))
+ const plugin = resolve(import.meta.dir, '../src/register.ts').replaceAll(
+ '\\',
+ '/',
+ )
+ writeFileSync(join(root, 'bunfig.toml'), '')
+ writeFileSync(join(root, 'tokens.ts'), "export const width = '751px'")
+ writeFileSync(
+ join(root, 'style.css.ts'),
+ "import { style } from '@devup-ui/react'; import { width } from './tokens'; export const cls = style({ width })",
+ )
+ writeFileSync(
+ join(root, 'check.ts'),
+ `import { register } from ${JSON.stringify(plugin)};
+await register({ debug: true });
+const { cls } = await import('./style.css.ts');
+console.log('WATCH_RESULT:' + JSON.stringify({ cls, css: await Bun.file('df/devup-ui/devup-ui.css').text() }));`,
+ )
+ const child = Bun.spawn([process.execPath, '--watch', 'check.ts'], {
+ cwd: root,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ const errors = new Response(child.stderr).text()
+ const timer = setTimeout(() => child.kill(), 15000)
+ const results: string[] = []
+ try {
+ let pending = ''
+ for await (const chunk of child.stdout) {
+ pending += new TextDecoder().decode(chunk)
+ const lines = pending.split('\n')
+ pending = lines.pop() ?? ''
+ for (const line of lines) {
+ if (!line.startsWith('WATCH_RESULT:')) continue
+ results.push(line)
+ if (results.length === 1)
+ writeFileSync(join(root, 'tokens.ts'), "export const width = '757px'")
+ }
+ if (results.length >= 2) break
+ }
+ child.kill()
+ expect(results, (await errors) + results.join('\n')).toHaveLength(2)
+ expect(results[0]).toContain('width:751px')
+ expect(results[1]).toContain('width:757px')
+ expect(results[0]).toMatch(/"cls":"[^"]*751px/)
+ expect(results[1]).toMatch(/"cls":"[^"]*757px/)
+ expect(results[1]).not.toContain('width:751px')
+ expect(results[1]).not.toBe(results[0])
+ } finally {
+ clearTimeout(timer)
+ child.kill()
+ await child.exited
+ rmSync(root, { recursive: true, force: true })
+ }
+}, 20000)
diff --git a/packages/bun-plugin/src/__tests__/callback-fixture.test.ts b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts
new file mode 100644
index 000000000..b03b35573
--- /dev/null
+++ b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts
@@ -0,0 +1,108 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import { loadDevupConfig } from '@devup-ui/plugin-utils'
+import {
+ registerShorthands,
+ registerTheme,
+ resetBuildState,
+ setDebug,
+ setPrefix,
+} from '@devup-ui/wasm'
+import type {
+ OnEndCallback,
+ OnLoadCallback,
+ OnResolveCallback,
+ PluginConstraints,
+} from 'bun'
+
+import { DevupUI } from '../devup-plugin'
+
+type Builder = Parameters['setup']>[0]
+
+export function fixture() {
+ const root = realpathSync.native(
+ mkdtempSync(join(tmpdir(), 'devup-bun-callback-')),
+ )
+ const loads: { constraints: PluginConstraints; callback: OnLoadCallback }[] =
+ []
+ const resolves: {
+ constraints: PluginConstraints
+ callback: OnResolveCallback
+ }[] = []
+ const ends: OnEndCallback[] = []
+ let success = true
+ const builder = {
+ onLoad(constraints, callback) {
+ loads.push({ constraints, callback })
+ return this
+ },
+ onResolve(constraints, callback) {
+ resolves.push({ constraints, callback })
+ return this
+ },
+ onEnd(callback) {
+ ends.push(callback)
+ return this
+ },
+ } satisfies Builder
+ return {
+ root,
+ builder,
+ loads,
+ resolves,
+ write(name: string, contents: string) {
+ const path = join(root, name)
+ mkdirSync(dirname(path), { recursive: true })
+ writeFileSync(path, contents)
+ return path
+ },
+ async setup(
+ options: Parameters[0] = {},
+ config?: Builder['config'],
+ ) {
+ await DevupUI({ root, ...options }).setup({
+ ...builder,
+ ...(config ? { config } : {}),
+ })
+ },
+ async load(path: string, namespace = 'file', defer = async () => {}) {
+ const hook = loads.find(
+ ({ constraints }) =>
+ (constraints.namespace ?? 'file') === namespace &&
+ constraints.filter.test(path),
+ )
+ if (!hook) throw new Error(`No load hook for ${namespace}:${path}`)
+ try {
+ return await hook.callback({ path, namespace, loader: 'tsx', defer })
+ } catch (error) {
+ success = false
+ throw error
+ }
+ },
+ async cleanup() {
+ for (const end of ends) await end({ outputs: [], logs: [], success })
+ resetBuildState()
+ registerShorthands({})
+ setPrefix(null)
+ registerTheme(
+ (await loadDevupConfig(join(process.cwd(), 'devup.json'))).theme ?? {},
+ )
+ setDebug(true)
+ rmSync(root, { recursive: true, force: true })
+ },
+ }
+}
+
+export function sourceContents(result: Awaited>) {
+ if (!result || !('contents' in result) || typeof result.contents !== 'string')
+ throw new TypeError('Expected source contents')
+ return result.contents
+}
diff --git a/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts
new file mode 100644
index 000000000..7db453379
--- /dev/null
+++ b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts
@@ -0,0 +1,89 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+import { getCss } from '@devup-ui/wasm'
+import { expect, it } from 'bun:test'
+
+import { DevupUI } from '../devup-plugin'
+
+const landing = createRequire(
+ resolve(import.meta.dir, '../../../../apps/landing/package.json'),
+)
+
+it.each(['.mdx', '.mdown'])(
+ 'compiles owned %s using the project compiler',
+ async (extension) => {
+ const root = mkdtempSync(join(tmpdir(), 'devup-owned-bun-'))
+ try {
+ const path = join(root, `page${extension}`)
+ const compilerPath = createRequire(
+ landing.resolve('@mdx-js/loader'),
+ ).resolve('@mdx-js/mdx')
+ mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true })
+ symlinkSync(
+ dirname(compilerPath),
+ join(root, 'node_modules/@mdx-js/mdx'),
+ 'junction',
+ )
+ writeFileSync(
+ path,
+ 'import {Box} from "@devup-ui/react"\n\n ',
+ )
+ const result = await Bun.build({
+ entrypoints: [path],
+ root,
+ plugins: [DevupUI({ root, mdxExtensions: ['.mdx', '.mdown'] })],
+ external: ['react', 'react/jsx-runtime'],
+ })
+ expect(result.success).toBe(true)
+ expect(getCss(null, false)).toContain('red')
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ },
+ 30_000,
+)
+
+it.each(['entry', 'import'])(
+ 'rejects a stolen owned %s without stylesheet imports',
+ async (kind) => {
+ const root = mkdtempSync(join(tmpdir(), 'devup-stolen-bun-'))
+ try {
+ const mdx = join(root, 'page.mdown')
+ const entry = join(root, 'entry.ts')
+ writeFileSync(mdx, '# Plain')
+ writeFileSync(
+ entry,
+ 'import value from "./page.mdown"; console.log(value)',
+ )
+ const action = Bun.build({
+ entrypoints: [kind === 'entry' ? mdx : entry],
+ root,
+ plugins: [
+ {
+ name: 'stealer',
+ setup(builder) {
+ builder.onLoad({ filter: /\.mdown$/ }, () => ({
+ contents: 'export default 1',
+ loader: 'js',
+ }))
+ },
+ },
+ DevupUI({ root, mdxExtensions: ['.mdown'] }),
+ ],
+ })
+ await expect(action).rejects.toThrow('let Devup compile')
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ },
+ 30_000,
+)
diff --git a/packages/bun-plugin/src/__tests__/mdx.test.ts b/packages/bun-plugin/src/__tests__/mdx.test.ts
new file mode 100644
index 000000000..9da06fe83
--- /dev/null
+++ b/packages/bun-plugin/src/__tests__/mdx.test.ts
@@ -0,0 +1,78 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+import { compileMdx } from '../mdx'
+
+const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun')
+mkdirSync(scratch, { recursive: true })
+
+it('compiles original MDX when the project has the real compiler', async () => {
+ const root = mkdtempSync(join(scratch, 'mdx-'))
+ try {
+ const host = createRequire(
+ resolve(import.meta.dir, '../../../../apps/landing/package.json'),
+ )
+ const compilerPath = createRequire(host.resolve('@mdx-js/loader')).resolve(
+ '@mdx-js/mdx',
+ )
+ mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true })
+ symlinkSync(
+ dirname(compilerPath),
+ join(root, 'node_modules/@mdx-js/mdx'),
+ 'junction',
+ )
+ const compiled = await compileMdx(
+ root,
+ join(root, 'page.mdx'),
+ '# title\n\n
',
+ )
+ expect(compiled?.value).toContain('
')
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+}, 20000)
+
+it.each(['unrelated', 'styling', 'invalid compiler'])(
+ 'handles compiler boundaries when the project has %s MDX',
+ async (scenario) => {
+ const root = mkdtempSync(join(scratch, 'mdx-'))
+ try {
+ const pkg = join(root, 'node_modules/@mdx-js/mdx')
+ mkdirSync(pkg, { recursive: true })
+ writeFileSync(
+ join(pkg, 'package.json'),
+ JSON.stringify({
+ type: 'module',
+ exports:
+ scenario === 'invalid compiler' ? './index.js' : './missing.js',
+ }),
+ )
+ writeFileSync(join(pkg, 'index.js'), 'export const unrelated = true')
+ const action = () =>
+ compileMdx(
+ root,
+ join(root, 'page.mdx'),
+ scenario === 'styling'
+ ? "import { Box } from '@devup-ui/react'"
+ : '# title',
+ )
+ if (scenario === 'unrelated') expect(await action()).toBeUndefined()
+ else
+ await expect(action()).rejects.toThrow(
+ scenario === 'styling' ? 'page.mdx:1:1' : 'compile()',
+ )
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ },
+)
diff --git a/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts
new file mode 100644
index 000000000..a85ad5d7f
--- /dev/null
+++ b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts
@@ -0,0 +1,224 @@
+import { symlinkSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+
+import * as wasm from '@devup-ui/wasm'
+import { expect, it, spyOn } from 'bun:test'
+
+import { fixture, sourceContents } from './callback-fixture.test'
+
+it('warns once and still extracts real files when optional numbering fails', async () => {
+ const f = fixture()
+ const path = f.write(
+ 'src/style.ts',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '751px' })",
+ )
+ const cause = new Error('optional seed failure')
+ const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => {
+ throw cause
+ })
+ const warning = spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ expect(warning).toHaveBeenCalledTimes(1)
+ expect(warning.mock.calls[0]?.[1]).toEqual({
+ root: f.root,
+ phase: 'seed',
+ impact: 'arrival-order file IDs',
+ cause,
+ })
+ expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react')
+ expect(wasm.getCss(null, false)).toContain('751px')
+ } finally {
+ seed.mockRestore()
+ warning.mockRestore()
+ await f.cleanup()
+ }
+})
+
+it('propagates non-Error numbering faults rather than converting them to warnings', async () => {
+ const f = fixture()
+ f.write(
+ 'src/style.ts',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '757px' })",
+ )
+ const cause = { code: 'seed-fault' }
+ const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => {
+ throw cause
+ })
+ try {
+ await expect(f.setup()).rejects.toBe(cause)
+ } finally {
+ seed.mockRestore()
+ await f.cleanup()
+ }
+})
+
+it.each(['unrelated', 'styling'])(
+ 'handles %s MDX without a project compiler',
+ async (scenario) => {
+ const f = fixture()
+ f.write(
+ 'node_modules/@mdx-js/mdx/package.json',
+ '{"exports":"./missing.js"}',
+ )
+ const path = f.write(
+ 'page.mdx',
+ scenario === 'styling'
+ ? "import { Box } from '@devup-ui/react'\n\n "
+ : '# title',
+ )
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ await expect(f.load(path)).rejects.toThrow('page.mdx:1:1')
+ } finally {
+ await f.cleanup()
+ }
+ },
+)
+
+it.each(['plain', 'styling', 'invalid'])(
+ 'compiles real %s MDX with the project compiler before scanning/extraction',
+ async (scenario) => {
+ const f = fixture()
+ const host = createRequire(
+ resolve(import.meta.dir, '../../../../apps/landing/package.json'),
+ )
+ const compiler = createRequire(host.resolve('@mdx-js/loader')).resolve(
+ '@mdx-js/mdx',
+ )
+ f.write('node_modules/@mdx-js/placeholder', '')
+ symlinkSync(
+ dirname(compiler),
+ join(f.root, 'node_modules/@mdx-js/mdx'),
+ 'junction',
+ )
+ const path = f.write(
+ 'src/page.mdx',
+ {
+ plain: '# heading\n\n
',
+ styling:
+ "import { Box } from '@devup-ui/react'\n\n# heading\n\n ",
+ invalid:
+ "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading",
+ }[scenario],
+ )
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ if (scenario === 'invalid')
+ await expect(f.load(path)).rejects.toThrow('(in compiled MDX)')
+ else {
+ const result = await f.load(path)
+ expect(result?.loader).toBe('jsx')
+ expect(sourceContents(result)).toContain('heading')
+ if (scenario === 'styling') {
+ expect(sourceContents(result)).not.toContain('@devup-ui/react')
+ expect(wasm.getCss(null, false)).toContain('761px')
+ }
+ }
+ } finally {
+ await f.cleanup()
+ }
+ },
+ 20000,
+)
+
+it('uses compiler source maps for located extraction errors', async () => {
+ const f = fixture()
+ f.write(
+ 'node_modules/@mdx-js/mdx/package.json',
+ '{"type":"module","exports":"./index.js"}',
+ )
+ f.write(
+ 'node_modules/@mdx-js/mdx/index.js',
+ `export async function compile(input) {
+ return { value: "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })", map: { version: 3, sources: [input.path], names: [], mappings: 'AAIA' } }
+ }`,
+ )
+ const path = f.write('page.mdx', '# title')
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ await expect(f.load(path)).rejects.toThrow(`${path}:5:1`)
+ } finally {
+ await f.cleanup()
+ }
+})
+
+it.each([
+ { target: 'browser', conditions: ['custom'], width: '773px' },
+ { target: 'node', conditions: 'custom', width: '773px' },
+ { target: 'node', conditions: [], width: '787px' },
+ { target: 'bun', width: '797px' },
+] as const)(
+ 'selects compile-time conditional exports using $target and $conditions',
+ async (config) => {
+ const f = fixture()
+ f.write(
+ 'node_modules/conditional/package.json',
+ JSON.stringify({
+ type: 'module',
+ exports: {
+ custom: './custom.js',
+ bun: './bun.js',
+ node: './node.js',
+ browser: './browser.js',
+ },
+ }),
+ )
+ for (const [branch, width] of Object.entries({
+ custom: '773px',
+ node: '787px',
+ bun: '797px',
+ browser: '809px',
+ }))
+ f.write(
+ `node_modules/conditional/${branch}.js`,
+ `export const width = '${width}'`,
+ )
+ const path = f.write(
+ 'style.ts',
+ "import { css } from '@devup-ui/react'; import { width } from 'conditional'; export const cls = css({ width })",
+ )
+ try {
+ await f.setup(
+ {},
+ {
+ entrypoints: [path],
+ plugins: [],
+ target: config.target,
+ ...(config.conditions !== undefined
+ ? {
+ conditions:
+ typeof config.conditions === 'string'
+ ? config.conditions
+ : [...config.conditions],
+ }
+ : {}),
+ },
+ )
+ await f.load(path)
+ expect(wasm.getCss(null, false)).toContain(config.width)
+ } finally {
+ await f.cleanup()
+ }
+ },
+)
+
+it('includes configured libraries in the native-path runtime filter', async () => {
+ const f = fixture()
+ f.write(
+ 'node_modules/library/package.json',
+ '{"type":"module","exports":"./index.mjs"}',
+ )
+ const path = f.write(
+ 'node_modules/library/index.mjs',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '811px' })",
+ )
+ try {
+ await f.setup({ include: ['library'] })
+ expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react')
+ expect(wasm.getCss(null, false)).toContain('811px')
+ } finally {
+ await f.cleanup()
+ }
+})
diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts
index e93d67418..5e33170ce 100644
--- a/packages/bun-plugin/src/__tests__/plugin.test.ts
+++ b/packages/bun-plugin/src/__tests__/plugin.test.ts
@@ -1,199 +1,201 @@
-import * as fs from 'node:fs'
-import * as fsPromises from 'node:fs/promises'
+import { readFileSync, statSync, utimesSync } from 'node:fs'
import { join } from 'node:path'
-import * as wasm from '@devup-ui/wasm'
-import {
- afterEach,
- beforeEach,
- describe,
- expect,
- it,
- mock,
- spyOn,
-} from 'bun:test'
+import { getCss, setDebug, setModuleResolver } from '@devup-ui/wasm'
+import { expect, it, spyOn } from 'bun:test'
-let getDefaultThemeSpy: ReturnType
-let existsSyncSpy: ReturnType
-let readFileSpy: ReturnType
-let writeFileSpy: ReturnType
-let mkdirSpy: ReturnType
-let registerThemeSpy: ReturnType
-let getThemeInterfaceSpy: ReturnType
-let getCssSpy: ReturnType
-let consoleErrorSpy: ReturnType
-let setDebugSpy: ReturnType
-let hasDevupUISpy: ReturnType
-let codeExtractSpy: ReturnType
+import { cssNamespace } from '../css-id'
+import { fixture, sourceContents } from './callback-fixture.test'
-type CodeExtractResult = ReturnType
-
-function createCodeExtractResult(): CodeExtractResult {
- return {
- code: 'code',
- css: '',
- cssFile: null,
- map: null,
- updatedBaseStyle: false,
- free: mock(),
- [Symbol.dispose]: mock(),
- } as unknown as CodeExtractResult
-}
-
-beforeEach(() => {
- getDefaultThemeSpy = spyOn(wasm, 'getDefaultTheme').mockReturnValue('default')
- existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false)
- readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}')
- writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined)
- mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined)
- registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined)
- getThemeInterfaceSpy = spyOn(wasm, 'getThemeInterface').mockReturnValue('')
- getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css')
- consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
- setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined)
- hasDevupUISpy = spyOn(wasm, 'hasDevupUI').mockReturnValue(false)
- codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue(
- createCodeExtractResult(),
+it('writes theme declarations and CSS when setup creates project directories', async () => {
+ const f = fixture()
+ f.write(
+ 'devup.json',
+ '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}',
)
+ try {
+ await f.setup({ shorthands: { insetX: ['left', 'right'] } })
+ expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain(
+ 'primary',
+ )
+ expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain(
+ 'insetX',
+ )
+ expect(readFileSync(join(f.root, 'df/compat.d.ts'), 'utf8')).toContain(
+ '@devup-ui/react/compat/emotion',
+ )
+ expect(readFileSync(join(f.root, 'df/.gitignore'), 'utf8')).toBe('*')
+ expect(
+ readFileSync(
+ join(f.root, 'df/devup-ui/devup-ui.css'),
+ 'utf8',
+ ).toLowerCase(),
+ ).toContain('#abcdef')
+ } finally {
+ await f.cleanup()
+ }
})
-afterEach(() => {
- getDefaultThemeSpy.mockRestore()
- existsSyncSpy.mockRestore()
- readFileSpy.mockRestore()
- writeFileSpy.mockRestore()
- mkdirSpy.mockRestore()
- registerThemeSpy.mockRestore()
- getThemeInterfaceSpy.mockRestore()
- getCssSpy.mockRestore()
- consoleErrorSpy.mockRestore()
- setDebugSpy.mockRestore()
- hasDevupUISpy.mockRestore()
- codeExtractSpy.mockRestore()
+it('overwrites stale declarations when configuration has no theme and directories exist', async () => {
+ const f = fixture()
+ f.write('devup.json', '{}')
+ f.write('df/theme.d.ts', 'stale-theme')
+ f.write('df/devup-ui/devup-ui.css', 'stale-css')
+ try {
+ await f.setup()
+ expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).not.toContain(
+ 'stale-theme',
+ )
+ expect(
+ readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8'),
+ ).not.toContain('stale-css')
+ } finally {
+ await f.cleanup()
+ }
})
-describe('getDevupDefine', () => {
- it('should return define object with theme', async () => {
- getDefaultThemeSpy.mockReturnValue('dark')
- expect(getDefaultThemeSpy()).toBe('dark')
- })
-
- it('should return empty object when no theme', async () => {
- getDefaultThemeSpy.mockReturnValue(undefined)
- expect(getDefaultThemeSpy()).toBe(undefined)
- })
+it('publishes changed CSS synchronously but leaves identical revisions untouched', async () => {
+ const f = fixture()
+ const path = f.write(
+ 'src/style.ts',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })",
+ )
+ const tokens = f.write('src/tokens.ts', "export const width = '727px'")
+ const plain = f.write('src/plain.ts', 'export const value = 1')
+ try {
+ await f.setup()
+ const result = await f.load(path)
+ expect(sourceContents(result)).not.toContain('@devup-ui/react')
+ const cssPath = join(f.root, 'df/devup-ui/devup-ui.css')
+ expect(readFileSync(cssPath, 'utf8')).toContain('width:719px')
+ utimesSync(cssPath, 1, 1)
+ const before = statSync(cssPath).mtimeMs
+ await f.load(path)
+ expect(statSync(cssPath).mtimeMs).toBe(before)
+ expect(
+ f.loads
+ .find((h) => !h.constraints.namespace)
+ ?.constraints.filter.test(tokens),
+ ).toBe(false)
+ expect(
+ f.loads
+ .find((h) => !h.constraints.namespace)
+ ?.constraints.filter.test(plain),
+ ).toBe(false)
+ expect(
+ await f.load('devup-ui.css', cssNamespace, () => {
+ throw new Error('Runtime must not defer')
+ }),
+ ).toEqual({ contents: '', loader: 'js' })
+ } finally {
+ await f.cleanup()
+ }
})
-describe('writeDataFiles behavior', () => {
- it('should register theme from devup.json when it exists', async () => {
- readFileSpy.mockResolvedValue('{"theme": {"colors": {"primary": "#000"}}}')
- getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}')
-
- // Simulate writeDataFiles behavior without exporting private plugin helpers.
- const content = '{"theme": {"colors": {"primary": "#000"}}}'
- const parsed = JSON.parse(content)
- registerThemeSpy(parsed?.['theme'] ?? {})
-
- expect(registerThemeSpy).toHaveBeenCalledWith({
- colors: { primary: '#000' },
- })
- })
-
- it('should write theme.d.ts when interfaceCode is returned', async () => {
- getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}')
+it('preserves compile-time dependency imports and restores debug/resolver before extraction', async () => {
+ const f = fixture()
+ f.write('src/tokens.ts', "export const width = '733px'")
+ const path = f.write(
+ 'src/style.ts',
+ "import { style } from '@vanilla-extract/css'; import { width } from './tokens'; export const cls = style({ width })",
+ )
+ try {
+ await f.setup({ debug: true })
+ setDebug(false)
+ setModuleResolver(() => ({
+ path: join(f.root, 'wrong.ts'),
+ code: "export const width = '999px'",
+ }))
+ const code = sourceContents(await f.load(path))
+ expect(code).toContain('733px')
+ expect(code).toContain('tokens.ts')
+ expect(getCss(null, false)).toContain('width:733px')
+ expect(getCss(null, false)).not.toContain('999px')
+ } finally {
+ await f.cleanup()
+ }
+})
- const interfaceCode = getThemeInterfaceSpy(
- '@devup-ui/react',
- 'CustomColors',
- 'DevupThemeTypography',
- 'CustomLength',
- 'CustomShadows',
- 'DevupTheme',
+it('defers bundled CSS until source callbacks complete and does not publish it to disk', async () => {
+ const f = fixture()
+ const path = f.write(
+ 'style.ts',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: '739px' })",
+ )
+ try {
+ await f.setup({}, { entrypoints: [path], root: f.root, plugins: [] })
+ const initial = readFileSync(
+ join(f.root, 'df/devup-ui/devup-ui.css'),
+ 'utf8',
)
-
- if (interfaceCode) {
- await writeFileSpy(join('df', 'theme.d.ts'), interfaceCode, 'utf-8')
- }
-
- expect(writeFileSpy).toHaveBeenCalledWith(
- join('df', 'theme.d.ts'),
- 'interface CustomColors {}',
- 'utf-8',
+ const defer = spyOn(
+ {
+ async run() {
+ await f.load(path)
+ },
+ },
+ 'run',
)
- })
-
- it('should register empty theme when devup.json does not exist', async () => {
- existsSyncSpy.mockReturnValue(false)
-
- // Simulate the missing config branch.
- const content = undefined
- if (!content) {
- registerThemeSpy({})
- }
-
- expect(registerThemeSpy).toHaveBeenCalledWith({})
- })
-
- it('should handle error and register empty theme on catch', async () => {
- existsSyncSpy.mockImplementation((path: string) => path === 'devup.json')
- readFileSpy.mockRejectedValue(new Error('Read error'))
-
- try {
- await readFileSpy('devup.json', 'utf-8')
- } catch (error) {
- consoleErrorSpy(error)
- registerThemeSpy({})
- }
-
- expect(consoleErrorSpy).toHaveBeenCalled()
- expect(registerThemeSpy).toHaveBeenCalledWith({})
- })
-
- it('should handle JSON without theme key', async () => {
- existsSyncSpy.mockImplementation((path: string) => path === 'devup.json')
-
- const content = '{"otherKey": "value"}'
- const parsed = JSON.parse(content)
- registerThemeSpy(parsed?.['theme'] ?? {})
-
- expect(registerThemeSpy).toHaveBeenCalledWith({})
- })
-
- it('should create css directory when it does not exist', async () => {
- existsSyncSpy.mockReturnValue(false)
-
- // Simulate the directory creation branch without exposing internals.
- if (!existsSyncSpy('df/devup-ui')) {
- await mkdirSpy('df/devup-ui', { recursive: true })
- }
-
- expect(mkdirSpy).toHaveBeenCalledWith('df/devup-ui', { recursive: true })
- })
+ const css = await f.load('devup-ui.css', cssNamespace, defer)
+ expect(defer).toHaveBeenCalledTimes(1)
+ expect(sourceContents(css)).toContain('739px')
+ expect(readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8')).toBe(
+ initial,
+ )
+ const resolve = f.resolves[0]
+ if (!resolve) throw new Error('Expected CSS resolver')
+ expect(
+ await resolve.callback({
+ path: './df/devup-ui/devup-ui.css',
+ importer: path,
+ namespace: 'file',
+ resolveDir: f.root,
+ kind: 'import-statement',
+ }),
+ ).toEqual({ path: 'devup-ui.css', namespace: cssNamespace })
+ } finally {
+ await f.cleanup()
+ }
})
-describe('plugin preload coverage', () => {
- // The plugin is preloaded via bunfig.toml, which covers the main execution paths
- // These tests verify the plugin's behavior through its side effects
-
- it('should have called setDebug during initialization', () => {
- // Plugin calls setDebug(true) in setup
- // This is covered by the preload
- expect(setDebugSpy).toBeDefined()
- })
-
- it('should have registered theme during initialization', () => {
- // Plugin calls registerTheme during writeDataFiles
- // This is covered by the preload
- expect(registerThemeSpy).toBeDefined()
- })
+it('passes ordinary modules through the bundler source callback', async () => {
+ const f = fixture()
+ const path = f.write('plain.mts', 'export const value = 1')
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ expect(await f.load(path)).toEqual({
+ contents: 'export const value = 1',
+ loader: 'ts',
+ })
+ } finally {
+ await f.cleanup()
+ }
+})
- it('should resolve devup-ui css path', async () => {
- // This import triggers the onResolve callback for CSS files
+it.each(['{', '{"extends":["./devup.json"]}'])(
+ 'rejects malformed configuration %s instead of silently registering an empty theme',
+ async (config) => {
+ const f = fixture()
+ f.write('devup.json', config)
try {
- await import('df/devup-ui/devup-ui.css')
- } catch {
- // File may not exist, but the resolver still runs
+ await expect(f.setup()).rejects.toThrow(join(f.root, 'devup.json'))
+ } finally {
+ await f.cleanup()
}
- expect(true).toBe(true)
- })
+ },
+)
+
+it('propagates source read and extraction errors', async () => {
+ const f = fixture()
+ const path = f.write(
+ 'bad.ts',
+ "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })",
+ )
+ try {
+ await f.setup({}, { entrypoints: [path], plugins: [] })
+ await expect(f.load(join(f.root, 'missing.ts'))).rejects.toThrow('ENOENT')
+ await expect(f.load(path)).rejects.toThrow('bad.ts:')
+ } finally {
+ await f.cleanup()
+ }
})
diff --git a/packages/bun-plugin/src/__tests__/source.test.ts b/packages/bun-plugin/src/__tests__/source.test.ts
new file mode 100644
index 000000000..ef0c8a3c1
--- /dev/null
+++ b/packages/bun-plugin/src/__tests__/source.test.ts
@@ -0,0 +1,105 @@
+import { join } from 'node:path'
+
+import { expect, it, spyOn } from 'bun:test'
+
+import {
+ importsCompiledPackage,
+ mentionsCompiledPackage,
+ preserveDependencies,
+ runtimeSourceFilter,
+ sourceLoader,
+} from '../source'
+
+it.each(['ts', 'mts', 'cts', 'tsx', 'js', 'mjs', 'cjs', 'jsx'])(
+ 'chooses the Bun loader when the source extension is %s',
+ (extension) => {
+ const filename = `source.${extension.toUpperCase()}`
+ const expected = extension.endsWith('tsx')
+ ? 'tsx'
+ : extension.endsWith('ts')
+ ? 'ts'
+ : extension === 'jsx'
+ ? 'jsx'
+ : 'js'
+ const loader = sourceLoader(filename)
+ expect(loader).toBe(expected)
+ },
+)
+
+it.each(['@devup-ui/react', '@emotion/react/jsx-runtime', '@stylexjs/stylex'])(
+ 'recognizes a real import when its package is %s',
+ (name) => {
+ const source = `import * as styles from '${name}'; export { styles }`
+ const found = importsCompiledPackage(source, 'js')
+ expect(found).toBe(true)
+ },
+)
+
+it.each(['js', 'jsx', 'ts', 'tsx'] as const)(
+ 'ignores text-only package mentions when using %s',
+ (loader) => {
+ const source = '// @devup-ui/react\nexport const value = 1'
+ const found = importsCompiledPackage(source, loader)
+ expect(found).toBe(false)
+ },
+)
+
+it('leaves syntax errors to Bun when the scanner rejects source', () => {
+ const found = importsCompiledPackage('import {', 'ts')
+ expect(found).toBe(false)
+})
+
+it('propagates unexpected scanner failures rather than treating them as syntax errors', () => {
+ const failure = Symbol('scanner failure')
+ const scanner = spyOn(
+ Bun.Transpiler.prototype,
+ 'scanImports',
+ ).mockImplementation(() => {
+ throw failure
+ })
+ let received: unknown
+ try {
+ importsCompiledPackage('export const value = 1', 'ts')
+ } catch (cause) {
+ if (cause !== failure) throw cause
+ received = cause
+ } finally {
+ scanner.mockRestore()
+ }
+ expect(received).toBe(failure)
+})
+
+it('distinguishes styling MDX when a compiler is missing', () => {
+ const results = ['# hello', "import { Box } from '@devup-ui/react'"].map(
+ mentionsCompiledPackage,
+ )
+ expect(results).toEqual([false, true])
+})
+
+it('preserves unique dependency edges when extraction erased the imports', () => {
+ const filename = join(process.cwd(), 'src', 'entry.ts')
+ const dependency = join(process.cwd(), 'tokens.ts')
+ const code = preserveDependencies('export const cls = "a"', filename, [
+ dependency,
+ dependency,
+ join(process.cwd(), 'src', 'local.ts'),
+ ])
+ expect(
+ new Bun.Transpiler({ loader: 'ts' })
+ .scanImports(code)
+ .map(({ path }) => path),
+ ).toEqual(['../tokens.ts', './local.ts'])
+})
+
+it('retains source when no dependencies were extracted', () => {
+ const code = preserveDependencies('export const value = 1', 'entry.ts', [])
+ expect(code).toBe('export const value = 1')
+})
+
+it('claims only candidate files when building a runtime filter', () => {
+ const filter = runtimeSourceFilter(['C:\\source\\foo.bar.ts'])
+ expect(filter.test('C:\\source\\foo.bar.ts')).toBe(true)
+ expect(filter.test('C:/source/foo.bar.ts')).toBe(true)
+ expect(filter.test('C:/source/fooXbar.ts')).toBe(false)
+ expect(runtimeSourceFilter([]).test('anything.ts')).toBe(false)
+})
diff --git a/packages/bun-plugin/src/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts
new file mode 100644
index 000000000..02dd62485
--- /dev/null
+++ b/packages/bun-plugin/src/devup-plugin.ts
@@ -0,0 +1,274 @@
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { basename, join, resolve } from 'node:path'
+
+import {
+ beginBuild,
+ collectNumberedFiles,
+ createCompatTypes,
+ createModuleResolver,
+ createThemeInterfaceArgs,
+ type CustomShorthands,
+ isMdxSource,
+ listSourceFiles,
+ loadDevupConfig,
+ mdxSourceFilter,
+ normalizeMdxExtensions,
+ type PreparedSource,
+ resolveProjectPaths,
+ resolveSourceDirs,
+ seedFileNumbers,
+ selectedSourceFilter,
+} from '@devup-ui/plugin-utils'
+import {
+ getCss,
+ getThemeInterface,
+ registerShorthands,
+ registerTheme,
+ resetBuildState,
+ seedFileMap,
+ setDebug,
+ setModuleResolver,
+ setPrefix,
+} from '@devup-ui/wasm'
+import { type BunPlugin, type PluginBuilder } from 'bun'
+
+import { cssNamespace, resolveCssId } from './css-id'
+import { createMdxOwnership } from './mdx-ownership'
+import {
+ compiledPackages,
+ importAliases,
+ libPackage,
+ mentionsCompiledPackage,
+ runtimeSourceFilter,
+} from './source'
+import { loadSourceFile, type SourceProject } from './source-load'
+
+type PluginHooks = {
+ readonly config?: PluginBuilder['config']
+ readonly onEnd?: (...args: Parameters) => void
+ readonly onLoad: (...args: Parameters) => void
+ readonly onResolve: (...args: Parameters) => void
+}
+
+export interface DevupUIBunPluginOptions {
+ shorthands?: CustomShorthands
+ /** Resolved against Bun.build's root, or cwd in the runtime. */
+ root?: string
+ devupFile?: string
+ distDir?: string
+ sourceDirs?: string | string[]
+ /** Uncompiled dependencies to transform under the runtime. */
+ include?: string[]
+ /** Readable class names. Defaults to false in both runtime and builds. */
+ debug?: boolean
+ mdxExtensions?: readonly string[]
+}
+
+type Project = ReturnType & SourceProject
+
+async function writeDataFiles({ devupFile, distDir, cssDir }: Project) {
+ const config = await loadDevupConfig(devupFile)
+ registerTheme(config.theme ?? {})
+
+ // Generate theme interface after registration (always write, even if empty)
+ await writeFile(
+ join(distDir, 'theme.d.ts'),
+ getThemeInterface(...createThemeInterfaceArgs(libPackage)),
+ 'utf-8',
+ )
+
+ if (!existsSync(cssDir)) {
+ await mkdir(cssDir, { recursive: true })
+ }
+ await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8')
+}
+
+async function initialize(project: Project, options: DevupUIBunPluginOptions) {
+ const { root, distDir } = project
+ registerShorthands(options.shorthands ?? {})
+ setPrefix(null)
+ setModuleResolver(project.resolver)
+ // Number every source file in path order, so class prefixes do not depend
+ // on the order Bun loads files in
+ try {
+ seedFileNumbers(
+ { seedFileMap },
+ collectNumberedFiles({
+ roots: resolveSourceDirs(root, options.sourceDirs),
+ includeMdx: project.mdxExtensions,
+ cwd: root,
+ include: options.include,
+ needles: compiledPackages,
+ toId: (path) => path,
+ }),
+ )
+ } catch (cause) {
+ if (!(cause instanceof Error)) throw cause
+ console.warn('[devup-ui] File numbering fallback', {
+ root,
+ phase: 'seed',
+ impact: 'arrival-order file IDs',
+ cause,
+ })
+ }
+ if (!existsSync(distDir)) await mkdir(distDir, { recursive: true })
+ await writeFile(join(distDir, '.gitignore'), '*', 'utf-8')
+ await writeFile(
+ join(distDir, 'compat.d.ts'),
+ createCompatTypes(importAliases),
+ 'utf-8',
+ )
+ await writeDataFiles(project)
+}
+
+/**
+ * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the
+ * Bun runtime ({@link register}).
+ */
+export function DevupUI(options: DevupUIBunPluginOptions = {}) {
+ return {
+ name: 'devup-ui',
+
+ async setup(build: PluginHooks) {
+ // `Bun.build` hands its config to plugins; the runtime has none
+ const bundling = build.config !== undefined
+ const root = resolve(options.root ?? build.config?.root ?? process.cwd())
+ const targetConditions = {
+ browser: ['browser'],
+ bun: ['bun', 'node'],
+ node: ['node'],
+ } as const
+ const customConditions =
+ typeof build.config?.conditions === 'string'
+ ? [build.config.conditions]
+ : (build.config?.conditions ?? [])
+ const mdxExtensions = normalizeMdxExtensions(options.mdxExtensions)
+ const conditions = [
+ ...targetConditions[
+ build.config?.target ?? (bundling ? 'browser' : 'bun')
+ ],
+ 'import',
+ ...(bundling ? ['module'] : []),
+ ...customConditions,
+ ]
+ const prepared = new Map()
+ const ownership = createMdxOwnership({
+ root,
+ extensions: mdxExtensions,
+ conditions,
+ entries: build.config?.entrypoints ?? [],
+ })
+ const project: Project = {
+ ...resolveProjectPaths(root, options),
+ root,
+ debug: options.debug ?? false,
+ mdxExtensions,
+ prepared,
+ ownership,
+ resolver: createModuleResolver({
+ cwd: root,
+ // Extracted ESM imports stay import requests even in the vanilla
+ // CommonJS evaluator. The callback does not expose a request kind.
+ conditions,
+ includeMdx: mdxExtensions,
+ prepareSource: (filename) => prepared.get(filename),
+ }),
+ }
+ // A build starts from its own options, not from what an earlier build in
+ // this process left in the engine
+ const endBuild = beginBuild({ resetBuildState })
+ build.onEnd?.((result) => {
+ try {
+ if (result.success) ownership.validate()
+ } finally {
+ endBuild()
+ }
+ })
+ await initialize(project, options)
+ setDebug(project.debug)
+ let writtenCss = getCss(null, false)
+ // Native token modules must keep Bun's filesystem watch/CJS loading path.
+ const sourceFilter = bundling
+ ? selectedSourceFilter(mdxExtensions)
+ : new RegExp(
+ `${
+ runtimeSourceFilter([
+ ...listSourceFiles(
+ root,
+ [
+ 'target',
+ 'dist',
+ basename(project.distDir),
+ '.git',
+ 'coverage',
+ ],
+ { includeMdx: mdxExtensions },
+ ).filter(
+ (file) =>
+ isMdxSource(file, mdxExtensions) ||
+ mentionsCompiledPackage(readFileSync(file, 'utf-8')),
+ ),
+ ...collectNumberedFiles({
+ roots: resolveSourceDirs(root, options.sourceDirs),
+ includeMdx: mdxExtensions,
+ cwd: root,
+ include: ['@devup-ui/components', ...(options.include ?? [])],
+ needles: compiledPackages,
+ toId: (path) => path,
+ }),
+ ]).source
+ }|${mdxSourceFilter(mdxExtensions).source}|\\.(?:test|spec)\\.[mc]?[jt]sx?$`,
+ 'i',
+ )
+
+ // Resolve devup-ui CSS files onto a path-free virtual id, so nothing
+ // derived from this checkout's cwd can be baked into Bun's shared,
+ // content-keyed transpiler cache. See ./css-id.
+ build.onResolve(
+ { filter: /devup-ui(-\d+)?\.css$/ },
+ ({ path, importer }) =>
+ resolveCssId(path, importer, basename(project.distDir)),
+ )
+
+ // The bundler takes the stylesheet once every other module is loaded,
+ // so it holds the styles of all of them. The Bun runtime has no CSS
+ // loader (`onLoad` only accepts the script/data loaders), so there the
+ // injected import resolves to an empty module.
+ build.onLoad(
+ { filter: /.*/, namespace: cssNamespace },
+ async ({ defer }) => {
+ if (!bundling) return { contents: '', loader: 'js' }
+ await defer()
+ ownership.validate()
+ return { contents: getCss(null, false), loader: 'css' }
+ },
+ )
+
+ // Load source files from packages directory (file namespace)
+ build.onLoad(
+ {
+ filter: sourceFilter,
+ },
+ ({ path }) => {
+ return loadSourceFile(path, project).then((result) => {
+ if (isMdxSource(path, mdxExtensions)) ownership.loaded(path)
+ if (!bundling && result) {
+ const css = getCss(null, false)
+ // Read-after-import is synchronous; only identical revisions coalesce.
+ if (css !== writtenCss) {
+ writeFileSync(
+ join(project.cssDir, 'devup-ui.css'),
+ css,
+ 'utf-8',
+ )
+ writtenCss = css
+ }
+ }
+ return result
+ })
+ },
+ )
+ },
+ } satisfies BunPlugin
+}
diff --git a/packages/bun-plugin/src/mdx-ownership.ts b/packages/bun-plugin/src/mdx-ownership.ts
new file mode 100644
index 000000000..bfcf0e84d
--- /dev/null
+++ b/packages/bun-plugin/src/mdx-ownership.ts
@@ -0,0 +1,67 @@
+import { extname, resolve } from 'node:path'
+
+import {
+ createModuleResolver,
+ isMdxSource,
+ scanImports,
+} from '@devup-ui/plugin-utils'
+
+function key(filename: string): string {
+ const path = resolve(filename).replaceAll('\\', '/')
+ return process.platform === 'win32' ? path.toLowerCase() : path
+}
+
+export class MdxOwnershipError extends Error {
+ readonly name = 'MdxOwnershipError'
+ constructor(
+ readonly importer: string,
+ readonly target: string,
+ ) {
+ super(
+ `${importer}:1:1: owned MDX module '${target}' under extension '${extname(target)}' was loaded outside Devup UI; let Devup compile its extension, or remove that extension from mdxExtensions`,
+ )
+ }
+}
+
+export function createMdxOwnership(options: {
+ readonly root: string
+ readonly extensions: readonly string[]
+ readonly conditions: readonly string[]
+ readonly entries: readonly string[]
+}) {
+ const resolver = createModuleResolver({
+ cwd: options.root,
+ conditions: options.conditions,
+ includeMdx: options.extensions,
+ prepareSource: () => '',
+ })
+ const reached = new Map<
+ string,
+ { readonly importer: string; readonly target: string }
+ >()
+ const successful = new Set()
+ function reach(target: string, importer: string) {
+ if (isMdxSource(target, options.extensions) && !reached.has(key(target)))
+ reached.set(key(target), { target, importer })
+ }
+ for (const entry of options.entries) {
+ const target = resolve(options.root, entry)
+ reach(target, target)
+ }
+ return {
+ observe(filename: string, contents: string, typescript: boolean) {
+ for (const reference of scanImports(contents, true, typescript)) {
+ const target = resolver(reference.specifier, filename)
+ if (target?.path !== undefined) reach(target.path, filename)
+ }
+ },
+ loaded(filename: string) {
+ successful.add(key(filename))
+ },
+ validate() {
+ for (const [path, value] of reached)
+ if (!successful.has(path))
+ throw new MdxOwnershipError(value.importer, value.target)
+ },
+ }
+}
diff --git a/packages/bun-plugin/src/mdx.ts b/packages/bun-plugin/src/mdx.ts
new file mode 100644
index 000000000..015f643e6
--- /dev/null
+++ b/packages/bun-plugin/src/mdx.ts
@@ -0,0 +1,64 @@
+import { createRequire } from 'node:module'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+import { mentionsCompiledPackage } from './source'
+
+type CompiledMdx = { readonly value: string; readonly map?: unknown }
+type Compiler = {
+ readonly compile: (
+ input: { readonly value: string; readonly path: string },
+ options: { readonly jsx: true; readonly format: 'mdx' },
+ ) => Promise
+}
+
+export class MdxCompilerError extends Error {
+ constructor(filename: string, cause: unknown) {
+ super(
+ `${filename}:1:1: MDX using Devup UI requires @mdx-js/mdx; install @mdx-js/mdx in the project`,
+ { cause },
+ )
+ this.name = 'MdxCompilerError'
+ }
+}
+
+function isCompiler(value: unknown): value is Compiler {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'compile' in value &&
+ typeof value.compile === 'function'
+ )
+}
+
+export async function compileMdx(
+ root: string,
+ filename: string,
+ contents: string,
+): Promise {
+ const require = createRequire(join(root, 'package.json'))
+ let compilerPath: string
+ try {
+ compilerPath = require.resolve('@mdx-js/mdx')
+ } catch (cause) {
+ if (
+ !(cause instanceof Error) ||
+ !('code' in cause) ||
+ cause.code !== 'MODULE_NOT_FOUND'
+ )
+ throw cause
+ if (mentionsCompiledPackage(contents))
+ throw new MdxCompilerError(filename, cause)
+ return undefined
+ }
+ // Host resolution is deliberate: the optional project compiler is not bundled.
+ const compiler: unknown = await import(pathToFileURL(compilerPath).href)
+ if (!isCompiler(compiler))
+ throw new TypeError(
+ `${filename}:1:1: @mdx-js/mdx does not export compile()`,
+ )
+ return compiler.compile(
+ { value: contents, path: filename },
+ { jsx: true, format: 'mdx' },
+ )
+}
diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts
index 52591c9ee..dec7ace58 100644
--- a/packages/bun-plugin/src/plugin.ts
+++ b/packages/bun-plugin/src/plugin.ts
@@ -1,151 +1,11 @@
-import { existsSync, writeFileSync } from 'node:fs'
-import { mkdir, writeFile } from 'node:fs/promises'
-import { dirname, join, relative, resolve } from 'node:path'
-
-import {
- createCompatTypes,
- createModuleResolver,
- createThemeInterfaceArgs,
- type CustomShorthands,
- loadDevupConfig,
- mergeImportAliases,
-} from '@devup-ui/plugin-utils'
-import {
- codeExtract,
- getCss,
- getThemeInterface,
- hasDevupUI,
- registerShorthands,
- registerTheme,
- setDebug,
- setModuleResolver,
-} from '@devup-ui/wasm'
import { plugin } from 'bun'
-import { cssDirName, cssNamespace, resolveCssId } from './css-id'
-
-const libPackage = '@devup-ui/react'
-const devupFile = 'devup.json'
-const distDir = 'df'
-const cssDir = resolve(distDir, cssDirName)
-const singleCss = true
-const importAliases = mergeImportAliases()
-
-export interface DevupUIBunPluginOptions {
- shorthands?: CustomShorthands
-}
-
-async function writeDataFiles() {
- let theme = {}
- try {
- const config = await loadDevupConfig(devupFile)
- theme = config.theme ?? {}
- } catch {
- // Error reading devup.json, use empty theme
- }
- registerTheme(theme)
-
- // Generate theme interface after registration (always write, even if empty)
- await writeFile(
- join(distDir, 'theme.d.ts'),
- getThemeInterface(...createThemeInterfaceArgs(libPackage)),
- 'utf-8',
- )
-
- if (!existsSync(cssDir)) {
- await mkdir(cssDir, { recursive: true })
- }
- await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8')
-}
-
-async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) {
- registerShorthands(shorthands ?? {})
- setModuleResolver(createModuleResolver())
- if (!existsSync(distDir)) await mkdir(distDir, { recursive: true })
- await writeFile(join(distDir, '.gitignore'), '*', 'utf-8')
- await writeFile(
- join(distDir, 'compat.d.ts'),
- createCompatTypes(importAliases),
- 'utf-8',
- )
- await writeDataFiles()
-}
+import { DevupUI, type DevupUIBunPluginOptions } from './devup-plugin'
-// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a
-// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the
-// script/data loaders). The injected import exists so bundlers pick the
-// stylesheet up, so under the Bun runtime it resolves to an empty module.
-function loadCssModule() {
- return { contents: '', loader: 'js' as const }
-}
-
-async function loadSourceFile(filePath: string) {
- const loader: 'tsx' | 'ts' | 'jsx' | 'js' = filePath.endsWith('.tsx')
- ? 'tsx'
- : filePath.endsWith('.ts')
- ? 'ts'
- : filePath.endsWith('.jsx')
- ? 'jsx'
- : 'js'
- const contents = await Bun.file(filePath).text()
-
- if (hasDevupUI(filePath, contents, libPackage)) {
- const code = codeExtract(
- filePath,
- contents,
- libPackage,
- relative(dirname(filePath), cssDir).replaceAll('\\', '/'),
- singleCss,
- true,
- false,
- importAliases,
- )
- // singleCss stores every extracted style in the base sheet. Finish the
- // write before returning the injected import; synchronous writes also keep
- // concurrent source loads from overwriting a newer sheet with an older one.
- writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8')
- return { contents: code.code, loader }
- }
- return { contents, loader }
-}
-
-// Registers the Bun plugin. Returns the promise produced by `plugin()` (its
-// `setup` is async), so callers MUST `await` it. Bun's preload mechanism waits
-// for an awaited module evaluation to settle; awaiting this guarantees the
-// `onLoad` hook is installed before any source file is loaded. Without the
-// await, preload-driven `bun test` users race the async setup and load sources
-// against the @devup-ui/react runtime stubs (throwing "Cannot run on the
-// runtime").
+// Await async setup before runtime imports, including Bun preload evaluation.
function register(options: DevupUIBunPluginOptions = {}) {
- return plugin({
- name: 'devup-ui',
-
- async setup(build) {
- await initialize(options)
- setDebug(true)
-
- // Resolve devup-ui CSS files onto a path-free virtual id, so nothing
- // derived from this checkout's cwd can be baked into Bun's shared,
- // content-keyed transpiler cache. See ./css-id.
- build.onResolve(
- { filter: /devup-ui(-\d+)?\.css$/ },
- ({ path, importer }) => resolveCssId(path, importer, distDir),
- )
-
- // Serve the virtual stylesheet resolved above
- build.onLoad({ filter: /.*/, namespace: cssNamespace }, () =>
- loadCssModule(),
- )
-
- // Load source files from packages directory (file namespace)
- build.onLoad(
- {
- filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/,
- },
- ({ path }) => loadSourceFile(path),
- )
- },
- })
+ return plugin(DevupUI(options))
}
-export { plugin, register }
+export type { DevupUIBunPluginOptions } from './devup-plugin'
+export { DevupUI, plugin, register }
diff --git a/packages/bun-plugin/src/register.ts b/packages/bun-plugin/src/register.ts
index eb32ee74c..3f4240ff6 100644
--- a/packages/bun-plugin/src/register.ts
+++ b/packages/bun-plugin/src/register.ts
@@ -1,2 +1,2 @@
export type { DevupUIBunPluginOptions } from './plugin'
-export { register } from './plugin'
+export { DevupUI, register } from './plugin'
diff --git a/packages/bun-plugin/src/source-load.ts b/packages/bun-plugin/src/source-load.ts
new file mode 100644
index 000000000..7773121cc
--- /dev/null
+++ b/packages/bun-plugin/src/source-load.ts
@@ -0,0 +1,77 @@
+import { dirname, relative } from 'node:path'
+
+import {
+ type createModuleResolver,
+ isMdxSource,
+ type PreparedSource,
+ remapMdxError,
+} from '@devup-ui/plugin-utils'
+import { codeExtract, setDebug, setModuleResolver } from '@devup-ui/wasm'
+
+import { compileMdx, MdxCompilerError } from './mdx'
+import type { createMdxOwnership } from './mdx-ownership'
+import {
+ importAliases,
+ importsCompiledPackage,
+ libPackage,
+ preserveDependencies,
+ sourceLoader,
+} from './source'
+
+export interface SourceProject {
+ readonly root: string
+ readonly cssDir: string
+ readonly debug: boolean
+ readonly resolver: ReturnType
+ readonly mdxExtensions: readonly string[]
+ readonly prepared: Map
+ readonly ownership: ReturnType
+}
+
+export async function loadSourceFile(filePath: string, project: SourceProject) {
+ const original = await Bun.file(filePath).text()
+ const selectedMdx = isMdxSource(filePath, project.mdxExtensions)
+ const mdx = selectedMdx
+ ? await compileMdx(project.root, filePath, original)
+ : undefined
+ if (selectedMdx && !mdx) throw new MdxCompilerError(filePath, undefined)
+ const loader = mdx ? 'jsx' : sourceLoader(filePath)
+ const contents = mdx?.value ?? original
+ if (mdx)
+ project.prepared.set(filePath, {
+ code: contents,
+ map: mdx.map,
+ sourceType: 'compiled-mdx',
+ })
+ project.ownership.observe(
+ filePath,
+ contents,
+ !mdx && ['ts', 'tsx'].includes(loader),
+ )
+ if (importsCompiledPackage(contents, loader)) {
+ setDebug(project.debug)
+ setModuleResolver(project.resolver)
+ try {
+ const code = codeExtract(
+ filePath,
+ contents,
+ libPackage,
+ relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'),
+ true,
+ true,
+ false,
+ importAliases,
+ ...(mdx ? (['compiled-mdx'] as const) : ([] as const)),
+ )
+ return {
+ contents: preserveDependencies(code.code, filePath, code.dependencies),
+ loader,
+ }
+ } catch (cause) {
+ const error = project.resolver.remapError(cause)
+ if (mdx) throw remapMdxError(error, filePath, mdx.map)
+ throw error
+ }
+ }
+ return { contents, loader }
+}
diff --git a/packages/bun-plugin/src/source.ts b/packages/bun-plugin/src/source.ts
new file mode 100644
index 000000000..caf35e7a5
--- /dev/null
+++ b/packages/bun-plugin/src/source.ts
@@ -0,0 +1,69 @@
+import { dirname, relative } from 'node:path'
+
+import { mergeImportAliases } from '@devup-ui/plugin-utils'
+
+export type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js'
+export const importAliases = mergeImportAliases()
+export const libPackage = '@devup-ui/react'
+export const compiledPackages = [
+ libPackage,
+ '@stylexjs/stylex',
+ ...Object.keys(importAliases),
+]
+const scanners = new Map()
+
+export function sourceLoader(filename: string): SourceLoader {
+ if (/\.tsx$/i.test(filename)) return 'tsx'
+ if (/\.[mc]?ts$/i.test(filename)) return 'ts'
+ if (/\.jsx$/i.test(filename)) return 'jsx'
+ return 'js'
+}
+
+export function mentionsCompiledPackage(contents: string): boolean {
+ return compiledPackages.some((name) => contents.includes(name))
+}
+
+/** The scanner avoids extracting package names that only appear in comments. */
+export function importsCompiledPackage(
+ contents: string,
+ loader: SourceLoader,
+): boolean {
+ let scanner = scanners.get(loader)
+ if (!scanner) {
+ scanner = new Bun.Transpiler({ loader })
+ scanners.set(loader, scanner)
+ }
+ try {
+ return scanner
+ .scanImports(contents)
+ .some(({ path }) =>
+ compiledPackages.some(
+ (name) => path === name || path.startsWith(`${name}/`),
+ ),
+ )
+ } catch (cause) {
+ // Leave invalid source to Bun's own located syntax diagnostic.
+ if (cause instanceof Error) return false
+ throw cause
+ }
+}
+
+/** Keep erased build-time imports in Bun's module graph, including watch mode. */
+export function preserveDependencies(
+ code: string,
+ filename: string,
+ dependencies: readonly string[],
+): string {
+ const imports = [...new Set(dependencies)].map((dependency) => {
+ const path = relative(dirname(filename), dependency).replaceAll('\\', '/')
+ return `import ${JSON.stringify(path.startsWith('.') ? path : `./${path}`)};`
+ })
+ return [...imports, code].join('\n')
+}
+
+export function runtimeSourceFilter(files: readonly string[]): RegExp {
+ const pattern = files.length
+ ? `^(?:${files.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\\\', '[\\\\/]')).join('|')})$`
+ : '$^'
+ return new RegExp(pattern)
+}
diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts
new file mode 100644
index 000000000..421ba1d17
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts
@@ -0,0 +1,94 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { afterEach, describe, expect, it } from 'bun:test'
+
+import {
+ formatPortFile,
+ isConnectionError,
+ parsePortFile,
+ removeOwnPortFile,
+ removeStalePortFile,
+ resolveCoordinatorPortFile,
+ unreachableCoordinatorError,
+} from '../coordinator-port'
+
+const dirs: string[] = []
+function tmp(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'devup-port-'))
+ dirs.push(dir)
+ return dir
+}
+// A pid that cannot be alive: far above any OS limit.
+const DEAD_PID = 2 ** 31 - 2
+// The parent process is alive and is not this one.
+const LIVE_FOREIGN_PID = process.ppid
+
+afterEach(() => {
+ for (const dir of dirs.splice(0))
+ rmSync(dir, { recursive: true, force: true })
+})
+
+describe('coordinator port file', () => {
+ it('keeps the port on the first line so older readers still parse it', () => {
+ const text = formatPortFile(4321, 99)
+ expect(Number.parseInt(text, 10)).toBe(4321)
+ expect(parsePortFile(text)).toEqual({ port: 4321, pid: 99 })
+ })
+
+ it('reads a bare port written by an older release', () => {
+ expect(parsePortFile('4321\n')).toEqual({ port: 4321, pid: undefined })
+ })
+
+ it('uses the shared name unless another live process owns it', () => {
+ const dir = tmp()
+ const shared = join(dir, 'coordinator.port')
+ expect(resolveCoordinatorPortFile(dir)).toBe(shared)
+
+ writeFileSync(shared, formatPortFile(1, DEAD_PID))
+ expect(resolveCoordinatorPortFile(dir)).toBe(shared)
+
+ writeFileSync(shared, formatPortFile(1, process.pid))
+ expect(resolveCoordinatorPortFile(dir)).toBe(shared)
+
+ writeFileSync(shared, formatPortFile(1, LIVE_FOREIGN_PID))
+ expect(resolveCoordinatorPortFile(dir)).toBe(
+ join(dir, `coordinator.${process.pid}.port`),
+ )
+ })
+
+ it('never deletes a live foreign owner file, but clears a dead one', () => {
+ const dir = tmp()
+ const file = join(dir, 'coordinator.port')
+ writeFileSync(file, formatPortFile(1, LIVE_FOREIGN_PID))
+ removeStalePortFile(file)
+ removeOwnPortFile(file)
+ expect(Bun.file(file).size).toBeGreaterThan(0)
+
+ writeFileSync(file, formatPortFile(1, DEAD_PID))
+ removeStalePortFile(file)
+ expect(Bun.file(file).size).toBe(0)
+ })
+
+ it('names port, owner pid, state and recovery when the coordinator is unreachable', () => {
+ const dir = tmp()
+ const file = join(dir, 'coordinator.port')
+ writeFileSync(file, formatPortFile(4321, DEAD_PID))
+ const message = unreachableCoordinatorError(
+ file,
+ Object.assign(new Error('connect ETIMEDOUT 127.0.0.1:4321'), {
+ code: 'ETIMEDOUT',
+ }),
+ ).message
+ expect(message).toContain('127.0.0.1:4321')
+ expect(message).toContain(`owner pid ${DEAD_PID} (not running)`)
+ expect(message).toContain('restart the dev server or build')
+ })
+
+ it('recognises connection failures, including aggregated ones', () => {
+ expect(isConnectionError({ code: 'ECONNREFUSED' })).toBe(true)
+ expect(isConnectionError({ errors: [{ code: 'ETIMEDOUT' }] })).toBe(true)
+ expect(isConnectionError(new Error('boom'))).toBe(false)
+ })
+})
diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts
index 1ff6f7187..af08e5a64 100644
--- a/packages/next-plugin/src/__tests__/css-loader.test.ts
+++ b/packages/next-plugin/src/__tests__/css-loader.test.ts
@@ -358,7 +358,10 @@ describe('devupUICssLoader', () => {
} as unknown as CssLoaderThis)(Buffer.from(''), '', '')
})
- expect(error.message).toBe('Coordinator port file not found')
+ expect(error.message).toContain(
+ 'Coordinator port file not found: nonexistent.port',
+ )
+ expect(error.message).toContain('Restart the dev server or build')
})
it('should error when coordinator returns non-200 status', async () => {
diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts
new file mode 100644
index 000000000..b18a334c8
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts
@@ -0,0 +1,145 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, relative } from 'node:path'
+
+import { collectNumberedFiles, seedFileNumbers } from '@devup-ui/plugin-utils'
+import * as wasm from '@devup-ui/wasm'
+import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
+
+const sources: Record = {
+ 'src/a.tsx': `import { Box } from '@devup-ui/react'\nexport const A = () => `,
+ 'src/b.tsx': `import { Box } from '@devup-ui/react'\nexport const B = () => `,
+ 'src/nested/c.tsx': `import { Box } from '@devup-ui/react'\nexport const C = () => `,
+ 'src/nested/d.tsx': `import { Box, Flex } from '@devup-ui/react'\nexport const D = () => `,
+ 'node_modules/@acme/ui/e.tsx': `import { Box } from '@devup-ui/react'\nexport const E = () => `,
+}
+
+/** How each plugin names the files it extracts */
+const schemes: Record (path: string) => string> = {
+ 'vite (absolute, posix)': () => (path) => path.replaceAll('\\', '/'),
+ 'webpack and next (relative to cwd)': (root) => (path) =>
+ relative(root, path).replaceAll('\\', '/'),
+ 'rsbuild and bun (absolute, as found)': () => (path) => path,
+}
+
+describe('class and file numbers do not depend on the order files are seen', () => {
+ let root: string
+ const original = process.cwd()
+
+ beforeAll(() => {
+ root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-order-'))
+ for (const [path, code] of Object.entries(sources)) {
+ mkdirSync(dirname(join(root, path)), { recursive: true })
+ writeFileSync(join(root, path), code)
+ }
+ process.chdir(root)
+ })
+
+ afterAll(() => {
+ wasm.resetBuildState()
+ process.chdir(original)
+ rmSync(root, { recursive: true, force: true })
+ })
+
+ function build(
+ scheme: (path: string) => string,
+ order: string[],
+ parallel: boolean,
+ ) {
+ wasm.resetBuildState()
+ seedFileNumbers(
+ wasm,
+ collectNumberedFiles({
+ roots: [join(root, 'src')],
+ include: ['@acme/ui'],
+ needles: ['@devup-ui/react'],
+ cwd: root,
+ toId: scheme,
+ }),
+ )
+ const extract = (file: string) => {
+ const output = wasm.codeExtractWithoutSourceMap(
+ scheme(join(root, file)),
+ readFileSync(join(root, file), 'utf-8'),
+ '@devup-ui/react',
+ '../css',
+ false,
+ false,
+ true,
+ {},
+ )
+ return { file, code: output.code, cssFile: output.cssFile }
+ }
+ const outputs = parallel
+ ? Promise.all(order.map(async (file) => extract(file)))
+ : Promise.resolve(order.map(extract))
+ return outputs.then((results) => {
+ const byFile = Object.fromEntries(
+ results
+ .sort((a, b) => a.file.localeCompare(b.file))
+ .map((r) => [r.file, r]),
+ )
+ const fileMap = JSON.parse(wasm.exportFileMap()) as Record
+ const sheets = Object.values(fileMap)
+ .sort((a, b) => a - b)
+ .map((number) => wasm.getCss(number, false))
+ return {
+ byFile,
+ fileMap: wasm.exportFileMap(),
+ classMap: wasm.exportClassMap(),
+ sheets,
+ base: wasm.getCss(null, false),
+ }
+ })
+ }
+
+ const files = Object.keys(sources)
+ const orders = [
+ files,
+ [...files].reverse(),
+ [files[2], files[4], files[0], files[3], files[1]],
+ ]
+
+ it.each(Object.entries(schemes))(
+ 'gives the same output for any order, one at a time or at once: %s',
+ async (_name, makeScheme) => {
+ const scheme = makeScheme(root)
+ const results = []
+ for (const order of orders) {
+ results.push(await build(scheme, order, false))
+ results.push(await build(scheme, order, true))
+ }
+ for (const result of results.slice(1)) {
+ expect(result.fileMap).toBe(results[0].fileMap)
+ expect(result.byFile).toEqual(results[0].byFile)
+ expect(result.sheets).toEqual(results[0].sheets)
+ expect(result.base).toBe(results[0].base)
+ }
+ const numbers = Object.values(
+ JSON.parse(results[0].fileMap) as Record,
+ ).sort((a, b) => a - b)
+ expect(numbers).toEqual([0, 1, 2, 3, 4])
+ },
+ )
+
+ it('numbers the files in path order, and files that appear later after them', () => {
+ wasm.resetBuildState()
+ seedFileNumbers(wasm, ['b.tsx', 'a.tsx'])
+ seedFileNumbers(wasm, ['0.tsx', 'a.tsx', 'c.tsx'])
+ expect(JSON.parse(wasm.exportFileMap())).toEqual({
+ 'a.tsx': 0,
+ 'b.tsx': 1,
+ '0.tsx': 2,
+ 'c.tsx': 3,
+ })
+ expect(wasm.exportFileMap()).toBe(
+ '{"0.tsx":2,"a.tsx":0,"b.tsx":1,"c.tsx":3}',
+ )
+ })
+})
diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts
index 3f4df0644..3ac31da70 100644
--- a/packages/next-plugin/src/__tests__/loader.test.ts
+++ b/packages/next-plugin/src/__tests__/loader.test.ts
@@ -603,6 +603,67 @@ describe('devupUILoader', () => {
requestSpy.mockRestore()
})
+ it('should report a coordinator that no longer answers', async () => {
+ existsSyncSpy.mockReturnValue(true)
+ readFileSyncSpy.mockReturnValue('12346')
+
+ const requestSpy = spyOn(http, 'request').mockImplementation(
+ (_options: any, _callback?: any) => {
+ const fakeReq = {
+ on: mock((event: string, handler: (...args: unknown[]) => void) => {
+ if (event === 'error') {
+ setTimeout(
+ () =>
+ handler(
+ Object.assign(new Error('refused'), {
+ code: 'ECONNREFUSED',
+ }),
+ ),
+ 0,
+ )
+ }
+ return fakeReq
+ }),
+ write: mock(),
+ end: mock(),
+ }
+ return asClientRequest(fakeReq)
+ },
+ )
+
+ const asyncCallback = mock()
+ const t = {
+ getOptions: () => ({
+ package: 'package',
+ cssDir: 'cssDir',
+ sheetFile: 'sheetFile',
+ classMapFile: 'classMapFile',
+ fileMapFile: 'fileMapFile',
+ themeFile: 'themeFile',
+ watch: true,
+ singleCss: true,
+ coordinatorPortFile: 'coordinator.port',
+ }),
+ async: mock().mockReturnValue(asyncCallback),
+ resourcePath: join(process.cwd(), 'src', 'App.tsx'),
+ addDependency: mock(),
+ }
+
+ devupUILoader.bind(asLoaderContext(t))(
+ Buffer.from('source code'),
+ 'src/App.tsx',
+ )
+
+ await waitFor(() => {
+ expect(asyncCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining('coordinator.port'),
+ }),
+ )
+ })
+
+ requestSpy.mockRestore()
+ })
it('should handle coordinator non-200 response', async () => {
existsSyncSpy.mockReturnValue(true)
readFileSyncSpy.mockReturnValue('12345')
@@ -856,7 +917,11 @@ describe('devupUILoader', () => {
// Retries 20 times × 50ms = 1s max, then calls back with error
await waitFor(() => {
expect(asyncCallback).toHaveBeenCalledWith(
- new Error('Coordinator port file not found'),
+ expect.objectContaining({
+ message: expect.stringContaining(
+ 'Coordinator port file not found: nonexistent.port',
+ ),
+ }),
)
}, 3000)
diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts
index 8bc599b7b..cc59a7688 100644
--- a/packages/next-plugin/src/__tests__/plugin.test.ts
+++ b/packages/next-plugin/src/__tests__/plugin.test.ts
@@ -415,13 +415,9 @@ describe('DevupUINextPlugin', () => {
],
condition: {
not: {
- path: new RegExp(
- `(node_modules(?!.*(${['@devup-ui', '@devup-editor']
- .join('|')
- .replaceAll(
- '/',
- '[\\/\\\\_]',
- )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`,
+ path: importGraphModule.createNodeModulesExcludeRegex(
+ [],
+ '.mdx.[tj]sx?$',
),
},
},
@@ -467,13 +463,9 @@ describe('DevupUINextPlugin', () => {
'*.{tsx,ts,jsx,js,mjs}': {
condition: {
not: {
- path: new RegExp(
- `(node_modules(?!.*(${['@devup-ui', '@devup-editor']
- .join('|')
- .replaceAll(
- '/',
- '[\\/\\\\_]',
- )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`,
+ path: importGraphModule.createNodeModulesExcludeRegex(
+ [],
+ '.mdx.[tj]sx?$',
),
},
},
@@ -528,7 +520,15 @@ describe('DevupUINextPlugin', () => {
readFileSyncSpy.mockReturnValue(JSON.stringify({ theme: 'theme' }))
mkdirSyncSpy.mockReturnValue('')
writeFileSyncSpy.mockReturnValue(undefined)
- const ret = DevupUI({})
+ const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((path) =>
+ resolve(String(path)),
+ )
+ let ret: ReturnType
+ try {
+ ret = DevupUI({})
+ } finally {
+ realpathSpy.mockRestore()
+ }
expect(ret).toEqual({
turbopack: {
@@ -560,13 +560,9 @@ describe('DevupUINextPlugin', () => {
'*.{tsx,ts,jsx,js,mjs}': {
condition: {
not: {
- path: new RegExp(
- `(node_modules(?!.*(${['@devup-ui', '@devup-editor']
- .join('|')
- .replaceAll(
- '/',
- '[\\/\\\\_]',
- )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`,
+ path: importGraphModule.createNodeModulesExcludeRegex(
+ [],
+ '.mdx.[tj]sx?$',
),
},
},
@@ -784,6 +780,32 @@ export const box = style({ color: 'red' })`
DevupUI({}, { prefix: 'my-prefix' })
expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix')
})
+ it('numbers the files the scan finds, and survives a failing scan', () => {
+ process.env.TURBOPACK = '1'
+ const collectSpy = spyOn(importGraphModule, 'collectNumberedFiles')
+ const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined)
+ try {
+ collectSpy.mockImplementation((options: any) => {
+ expect(options.roots).toEqual(
+ ['src', 'app', 'pages'].map((dir) => resolve(dir)),
+ )
+ expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx')
+ return ['src/a.tsx']
+ })
+ DevupUI({}, { include: ['@acme/ui'] })
+ expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx'])
+ expect(collectSpy.mock.calls[0][0]).toMatchObject({
+ include: ['@acme/ui'],
+ })
+ collectSpy.mockImplementation(() => {
+ throw new Error('scan boom')
+ })
+ DevupUI({}, {})
+ } finally {
+ collectSpy.mockRestore()
+ seedSpy.mockRestore()
+ }
+ })
it('should import previous session state on restart', () => {
process.env.TURBOPACK = '1'
existsSyncSpy
diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts
new file mode 100644
index 000000000..1335a4c36
--- /dev/null
+++ b/packages/next-plugin/src/coordinator-port.ts
@@ -0,0 +1,133 @@
+import { existsSync, readFileSync, unlinkSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * What a coordinator writes to its port file: the port on the first line (all
+ * an older reader looks at, via `parseInt`) and the owning pid on the second.
+ */
+export interface CoordinatorPortInfo {
+ port: number
+ /** Process that owns the coordinator, when the file says. */
+ pid?: number
+}
+
+export function formatPortFile(port: number, pid = process.pid): string {
+ return `${port}\n${pid}`
+}
+
+export function parsePortFile(text: string): CoordinatorPortInfo {
+ const [portLine = '', pidLine = ''] = text.trim().split(/\r?\n/)
+ const port = Number.parseInt(portLine.trim(), 10)
+ const pid = Number.parseInt(pidLine.trim(), 10)
+ return { port, pid: Number.isInteger(pid) && pid > 0 ? pid : undefined }
+}
+
+/** Whether a process exists. EPERM means it exists but is not ours. */
+export function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
+ }
+}
+
+function readOwner(portFile: string): CoordinatorPortInfo | undefined {
+ try {
+ return parsePortFile(readFileSync(portFile, 'utf-8'))
+ } catch {
+ return undefined
+ }
+}
+
+/** A live coordinator owned by some other process, if the file names one. */
+export function foreignLiveOwner(portFile: string): number | undefined {
+ const pid = readOwner(portFile)?.pid
+ return pid !== undefined && pid !== process.pid && isProcessAlive(pid)
+ ? pid
+ : undefined
+}
+
+/**
+ * The port file this process should use in `distDir`.
+ *
+ * `next dev` and `next build` in one directory used to share one file: the
+ * second one deleted the first one's port and overwrote it, and the first's
+ * loaders then talked to a dead port or the wrong coordinator. The shared name
+ * is kept when nobody else holds it (so a cache keyed on loader options stays
+ * stable in the common case); when a different live process owns it, this
+ * process gets its own `coordinator..port`.
+ */
+export function resolveCoordinatorPortFile(distDir: string): string {
+ const shared = join(distDir, 'coordinator.port')
+ return foreignLiveOwner(shared) === undefined
+ ? shared
+ : join(distDir, `coordinator.${process.pid}.port`)
+}
+
+/** Remove the file unless a different live process owns it. */
+export function removeStalePortFile(portFile: string): void {
+ if (foreignLiveOwner(portFile) !== undefined) return
+ try {
+ unlinkSync(portFile)
+ } catch {
+ // Nothing to remove (first run).
+ }
+}
+
+/** Remove the file only if this process wrote it. */
+export function removeOwnPortFile(portFile: string): void {
+ const owner = readOwner(portFile)
+ if (owner?.pid !== undefined && owner.pid !== process.pid) return
+ try {
+ unlinkSync(portFile)
+ } catch {
+ // Already gone.
+ }
+}
+
+const CONNECTION_CODES = new Set([
+ 'ECONNREFUSED',
+ 'ECONNRESET',
+ 'ETIMEDOUT',
+ 'EPIPE',
+ 'EHOSTUNREACH',
+])
+
+export function isConnectionError(error: unknown): boolean {
+ const code = (error as NodeJS.ErrnoException | undefined)?.code
+ if (code !== undefined && CONNECTION_CODES.has(code)) return true
+ // An AggregateError from `localhost` resolution carries its codes inside.
+ const inner = (error as { errors?: unknown[] } | undefined)?.errors
+ return Array.isArray(inner) && inner.some(isConnectionError)
+}
+
+/** The port file is missing: say whose it should be and how to recover. */
+export function missingPortFileError(portFile: string): Error {
+ return new Error(
+ `Coordinator port file not found: ${portFile}. ` +
+ 'The devup-ui coordinator for this build is not running (or exited). ' +
+ 'Restart the dev server or build; if another `next dev`/`next build` ' +
+ 'uses the same distDir, stop it or give this one its own distDir.',
+ )
+}
+
+/** A coordinator that was running is not answering. */
+export function unreachableCoordinatorError(
+ portFile: string,
+ cause: unknown,
+): Error {
+ const info = existsSync(portFile) ? readOwner(portFile) : undefined
+ const owner =
+ info?.pid === undefined
+ ? 'unknown owner'
+ : `owner pid ${info.pid} (${isProcessAlive(info.pid) ? 'running' : 'not running'})`
+ const detail = cause instanceof Error ? cause.message : String(cause)
+ const next =
+ info?.pid !== undefined && !isProcessAlive(info.pid)
+ ? 'The coordinator process has exited: restart the dev server or build.'
+ : 'Another process may have replaced the coordinator: restart the dev server or build, and avoid running `next dev` and `next build` in one distDir at the same time.'
+ return new Error(
+ `Coordinator unreachable at 127.0.0.1:${info?.port ?? '?'} (${owner}, port file ${portFile}): ${detail}. ${next}`,
+ )
+}
diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts
index 17b9c7f54..892f8287d 100644
--- a/packages/next-plugin/src/coordinator.ts
+++ b/packages/next-plugin/src/coordinator.ts
@@ -1,9 +1,10 @@
-import { unlinkSync, writeFile, writeFileSync } from 'node:fs'
+import { writeFile, writeFileSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server } from 'node:http'
import { basename, dirname, join, relative } from 'node:path'
import { getFileNumByFilename } from '@devup-ui/plugin-utils'
+import { formatPortFile, removeOwnPortFile } from './coordinator-port'
import { elapsedMs, profileStart, reportProfile } from './profile'
import type { DevupWasm } from './wasm'
@@ -666,7 +667,7 @@ export function startCoordinator(options: CoordinatorOptions): {
coordinatorServer.listen(0, '127.0.0.1', () => {
const addr = coordinatorServer.address()
if (addr && typeof addr !== 'string') {
- writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8')
+ writeFileSync(coordinatorPortFile, formatPortFile(addr.port), 'utf-8')
}
})
@@ -680,11 +681,7 @@ export function startCoordinator(options: CoordinatorOptions): {
coordinatorServer.close()
if (server === coordinatorServer) {
server = null
- try {
- unlinkSync(coordinatorPortFile)
- } catch {
- // ignore if already deleted
- }
+ removeOwnPortFile(coordinatorPortFile)
}
},
}
diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts
index 1b37059e8..b74038ca3 100644
--- a/packages/next-plugin/src/css-loader.ts
+++ b/packages/next-plugin/src/css-loader.ts
@@ -4,6 +4,12 @@ import { Agent, request } from 'node:http'
import { getFileNumByFilename } from '@devup-ui/plugin-utils'
import type { RawLoaderDefinitionFunction } from 'webpack'
+import {
+ isConnectionError,
+ missingPortFileError,
+ parsePortFile,
+ unreachableCoordinatorError,
+} from './coordinator-port'
import { loadWasm } from './wasm'
export interface DevupUICssLoaderOptions {
@@ -26,7 +32,7 @@ const keepAliveAgent = new Agent({ keepAlive: true })
function readCoordinatorPort(portFile: string): number {
if (cachedPort !== null) return cachedPort
- cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim())
+ cachedPort = parsePortFile(readFileSync(portFile, 'utf-8')).port
return cachedPort
}
@@ -96,7 +102,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction =
setTimeout(() => tryFetch(retries - 1), 50)
return
}
- callback(new Error('Coordinator port file not found'))
+ callback(missingPortFileError(coordinatorPortFile))
return
}
try {
@@ -107,7 +113,17 @@ const devupUICssLoader: RawLoaderDefinitionFunction =
importMainCss,
!watch,
(err, css) => {
- if (err) return callback(err)
+ if (err) {
+ if (isConnectionError(err)) {
+ // The port may belong to a coordinator that has since been
+ // replaced; forget it so the next call re-reads the file.
+ cachedPort = null
+ return callback(
+ unreachableCoordinatorError(coordinatorPortFile, err),
+ )
+ }
+ return callback(err)
+ }
callback(null, css)
},
)
diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts
index e11c789b4..4fd488d00 100644
--- a/packages/next-plugin/src/loader.ts
+++ b/packages/next-plugin/src/loader.ts
@@ -3,10 +3,21 @@ import { writeFile } from 'node:fs/promises'
import { Agent, request } from 'node:http'
import { basename, dirname, join, relative, resolve } from 'node:path'
+import { createStateWriter } from '@devup-ui/plugin-utils'
import type { RawLoaderDefinitionFunction } from 'webpack'
+import {
+ isConnectionError,
+ missingPortFileError,
+ parsePortFile,
+ unreachableCoordinatorError,
+} from './coordinator-port'
import { loadWasm } from './wasm'
+const stateWriter = createStateWriter((path, content, encoding) =>
+ encoding ? writeFile(path, content, encoding) : writeFile(path, content),
+)
+
export interface DevupUILoaderOptions {
package: string
cssDir: string
@@ -44,7 +55,7 @@ function readCoordinatorPort(portFile: string): number {
const cachedPort = cachedPorts.get(portFile)
if (cachedPort !== undefined) return cachedPort
- const port = Number.parseInt(readFileSync(portFile, 'utf-8').trim(), 10)
+ const port = parsePortFile(readFileSync(portFile, 'utf-8')).port
cachedPorts.set(portFile, port)
return port
}
@@ -153,7 +164,7 @@ const devupUILoader: RawLoaderDefinitionFunction =
return
}
// Port file never appeared — fall through to error
- callback(new Error('Coordinator port file not found'))
+ callback(missingPortFileError(coordinatorPortFile))
return
}
try {
@@ -174,7 +185,17 @@ const devupUILoader: RawLoaderDefinitionFunction =
port,
body,
(err, content, sourceMap, dependencies = []) => {
- if (err) return callback(err)
+ if (err) {
+ if (isConnectionError(err)) {
+ // Forget a port that no longer answers so the next file
+ // re-reads the port file instead of repeating the timeout.
+ cachedPorts.delete(coordinatorPortFile)
+ return callback(
+ unreachableCoordinatorError(coordinatorPortFile, err),
+ )
+ }
+ return callback(err)
+ }
for (const dependency of dependencies) {
this.addDependency(resolve(dependency))
}
@@ -266,19 +287,23 @@ const devupUILoader: RawLoaderDefinitionFunction =
if (updatedBaseStyle && watch) {
// update base style
promises.push(
- writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'),
+ stateWriter.write(
+ join(cssDir, 'devup-ui.css'),
+ getCss(null, false),
+ 'utf-8',
+ ),
)
}
if (cssFile && watch) {
// don't write file when build
promises.push(
- writeFile(
+ stateWriter.write(
join(cssDir, basename(cssFile)),
`/* ${this.resourcePath} ${Date.now()} */`,
),
- writeFile(sheetFile, exportSheet()),
- writeFile(classMapFile, exportClassMap()),
- writeFile(fileMapFile, exportFileMap()),
+ stateWriter.write(sheetFile, exportSheet()),
+ stateWriter.write(classMapFile, exportClassMap()),
+ stateWriter.write(fileMapFile, exportFileMap()),
)
}
Promise.all(promises).then(
diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts
index 7905cc7d0..542c00e74 100644
--- a/packages/next-plugin/src/plugin.ts
+++ b/packages/next-plugin/src/plugin.ts
@@ -11,15 +11,18 @@ import { deserialize, serialize } from 'node:v8'
import {
buildCanonicalMap,
buildStaticImportGraph,
+ collectNumberedFiles,
computeCompiledFiles,
computeFileRoutes,
createCompatTypes,
createNodeModulesExcludeRegex,
createThemeInterfaceArgs,
type DevupUIBasePluginOptions,
+ extractedNeedles,
loadDevupConfigSync,
mergeImportAliases,
planAtomHoist,
+ seedFileNumbers,
type StaticImportGraph,
} from '@devup-ui/plugin-utils'
import { type NextConfig } from 'next'
@@ -29,6 +32,10 @@ import {
startCoordinator,
takeExtractOutput,
} from './coordinator'
+import {
+ removeStalePortFile,
+ resolveCoordinatorPortFile,
+} from './coordinator-port'
import { collectProductionPrewarmFiles } from './prewarm'
import { elapsedMs, profileStart, reportProfile } from './profile'
import { loadWasm, loadWebpackPlugin } from './wasm'
@@ -229,9 +236,7 @@ export function DevupUI(
registerShorthands(shorthands ?? {})
- if (prefix) {
- setPrefix(prefix)
- }
+ setPrefix(prefix ?? null)
writeFileSync(
join(distDir, 'compat.d.ts'),
@@ -246,7 +251,7 @@ export function DevupUI(
importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8')))
importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8')))
} catch {
- // No previous session state (first run) or corrupt files — start fresh
+ // No previous session state (first run) or corrupt files, start fresh
}
const devupConfig = loadDevupConfigSync(devupFile)
@@ -264,7 +269,7 @@ export function DevupUI(
// disable turbo parallel
const excludeRegex = createNodeModulesExcludeRegex(include, '.mdx.[tj]sx?$')
- const coordinatorPortFile = join(distDir, 'coordinator.port')
+ const coordinatorPortFile = resolveCoordinatorPortFile(distDir)
// Pre-pass: single-importer collapse ALWAYS runs (files with exactly one
// importer merge into that importer's bucket, so their identical atoms share
@@ -358,6 +363,24 @@ export function DevupUI(
})
}
+ // Number every file the build can extract in path order, so class prefixes
+ // do not depend on the order modules reach a loader. Numbers restored above
+ // stay; files that appear later get the numbers after them.
+ try {
+ const cwd = process.cwd()
+ seedFileNumbers(
+ { seedFileMap: wasm.seedFileMap },
+ collectNumberedFiles({
+ roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)),
+ include,
+ cwd,
+ needles: extractedNeedles(libPackage, importAliases),
+ toId: (path) => relative(cwd, path).replaceAll('\\', '/'),
+ }),
+ )
+ } catch {
+ // Best-effort; numbering falls back to arrival order.
+ }
// Turbopack can request a CSS module before it has scheduled every source
// loader. Waiting for a quiet window is not a compilation-complete signal:
// a CSS request can itself hold up the next extraction wave. In one-shot
@@ -462,11 +485,9 @@ export function DevupUI(
// Delete stale port file from previous session so loaders don't connect
// to a dead coordinator port. The new coordinator writes a fresh port file
// once it starts listening.
- try {
- unlinkSync(coordinatorPortFile)
- } catch {
- // Port file doesn't exist (first run) — safe to ignore
- }
+ // A live coordinator owned by another process is left alone (see
+ // resolveCoordinatorPortFile).
+ removeStalePortFile(coordinatorPortFile)
const coordinator = startCoordinator({
wasm,
diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md
new file mode 100644
index 000000000..41f74ba01
--- /dev/null
+++ b/packages/plugin-utils/README.md
@@ -0,0 +1,262 @@
+# @devup-ui/plugin-utils
+
+Shared build-time utilities for Devup UI plugins. These utilities do not execute
+modules or add styling JavaScript to application output.
+
+## Source selection and exclusions
+
+`listSourceFiles`, `buildStaticImportGraph`, and `collectNumberedFiles` all default
+to JavaScript/TypeScript only (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`,
+`.cjs`). Markdown is **off by default everywhere**.
+
+Their `includeMdx` option has type `boolean | readonly string[]`:
+
+- Omitted, `false`, or `[]`: no extra extensions.
+- `true`: exactly `.mdx`, not `.md`.
+- A list: exactly the supplied literal extensions, such as `['.md', '.mdx']`
+ or `['.mdown']`. Use normalized extension strings with their leading dot;
+ matching is case-insensitive. No extension patterns or regular expressions.
+
+```ts
+import {
+ buildStaticImportGraph,
+ collectNumberedFiles,
+ listSourceFiles,
+} from '@devup-ui/plugin-utils'
+
+const js = listSourceFiles('/project/src', ['generated'])
+const docs = listSourceFiles('/project/src', ['generated'], {
+ includeMdx: ['.md', '.mdx', '.mdown'],
+})
+const numbered = collectNumberedFiles({
+ roots: ['/project/src'],
+ includeMdx: ['.mdown'],
+ exclude: ['/project/build/next', 'generated'],
+ needles: ['@devup-ui/react'],
+})
+```
+
+The two-argument scanner call retains its directory-exclusion argument; use the
+third options argument to opt into Markdown. All three APIs use one exclusion
+matcher: a bare directory name matches at any depth; an absolute directory path
+matches only that directory and its descendants, not same-basename folders
+elsewhere. Windows matching normalizes separators, drive letters and path case;
+POSIX matching preserves case. Excluded roots and imported targets are skipped
+before reading source, preparation or numbering needle checks. `node_modules`
+and test/spec files remain absent from directory enumeration.
+
+Opt-in extensions also participate in extensionless graph resolution and route
+recognition. Explicit existing file paths still resolve with
+`createModuleResolver` without opting into their extension, but Markdown must be
+prepared before its source can be returned. Numbering forwards
+selection and exclusions to the scanner rather than reading then filtering.
+The public module resolver retains its legacy extensionless `.mdx` probing;
+the graph's path-only resolver probes extra extensions only when selected.
+
+## Synchronous and prepared-source graphs
+
+Without a `prepareSource` field, `buildStaticImportGraph` remains synchronous and
+returns `StaticImportGraph`. Existing synchronous callers do not receive a
+`StaticImportGraph | Promise` union.
+
+```ts
+const graph = buildStaticImportGraph('src', undefined, { cwd: '/project' })
+
+const prepared = await buildStaticImportGraph('src', undefined, {
+ cwd: '/project',
+ includeMdx: ['.mdx', '.mdown'],
+ alias: { 'mdx-provider$': '/project/mdx-components.js' },
+ prepareSource: async (filename) => {
+ if (!filename.endsWith('.mdx') && !filename.endsWith('.mdown')) return undefined
+ const compiled = await compileProjectMarkdown(filename)
+ return {
+ code: String(compiled.value),
+ map: compiled.map,
+ sourceType: 'compiled-mdx',
+ }
+ },
+})
+```
+
+Supplying **any** preparer, including a synchronous callback, returns
+`Promise`. The exported `PrepareSource` receives each visited
+real absolute filename once, including discovered providers and eligible
+included-package dependencies. It returns a string, `{ code: string, map?:
+unknown }`, `undefined`, or a Promise of those values. Only `undefined` reads raw
+source; `''` is valid prepared code. Relative imports, keys and diagnostics keep
+the actual filename. Prepared code bypasses the raw Markdown ESM-block filter,
+so compiler-injected provider/remark/rehype imports can be discovered. Configure
+the project compiler to interpret custom extensions as MDX where required.
+
+Prepared objects and resolved modules accept `sourceType?: 'compiled-mdx'`.
+This explicitly selects JavaScript with JSX, including under `.mdown` or a TS
+filename; ordinary filename-derived JS/TS grammar is unchanged when omitted.
+The extraction functions receive this value as their optional ninth argument.
+`.md` and `.mdx` retain their compiled-MDX extractor defaults; a custom extraction
+extension without an explicit type is a located error, never a renamed-file
+workaround. Invalid values or throwing type getters fail at the preparation
+boundary with the real filename/importer and cause. The field follows prepared
+resolver generations into WASM, including imported constants after JSX and
+reexports, and caches include the selected type.
+
+The graph is an **import scanner, not a full syntax validator**. The caller's
+compiler and bundler own validation of the same compiled output. No parser
+dependency is installed by this package. **Every edge always comes from one
+dependency-free scanner.** If optional `oxc-parser` is available, it supplies
+syntax diagnostics only: its AST and `program` getter never determine edges.
+Parser exceptions/diagnostics fail rather than falling back to raw source or
+changing edge authority. Compiler failures retain the real filename and
+original cause; setup/hook failures reject the Promise. Parser positions are
+remapped using `remapMdxError` when a map is supplied; unmapped positions are
+explicitly labeled `in compiled output`, not presented as Markdown coordinates.
+
+The lexer keeps real filename language context: comparisons/shifts are not JSX,
+TSX generic arrows are not tags, and type-only imports are not runtime edges.
+Prepared ordinary TS retains its TS grammar; explicit compiled-MDX output uses
+JS/JSX grammar even under a TS filename. The lexer masks ordinary strings, outer
+template text, comments, regular
+expressions and JSX text/quoted attributes. Template `${...}` expressions,
+including nested templates, and JSX brace/attribute expressions are real code:
+literal `require('...')` adds static edges and literal `import('...')` adds dynamic
+edges there just as in ordinary code. Normal imports, side-effect imports and
+export-from declarations add static edges; type-only declarations/specifiers are
+elided without dropping value bindings named `type`. Escaped specifiers are
+decoded without evaluating source. Nonliteral/computed or concatenated arguments
+such as `import(name)`, `require(name)` and `import('./' + name)` add no edge.
+The scanner is not a full parser; builders retain fail-closed checks for modules
+the bundler actually loads but the static graph misses. Raw opted-in Markdown
+scans only ESM blocks, not code fences or prose.
+
+The checked-in differential gate is runnable from the repository root with
+`bun test ./packages/plugin-utils/src/__tests__/parser-corpus.test.ts` (and runs
+in the root suite). It compares complete graphs for every package `src` root and
+`apps/landing/src`, including real landing docs compiled once using the project's
+MDX compiler/provider options, with diagnostics disabled and injected. It asserts
+one diagnostic invocation per source and zero AST getter reads; independent
+targeted fixtures also assert expected literal edges and reject contradictory AST
+dependencies. The optional Oxc package is not installed by this gate.
+
+## Synchronous prepared module resolver
+
+`createModuleResolver({ prepareSource })` retains its synchronous callable shape
+for `setModuleResolver` and also exposes `resolver.remapError(error)`. The hook
+receives the real absolute filename after native/alias resolution and before
+`toId`; it returns the same string, `{ code, map? }`, or `undefined` values as the
+graph hook. Empty code is valid. Ordinary JS/TS with `undefined` still reads raw
+source. The resolver never awaits: **any thenable is rejected immediately**.
+Async compilers must finish preparation into a caller-owned cache first, then
+pass the same synchronous lookup hook to both graph and resolver.
+
+With or without a hook, `.mdx`, `.md`, and explicit `includeMdx` extensions cannot
+reach WASM without prepared JavaScript. Missing preparation, thenables and hook
+exceptions fail at the actual importer `:1:1` (the callback has no import span),
+name the actual module and preserve the cause. Move imported values into JS/TS
+when Markdown compilation is not available; this API does not add MDX support
+to plugins that do not compile it.
+
+Callers supplying `prepareSource` **must route every error thrown by
+`codeExtract` through `resolver.remapError`**, not just callback errors:
+
+```ts
+const prepareSource = (filename: string) => compiledCache.get(filename)
+const resolver = createModuleResolver({ cwd, toId, prepareSource })
+setModuleResolver(resolver)
+try {
+ return codeExtract(filename, source, /* existing extraction arguments */)
+} catch (error) {
+ throw resolver.remapError(error)
+}
+```
+
+Mappings are resolver-instance-local, keyed by the exact id returned to WASM.
+Each resolved generation replaces its prior code/map; ordinary raw JS/TS removes
+its stale map. Keep the resolver for its build/compiler context, replace cache
+entries when recompiling, and discard it when that context ends. Vite and Bun
+create build-context resolvers; Webpack creates compiler/loader-context resolvers.
+Bun supplies this hook for its own compiled MDX cache and wraps extraction
+errors; Next's prepared-source integration on #752 also owns its preparation
+and wrapping. Vite, Webpack and Rsbuild do not gain a separate project MDX
+preparation cache from this shared API.
+
+Every exact prepared-id location in an error is remapped through `remapMdxError`;
+other file locations are untouched. Missing/uncovered mappings explicitly say
+`in compiled output` of the real file. Returned errors retain the original as
+`cause`; passing the returned error to the same resolver again is idempotent.
+The resolver cannot recover imported-file coordinates absent from the extractor
+error: that mapping boundary belongs to #757. With #755 alone, the real imported
+invalid-value case reports the importing TSX source position, which stays
+untouched. Contract tests use synthetic extractor-format diagnostics and real
+compiler maps; the imported-module end-to-end mapping check runs after #755 and
+#757 are both on main, without merging either automatically.
+
+## Resolver aliases
+
+Both `createModuleResolver` and graph options accept an optional readonly
+`alias: ModuleAliasOptions`. The existing `ModuleAliases` map type remains
+`Readonly>`.
+The option also accepts native `false` (disable aliases) or ordered descriptors:
+
+```ts
+const alias = [
+ { name: 'provider', alias: ['first-provider', false], onlyModule: true },
+ { name: 'provider$', alias: false, onlyModule: false },
+] as const
+```
+
+Descriptors preserve declaration order and duplicate names; a literal `$` in
+their name remains a prefix when `onlyModule` is false. In the map form,
+`provider$` instead means the exact name `provider`.
+Targets are strings, `false`, or ordered string/false candidate arrays:
+absolute file/directory paths or package requests. They run before tsconfig
+paths, baseUrl and package resolution.
+
+- `name$` matches only `name`; `name` matches `name` and `name/subpath`, not
+ `name-other`.
+- Declaration order matters: the first **rewriting** match wins. Put a specific
+ key before an overlapping broad key.
+- Each candidate undergoes full native resolution in order; the first resolving
+ candidate wins. A failed matched key never falls through to the original request
+ or a later key. All unresolved rewriting candidates produce an
+ importer-located error naming the key and every rewritten candidate tried.
+ Later descriptors with the same name do not bypass that failure. Empty arrays
+ and selfguard-only entries perform no rewrite, so they continue to the next
+ descriptor/key and then the raw request. Unaliased unresolved requests still
+ return `undefined`.
+- Finite chains resolve; self-aliases are skipped; cycles produce an
+ importer-located failure.
+- A reached `false` immediately resolves to `{ ignored: true }`, including when
+ reached through another string alias. String candidates re-enter the alias map;
+ a request equal to or below its target is not aliased again. Earlier resolving
+ candidates win, and newly created earlier files are visible on the next call.
+- Package exports and active conditions are applied after rewriting. Graph
+ package eligibility uses the actual target package, not the alias name.
+- Filesystem directory requests read their own `package.json` and try the shared
+ `module`, `main`, then index order after file/extension probes. These absolute
+ or relative directory paths follow native main-field resolution, not a bare
+ package's exports restriction. Recursive entries honor exclusions before IO;
+ malformed manifests and directory cycles are located errors. Installed Next's
+ absolute `@swc/helpers/_` prefix alias is covered with the real helper package.
+- Export/active-target failures, configuration/I/O errors and cycles are fatal,
+ not ordinary misses that advance to the next candidate. Excluded candidates
+ are skipped before I/O; an entirely excluded list stays excluded, not external.
+- Project-local aliased providers outside configured source roots can be
+ followed; excluded directories and excluded external packages remain excluded.
+
+Ignored results have no `path`, `code` or `sourceType`. `ModuleResolution` is
+`ResolvedModule | IgnoredModule`; narrow the ignored result before reading file
+fields. Neither the graph nor the evaluator scans, prepares, watches or numbers
+a manufactured ignored file. Physically enumerated real source files retain
+their normal numbering. Evaluated ignored imports use an empty CommonJS export
+object: missing named values are `undefined`, the default follows existing
+CommonJS interop, and namespace/require values are empty objects.
+
+This subset is tested against installed enhanced-resolve 5.25.1 with real files.
+Its `AliasUtils` supports reached false candidates, but its public
+`ResolverFactory` currently rejects mixed string/false arrays before that handler;
+Webpack therefore stops with its own construction error for those arrays.
+Rspack's native map-or-false format accepts mixed false arrays and has the same
+terminal-rewrite/empty-array behavior. Webpack passes its native map or ordered
+descriptors unchanged through graph, setup and loader resolver/cache; Rsbuild
+passes the actual Rspack map-or-false form. Unsupported wildcard alias names fail
+with an importer-located configuration error naming the key instead of being
+silently dropped.
diff --git a/packages/plugin-utils/src/__tests__/alias-array-fixture.ts b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts
new file mode 100644
index 000000000..d12585d05
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts
@@ -0,0 +1,50 @@
+import * as fs from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+import type { CreateModuleResolverOptions } from '../import-graph'
+
+const installedRequire = createRequire(
+ resolve(
+ 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json',
+ ),
+)
+const enhancedResolve = installedRequire('enhanced-resolve')
+
+export function aliasFixture() {
+ const parent = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core')
+ fs.mkdirSync(parent, { recursive: true })
+ const root = fs.realpathSync.native(
+ fs.mkdtempSync(join(parent, 'alias-array-')),
+ )
+ const file = (name: string, code = 'export {}') => {
+ const target = join(root, name)
+ fs.mkdirSync(dirname(target), { recursive: true })
+ fs.writeFileSync(target, code)
+ return target
+ }
+ const entry = file('src/main.ts')
+ return {
+ root,
+ entry,
+ file,
+ dispose: () => fs.rmSync(root, { recursive: true, force: true }),
+ installed: (
+ request: string,
+ options: CreateModuleResolverOptions & {
+ readonly mainFields?: readonly string[]
+ },
+ ): string | false => {
+ const resolver = enhancedResolve.ResolverFactory.createResolver({
+ fileSystem: fs,
+ useSyncFileSystemCalls: true,
+ extensions: ['.js', '.ts'],
+ alias: options.alias,
+ conditionNames: options.conditions ?? ['import', 'browser'],
+ mainFields: options.mainFields ?? ['main'],
+ })
+ return resolver.resolveSync({}, dirname(entry), request)
+ },
+ }
+}
diff --git a/packages/plugin-utils/src/__tests__/alias-array-package.test.ts b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts
new file mode 100644
index 000000000..6450aa73c
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts
@@ -0,0 +1,178 @@
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { aliasFixture } from './alias-array-fixture'
+
+let fixture: ReturnType
+beforeEach(() => {
+ fixture = aliasFixture()
+})
+afterEach(() => fixture.dispose())
+
+it.each([
+ { conditions: ['browser', 'import'] },
+ { conditions: ['import'] },
+ { conditions: [] },
+])(
+ 'resolves active conditions before selecting the package (%j)',
+ ({ conditions }: { readonly conditions: readonly string[] }) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ file('node_modules/missing-main/package.json', '{"main":"absent.js"}')
+ file(
+ 'node_modules/actual/package.json',
+ '{"exports":{"browser":"./browser.js","import":"./import.js","default":"./default.js"}}',
+ )
+ const browser = file('node_modules/actual/browser.js', "import './leaf'")
+ const imported = file('node_modules/actual/import.js', "import './leaf'")
+ const fallback = file('node_modules/actual/default.js', "import './leaf'")
+ const leaf = file('node_modules/actual/leaf.js')
+ const alias = { provider: ['missing-main', 'actual'] }
+ const expected = conditions.includes('browser')
+ ? browser
+ : conditions.includes('import')
+ ? imported
+ : fallback
+ expect(installed('provider', { alias, conditions })).toBe(expected)
+ expect(
+ createModuleResolver({ cwd: root, alias, conditions })('provider', entry),
+ ).toEqual({ path: expected, code: "import './leaf'" })
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ conditions,
+ include: ['actual'],
+ }).files,
+ ).toEqual([entry, expected, leaf].sort())
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias, conditions })
+ .files,
+ ).toEqual([entry])
+ },
+)
+
+it.each(['blocked', 'condition', 'poison'])(
+ 'preserves fatal %s package errors instead of trying a later candidate',
+ (kind) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const manifest =
+ kind === 'blocked'
+ ? '{"exports":{"./other":"./index.js"}}'
+ : kind === 'condition'
+ ? '{"exports":{"browser":"./absent.js","default":"./index.js"}}'
+ : '{'
+ const manifestFile = file('node_modules/first/package.json', manifest)
+ file('node_modules/first/index.js')
+ const alias = { provider: ['first', file('later.js')] }
+ expect(() => installed('provider', { alias })).toThrow()
+ const expected =
+ kind === 'poison'
+ ? manifestFile
+ : `${entry}:1:1: Module alias package first`
+ expect(() =>
+ createModuleResolver({ cwd: root, alias, conditions: ['browser'] })(
+ 'provider',
+ entry,
+ ),
+ ).toThrow(expected)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ conditions: ['browser'],
+ }),
+ ).toThrow(expected)
+ },
+)
+
+it('keeps cycles fatal even with a resolving later candidate', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const alias = { provider: ['nested', file('later.js')], nested: 'provider' }
+ expect(() => installed('provider', { alias })).toThrow()
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('provider', entry),
+ ).toThrow(`${entry}:1:1: Module alias cycle`)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }),
+ ).toThrow(`${entry}:1:1: Module alias cycle`)
+})
+
+it('skips self candidates while retaining ordered overlapping keys', () => {
+ const { root, entry, file, installed } = fixture
+ const chosen = file('chosen.js')
+ file('src/main.ts', "import 'provider'")
+ const alias = {
+ provider: ['provider', chosen],
+ provider$: [file('later.js')],
+ }
+ expect(installed('provider', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, chosen].sort())
+})
+
+it('follows a selected absolute package candidate and its closure', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ file('node_modules/actual/package.json', '{"main":"index.js"}')
+ const chosen = file('node_modules/actual/index.js', "import './leaf'")
+ const leaf = file('node_modules/actual/leaf.js')
+ const alias = { provider: [join(root, 'missing'), chosen] }
+ expect(installed('provider', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ include: ['actual'],
+ }).files,
+ ).toEqual([entry, chosen, leaf].sort())
+})
+
+it.each([false, true])(
+ 'skips poisoned excluded candidates without external prewarming (allExcluded=%s)',
+ (allExcluded) => {
+ const { root, entry, file } = fixture
+ file('src/main.ts', "import 'provider'")
+ const poison = file('node_modules/poison/package.json', '{')
+ const chosen = file('local/provider.js', "import './leaf'")
+ const leaf = file('local/leaf.js')
+ const alias = { provider: ['poison', chosen] }
+ const exclude = allExcluded
+ ? [dirname(poison), dirname(chosen)]
+ : [dirname(poison)]
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ exclude,
+ })
+ expect(graph.files).toEqual(
+ (allExcluded ? [entry] : [entry, chosen, leaf]).sort(),
+ )
+ expect(graph.externalImports?.get(entry)).toEqual(new Set())
+ },
+)
+
+it('keeps a mixed excluded and missing candidate list terminal', () => {
+ const { root, entry, file } = fixture
+ file('src/main.ts', "import 'provider'")
+ const poison = file('node_modules/poison/package.json', '{')
+ const alias = { provider: ['poison', join(root, 'missing')] }
+ expect(() =>
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ exclude: [dirname(poison)],
+ }),
+ ).toThrow(`${entry}:1:1: Module alias provider`)
+})
diff --git a/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts
new file mode 100644
index 000000000..2a70a7f9d
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts
@@ -0,0 +1,72 @@
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import * as wasm from '../../../../bindings/devup-ui-wasm/pkg'
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { aliasFixture } from './alias-array-fixture'
+
+const projectRequire = createRequire(resolve('apps/landing/package.json'))
+const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader'))
+const { compile } = compilerRequire('@mdx-js/mdx')
+let fixture: ReturnType
+let debug: boolean
+beforeEach(() => {
+ fixture = aliasFixture()
+ debug = wasm.isDebug()
+ wasm.resetBuildState()
+ wasm.setDebug(false)
+})
+afterEach(() => {
+ wasm.setModuleResolver(undefined)
+ wasm.resetBuildState()
+ wasm.registerTheme({})
+ wasm.setDebug(debug)
+ fixture.dispose()
+})
+
+it('prepares only the chosen real Markdown filename and extracts its imported constant', async () => {
+ const { root, entry, file } = fixture
+ const markdown = "export const PRIMARY = 'tomato'\n\n# Heading"
+ const chosen = file('provider.mdx', markdown)
+ const compiled = String(await compile({ value: markdown, path: chosen }))
+ const alias = { provider: [join(root, 'missing.mdx'), chosen] }
+ const calls: string[] = []
+ const prepareSource = (filename: string) => {
+ calls.push(filename)
+ return filename === chosen ? compiled : undefined
+ }
+ const resolver = createModuleResolver({ cwd: root, alias, prepareSource })
+ expect(resolver('provider', entry)).toEqual({ path: chosen, code: compiled })
+ expect(calls).toEqual([chosen])
+ const source =
+ "import {PRIMARY} from 'provider'; import {Box} from '@devup-ui/react'; export const view = ;"
+ file('src/main.ts', source)
+ calls.length = 0
+ const graph = await buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ includeMdx: true,
+ prepareSource,
+ })
+ expect(graph.staticImports.get(entry)).toEqual(new Set([chosen]))
+ expect(calls).toEqual([entry, chosen])
+ wasm.setModuleResolver(resolver)
+ const output = wasm.codeExtract(
+ entry.replace(/\.ts$/, '.tsx'),
+ source,
+ '@devup-ui/react',
+ 'df',
+ true,
+ false,
+ false,
+ {},
+ )
+ try {
+ expect(wasm.getCss(undefined, false)).toContain('color:tomato')
+ expect(output.code).not.toContain('--')
+ } finally {
+ output.free()
+ }
+})
diff --git a/packages/plugin-utils/src/__tests__/alias-array.test.ts b/packages/plugin-utils/src/__tests__/alias-array.test.ts
new file mode 100644
index 000000000..772f8f7b9
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-array.test.ts
@@ -0,0 +1,129 @@
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { aliasFixture } from './alias-array-fixture'
+
+let fixture: ReturnType
+beforeEach(() => {
+ fixture = aliasFixture()
+})
+afterEach(() => fixture.dispose())
+
+it.each([false, true])(
+ 'selects the first fully resolving candidate when first exists=%s',
+ (firstExists) => {
+ const { root, entry, file, installed } = fixture
+ const first = join(root, 'first/index.js')
+ if (firstExists) file('first/index.js', "export const value = 'first'")
+ const second = file('second/index.js', "export const value = 'second'")
+ file('src/main.ts', "import 'provider'")
+ const alias = { provider: [dirname(first), dirname(second)] }
+ const expected = firstExists ? first : second
+ const resolved = createModuleResolver({ cwd: root, alias })(
+ 'provider',
+ entry,
+ )
+ expect(resolved).toEqual({
+ path: expected,
+ code: firstExists
+ ? "export const value = 'first'"
+ : "export const value = 'second'",
+ })
+ expect(installed('provider', { alias })).toBe(expected)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, expected].sort())
+ },
+)
+
+it.each(['provider', 'provider/feature'])(
+ 'applies exact and subpath arrays when request is %s',
+ (request) => {
+ const { root, entry, file, installed } = fixture
+ const exact = file('exact.js')
+ const feature = file('second/feature.js')
+ file('src/main.ts', `import '${request}'`)
+ const alias = {
+ provider$: [join(root, 'missing'), exact],
+ provider: [join(root, 'absent'), dirname(feature)],
+ }
+ const expected = request === 'provider' ? exact : feature
+ expect(installed(request, { alias })).toBe(expected)
+ expect(
+ createModuleResolver({ cwd: root, alias })(request, entry)?.path,
+ ).toBe(expected)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, expected].sort())
+ },
+)
+
+it('reports all candidates at the real importer without original or lateral fallback', () => {
+ const { root, entry, file, installed } = fixture
+ file('node_modules/original/package.json', '{"main":"index.js"}')
+ file('node_modules/original/index.js')
+ const later = file('later.js')
+ file('src/main.ts', "import 'original'")
+ const candidates = [join(root, 'missing'), join(root, 'absent')]
+ const alias = { original: candidates, original$: [later] }
+ expect(() => installed('original', { alias })).toThrow()
+ const message = `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify(candidates)}`
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('original', entry),
+ ).toThrow(message)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }),
+ ).toThrow(message)
+})
+
+it('continues to the next key when a matched candidate list is empty', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const chosen = file('later.js')
+ const alias = { provider: [], provider$: chosen }
+ expect(installed('provider', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, chosen].sort())
+})
+
+it('tries another candidate after a complete nested alias miss', () => {
+ const { root, entry, file, installed } = fixture
+ const chosen = file('chosen.js')
+ file('src/main.ts', "import 'provider'")
+ const alias = { provider: ['nested', chosen], nested: join(root, 'missing') }
+ expect(installed('provider', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, chosen].sort())
+})
+
+it('does not match an exact array key against a package subpath', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider/feature'")
+ file(
+ 'node_modules/provider/package.json',
+ '{"exports":{"./feature":"./feature.js"}}',
+ )
+ const chosen = file('node_modules/provider/feature.js')
+ const alias = { provider$: [file('wrong.js')] }
+ expect(installed('provider/feature', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider/feature', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ include: ['provider'],
+ }).files,
+ ).toEqual([entry, chosen].sort())
+})
diff --git a/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts
new file mode 100644
index 000000000..3e45d3f25
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts
@@ -0,0 +1,206 @@
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { aliasFixture } from './alias-array-fixture'
+
+let fixture: ReturnType
+beforeEach(() => {
+ fixture = aliasFixture()
+})
+afterEach(() => fixture.dispose())
+
+it.each([false, true])(
+ 'selects the first native descriptor success when earlier file exists=%s',
+ (exists) => {
+ const { root, entry, file, installed } = fixture
+ const first = join(root, 'first.js')
+ if (exists) file('first.js')
+ const second = file('second.js')
+ file('src/main.ts', "import 'provider'")
+ const alias = [
+ { name: 'provider', alias: [first, second] },
+ { name: 'provider', alias: false },
+ ] as const
+ const expected = exists ? first : second
+ expect(installed('provider', { alias })).toBe(expected)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(expected)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, expected].sort())
+ },
+)
+
+it.each([false, true])(
+ 'keeps a rewriting miss terminal despite a duplicate later ignored=%s',
+ (ignored) => {
+ const { root, entry, file, installed } = fixture
+ const missing = join(root, 'missing.js')
+ const later = file('later.js')
+ file('src/main.ts', "import 'provider'")
+ const alias = [
+ { name: 'provider', alias: missing },
+ { name: 'provider', alias: ignored ? false : later },
+ ] as const
+ expect(() => installed('provider', { alias })).toThrow()
+ const message = `${entry}:1:1: Module alias provider cannot resolve candidates ${JSON.stringify([missing])}`
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('provider', entry),
+ ).toThrow(message)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }),
+ ).toThrow(message)
+ },
+)
+
+it.each(['empty', 'self'])(
+ 'advances to a duplicate descriptor when the first is %s without a rewrite',
+ (kind) => {
+ const { root, entry, file, installed } = fixture
+ const chosen = file('chosen.js')
+ file('src/main.ts', "import 'provider'")
+ const alias = [
+ { name: 'provider', alias: kind === 'empty' ? [] : ['provider'] },
+ { name: 'provider', alias: chosen },
+ ] as const
+ expect(installed('provider', { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, chosen].sort())
+ },
+)
+
+it.each(['empty-map', 'empty-descriptor', 'self', 'below-target', 'disabled'])(
+ 'uses the raw real file when %s makes no rewrite',
+ (kind) => {
+ const { root, entry, file, installed } = fixture
+ file('node_modules/provider/package.json', '{"main":"index.js"}')
+ const request = kind === 'below-target' ? 'provider/child' : 'provider'
+ const chosen = file(
+ kind === 'below-target'
+ ? 'node_modules/provider/child.js'
+ : 'node_modules/provider/index.js',
+ )
+ file('src/main.ts', `import '${request}'`)
+ const alias =
+ kind === 'empty-map'
+ ? { provider: [] }
+ : kind === 'disabled'
+ ? false
+ : [
+ {
+ name: 'provider',
+ alias: kind === 'empty-descriptor' ? [] : 'provider',
+ },
+ ]
+ expect(installed(request, { alias })).toBe(chosen)
+ expect(
+ createModuleResolver({ cwd: root, alias })(request, entry)?.path,
+ ).toBe(chosen)
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ include: ['provider'],
+ }).files,
+ ).toEqual([entry, chosen].sort())
+ },
+)
+
+it.each(['provider$', 'provider$/child'])(
+ 'keeps a literal dollar prefix when onlyModule=false and request=%s',
+ (request) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', `import '${request}'`)
+ const alias = [
+ { name: 'provider$', alias: false, onlyModule: false },
+ ] as const
+ expect(installed(request, { alias })).toBe(false)
+ expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({
+ ignored: true,
+ })
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry])
+ },
+)
+
+it.each(['provider', 'provider/child'])(
+ 'matches onlyModule=true like map trailing dollar for %s',
+ (request) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', `import '${request}'`)
+ const chosen = file('target/child.js')
+ const alias = [
+ { name: 'provider', alias: false, onlyModule: true },
+ { name: 'provider', alias: dirname(chosen) },
+ ] as const
+ const map = { provider$: false, provider: dirname(chosen) } as const
+ const expected =
+ request === 'provider'
+ ? { ignored: true }
+ : { path: chosen, code: 'export {}' }
+ expect(installed(request, { alias })).toBe(
+ request === 'provider' ? false : chosen,
+ )
+ expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual(
+ expected,
+ )
+ expect(
+ createModuleResolver({ cwd: root, alias: map })(request, entry),
+ ).toEqual(expected)
+ },
+)
+
+it('re-enters descriptors through finite chains to an ignored module', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const alias = [
+ { name: 'provider', alias: ['missing', 'empty'] },
+ { name: 'empty', alias: false },
+ ] as const
+ expect(installed('provider', { alias })).toBe(false)
+ expect(createModuleResolver({ cwd: root, alias })('provider', entry)).toEqual(
+ { ignored: true },
+ )
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry])
+})
+
+it.each([true, false])(
+ 'uses reached false candidate semantics while native Factory rejects mixed arrays; first false=%s',
+ (first) => {
+ const { root, entry, file, installed } = fixture
+ const chosen = file('chosen.js')
+ const alias = [
+ {
+ name: 'provider',
+ alias: first ? [false, chosen] : [join(root, 'missing'), false],
+ },
+ ] as const
+ expect(() => installed('provider', { alias })).toThrow(TypeError)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider', entry),
+ ).toEqual({ ignored: true })
+ },
+)
+
+it.each([false, true])(
+ 'reports unsupported wildcard alias names instead of dropping their native form; descriptor=%s',
+ (descriptor) => {
+ const { root, entry } = fixture
+ const alias = descriptor
+ ? ([{ name: 'provider/*', alias: false }] as const)
+ : ({ 'provider/*': false } as const)
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('provider/child', entry),
+ ).toThrow(`${entry}:1:1: Module alias provider/* cannot use wildcard names`)
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/alias-directory.test.ts b/packages/plugin-utils/src/__tests__/alias-directory.test.ts
new file mode 100644
index 000000000..90cedd7d8
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/alias-directory.test.ts
@@ -0,0 +1,219 @@
+import { readFileSync, realpathSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { ConfigLoadError } from '../load-config'
+import { aliasFixture } from './alias-array-fixture'
+
+let fixture: ReturnType
+beforeEach(() => {
+ fixture = aliasFixture()
+})
+afterEach(() => fixture.dispose())
+
+it.each([
+ { conditions: ['import'] },
+ { conditions: ['require'] },
+ { conditions: [] },
+])(
+ 'reads the installed Next SWC absolute directory module regardless of conditions=%j',
+ ({ conditions }) => {
+ // Given the actual helper installation resolved from this benchmark's Next.
+ const { root, entry, file, installed } = fixture
+ const next = createRequire(
+ resolve('benchmark/next-devup-ui/package.json'),
+ ).resolve('next/package.json')
+ const helpers = dirname(
+ createRequire(next).resolve('@swc/helpers/package.json'),
+ )
+ const request = '@swc/helpers/_/_interop_require_wildcard'
+ const alias = { '@swc/helpers/_': join(helpers, '_') }
+ const expected = realpathSync(
+ join(helpers, 'esm/_interop_require_wildcard.js'),
+ )
+ file('src/main.ts', `import '${request}'`)
+ expect(
+ installed(request, { alias, conditions, mainFields: ['module', 'main'] }),
+ ).toBe(expected)
+ expect(
+ installed(request, { alias, conditions, mainFields: ['main'] }),
+ ).toBe(realpathSync(join(helpers, 'cjs/_interop_require_wildcard.cjs')))
+ // When the shared resolver and graph follow the same real directory alias.
+ const selected = createModuleResolver({ cwd: root, alias, conditions })(
+ request,
+ entry,
+ )
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ conditions,
+ include: ['@swc/helpers'],
+ })
+ // Then both match native module-first resolution and the installed code.
+ expect(selected).toEqual({
+ path: expected,
+ code: readFileSync(expected, 'utf-8'),
+ })
+ expect(graph.staticImports.get(entry)).toEqual(new Set([expected]))
+ expect(graph.files).toEqual([entry, expected].sort())
+ },
+)
+
+it.each(['absolute', 'relative'])(
+ 'uses directory main fields before index and ignores exports when alias is %s',
+ (kind) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ file(
+ 'target/package.json',
+ JSON.stringify({
+ module: './module.js',
+ main: './main.js',
+ exports: { import: './export.js', require: './absent.js' },
+ }),
+ )
+ const chosen = file('target/module.js', "import './leaf'")
+ file('target/main.js')
+ file('target/index.js')
+ file('target/export.js')
+ const leaf = file('target/leaf.js')
+ const alias = {
+ provider: kind === 'absolute' ? join(root, 'target') : '../target',
+ }
+ expect(
+ installed('provider', { alias, mainFields: ['module', 'main'] }),
+ ).toBe(chosen)
+ const selected = createModuleResolver({ cwd: root, alias })(
+ 'provider',
+ entry,
+ )
+ const graph = buildStaticImportGraph('src', undefined, { cwd: root, alias })
+ expect(selected).toEqual({ path: chosen, code: "import './leaf'" })
+ expect(graph.files).toEqual([entry, chosen, leaf].sort())
+ },
+)
+
+it.each(['main', 'index', 'self', 'nested', 'extension', 'file'])(
+ 'matches native directory entry fallback when selected target is %s',
+ (kind) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ file(
+ 'target/package.json',
+ JSON.stringify({
+ module:
+ kind === 'self' ? '.' : kind === 'nested' ? './child' : './absent',
+ main: kind === 'index' || kind === 'self' ? './absent-main' : './main',
+ }),
+ )
+ const chosen = file(
+ kind === 'file'
+ ? 'target.js'
+ : kind === 'nested'
+ ? 'target/child/index.js'
+ : kind === 'index' || kind === 'self'
+ ? 'target/index.js'
+ : 'target/main.js',
+ 'export const selected = 42',
+ )
+ const alias = { provider: join(root, 'target') }
+ expect(
+ installed('provider', { alias, mainFields: ['module', 'main'] }),
+ ).toBe(chosen)
+ const selected = createModuleResolver({ cwd: root, alias })(
+ 'provider',
+ entry,
+ )
+ expect(selected).toEqual({
+ path: chosen,
+ code: 'export const selected = 42',
+ })
+ expect(
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }).files,
+ ).toEqual([entry, chosen].sort())
+ },
+)
+
+it.each(['{', '[]', 'null'])(
+ 'keeps malformed directory manifests fatal before a later false (%s)',
+ (content) => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const manifest = file('target/package.json', content)
+ file('target/index.js')
+ const target = join(root, 'target')
+ if (content === '[]') {
+ expect(installed('provider', { alias: { provider: target } })).toBe(
+ join(target, 'index.js'),
+ )
+ } else {
+ expect(() =>
+ installed('provider', { alias: { provider: target } }),
+ ).toThrow()
+ }
+ const alias = { provider: [target, false] }
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('provider', entry),
+ ).toThrow(ConfigLoadError)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }),
+ ).toThrow(`${manifest}:1:1: Cannot load configuration`)
+ },
+)
+
+it('keeps directory entry cycles fatal and located instead of reaching false', () => {
+ const { root, entry, file, installed } = fixture
+ file('src/main.ts', "import 'provider'")
+ const manifest = file('target/package.json', '{"module":"./child"}')
+ file('target/child/package.json', '{"module":".."}')
+ const target = join(root, 'target')
+ expect(() =>
+ installed('provider', {
+ alias: { provider: target },
+ mainFields: ['module', 'main'],
+ }),
+ ).toThrow()
+ const alias = { provider: [target, false] }
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('provider', entry),
+ ).toThrow(`${manifest}:1:1:`)
+ expect(() =>
+ buildStaticImportGraph('src', undefined, { cwd: root, alias }),
+ ).toThrow(`${manifest}:1:1:`)
+})
+
+it.each([false, true])(
+ 'skips excluded poisoned directory manifests before IO (all excluded=%s)',
+ (all) => {
+ const { root, entry, file } = fixture
+ file('src/main.ts', "import 'provider'")
+ const poison = dirname(file('poison/package.json', '{'))
+ const chosen = file('target/entry.js')
+ const target = dirname(file('target/package.json', '{"main":"entry.js"}'))
+ const alias = { provider: [poison, target] }
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias,
+ exclude: all ? [poison, target] : [poison],
+ })
+ expect(graph.files).toEqual((all ? [entry] : [entry, chosen]).sort())
+ expect(graph.externalImports?.get(entry)).toEqual(new Set())
+ },
+)
+
+it('excludes the selected main directory before reading its poisoned manifest', () => {
+ const { root, entry, file } = fixture
+ file('src/main.ts', "import 'provider'")
+ const target = dirname(file('target/package.json', '{"module":"../poison"}'))
+ const poison = dirname(file('poison/package.json', '{'))
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias: { provider: target },
+ exclude: [poison],
+ })
+ expect(graph.files).toEqual([entry])
+ expect(graph.externalImports?.get(entry)).toEqual(new Set())
+})
diff --git a/packages/plugin-utils/src/__tests__/compiled-guard.test.ts b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts
new file mode 100644
index 000000000..822453a72
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts
@@ -0,0 +1,187 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+import {
+ createCompileTimeClassifier,
+ isCompileTimeAlias,
+ UntransformedSourceError,
+} from '../compiled-guard'
+import { createDependencyGuard } from '../dependency-guard'
+import {
+ isMdxSource,
+ mdxSourceFilter,
+ normalizeMdxExtensions,
+ selectedSourceFilter,
+} from '../mdx-selection'
+
+it('normalizes literal selection and preserves exact source filtering', () => {
+ const extensions = normalizeMdxExtensions(['.MDOWN', '.mdx', '.mdown'])
+ expect(extensions).toEqual(['.mdown', '.mdx'])
+ expect(isMdxSource('page.MDOWN?query', extensions)).toBe(true)
+ expect(mdxSourceFilter(extensions).test('page.md')).toBe(false)
+ expect(selectedSourceFilter(extensions).test('page.mts')).toBe(true)
+ expect(mdxSourceFilter([]).test('page.mdx')).toBe(false)
+ expect(normalizeMdxExtensions()).toEqual(['.mdx'])
+ expect(() => normalizeMdxExtensions(['*.mdx'])).toThrow('mdxExtensions:1:1')
+})
+
+it('offers alias disabling only for an active compile-time compatibility alias', () => {
+ const reference = {
+ request: '@devup-ui/react',
+ ids: ['Box'],
+ line: 1,
+ column: 1,
+ }
+ expect(
+ new UntransformedSourceError('/page.mdown', reference).message,
+ ).not.toContain('importAliases')
+ expect(
+ new UntransformedSourceError(
+ '/page.mdown',
+ { ...reference, request: '@emotion/styled' },
+ true,
+ ).message,
+ ).toContain('importAliases["@emotion/styled"]=false')
+})
+
+it('classifies only actual public surfaces instead of names or directory membership', () => {
+ const root = mkdtempSync(join(tmpdir(), 'devup-surface-'))
+ mkdirSync(join(root, 'dist/compat'), { recursive: true })
+ writeFileSync(
+ join(root, 'package.json'),
+ JSON.stringify({
+ name: '@devup-ui/react',
+ exports: {
+ '.': { types: './dist/index.d.ts', import: './dist/index.js' },
+ './compat': './dist/compat/index.js',
+ './stylex': './dist/stylex.cjs',
+ './types': {},
+ },
+ }),
+ )
+ try {
+ const classify = createCompileTimeClassifier('@devup-ui/react')
+ expect(classify(join(root, 'dist/index.js'), ['Box'])).toBe(true)
+ expect(classify(join(root, 'dist/index.js'), ['getTheme'])).toBe(false)
+ expect(classify(join(root, 'dist/index.js'), ['stylex', 'create'])).toBe(
+ true,
+ )
+ expect(classify(join(root, 'dist/index.js'), ['stylex'])).toBe(false)
+ expect(classify(join(root, 'dist/compat/index.js'), ['Global'])).toBe(true)
+ expect(
+ classify(join(root, 'dist/compat/index.js'), ['ThemeProvider']),
+ ).toBe(false)
+ expect(classify(join(root, 'dist/stylex.cjs'), ['props'])).toBe(true)
+ expect(classify(join(root, 'dist/ordinary.js'), ['Box'])).toBe(false)
+ expect(classify(join(tmpdir(), 'Box.js'), ['Box'])).toBe(false)
+ mkdirSync(join(root, 'stylex'))
+ writeFileSync(
+ join(root, 'stylex/package.json'),
+ JSON.stringify({
+ name: '@stylexjs/stylex',
+ exports: { '.': './index.js' },
+ }),
+ )
+ expect(classify(join(root, 'stylex/index.js'), ['default', 'create'])).toBe(
+ true,
+ )
+ expect(classify(join(root, 'stylex/index.js'), ['default'])).toBe(false)
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+it.each([
+ ['@emotion/styled', ['default'], { '@emotion/styled': 'styled' }, true],
+ ['@emotion/react', ['CacheProvider'], { '@emotion/react': null }, false],
+ [
+ 'styled-components',
+ ['ThemeProvider'],
+ { 'styled-components': 'styled' },
+ false,
+ ],
+ ['@vanilla-extract/css', ['style'], { '@vanilla-extract/css': null }, true],
+ ['custom', ['Box'], { custom: null }, true],
+ ['custom', [], { custom: 'styled' }, false],
+ ['custom', ['default'], { custom: null }, false],
+ ['disabled', ['css'], {}, false],
+ ['@stylexjs/stylex', ['default', 'create'], {}, false],
+])(
+ 'uses extractor-compatible alias mapping for %s/%s',
+ (request, ids, aliases, expected) => {
+ expect(
+ isCompileTimeAlias({ request, ids, line: 1, column: 1 }, aliases),
+ ).toBe(expected)
+ },
+)
+
+it('guards used dependency IDs without guarding unused imports, opaque namespaces or ordinary JS', () => {
+ const check = createDependencyGuard({
+ package: '@devup-ui/react',
+ mdxExtensions: ['.mdx'],
+ importAliases: { alias: 'styled' },
+ })
+ const dependency = {
+ type: 'esm import specifier',
+ request: 'alias',
+ ids: ['default'],
+ loc: { start: { line: 3, column: 4 } },
+ }
+ const modules = [
+ {
+ type: 'javascript/auto',
+ resource: '/page.mdown',
+ dependencies: [
+ dependency,
+ { ...dependency, ids: [] },
+ { ...dependency, type: 'esm import' },
+ null,
+ ],
+ },
+ {
+ type: 'javascript/auto',
+ resource: '/node_modules/skipped.js',
+ dependencies: [dependency],
+ },
+ {
+ type: 'asset/source',
+ resource: '/asset.blob',
+ dependencies: [dependency],
+ },
+ { type: 'javascript/auto', dependencies: [dependency] },
+ ]
+ const errors = check(modules, { graph: {}, target: () => undefined })
+ expect(errors).toHaveLength(1)
+ expect(errors[0]?.message).toContain('/page.mdown:3:5:')
+})
+
+it('consumes public getIds and require-member names but ignores untyped or opaque IDs', () => {
+ const check = createDependencyGuard({
+ package: '@devup-ui/react',
+ mdxExtensions: [],
+ importAliases: { alias: null },
+ })
+ const module = {
+ type: 'javascript/auto',
+ resource: '/page.unknown',
+ dependencies: [
+ {
+ type: 'harmony import specifier',
+ request: 'alias',
+ getIds: () => ['css'],
+ },
+ { type: 'cjs full require', request: 'alias', names: ['css'] },
+ { type: 'cjs full require', request: 'alias', names: [123] },
+ ],
+ }
+ expect(
+ check([module], {
+ graph: {},
+ target: () => undefined,
+ forwarded: () => false,
+ }),
+ ).toHaveLength(2)
+})
diff --git a/packages/plugin-utils/src/__tests__/config-errors.test.ts b/packages/plugin-utils/src/__tests__/config-errors.test.ts
new file mode 100644
index 000000000..622aa2c1c
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/config-errors.test.ts
@@ -0,0 +1,100 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+import {
+ ConfigLoadError,
+ loadDevupConfig,
+ loadDevupConfigSync,
+} from '../load-config'
+
+const parent = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'shared',
+)
+let root: string
+beforeEach(() => {
+ mkdirSync(parent, { recursive: true })
+ root = realpathSync(mkdtempSync(join(parent, 'config-')))
+})
+afterEach(() => rmSync(root, { recursive: true, force: true }))
+
+describe('located bounded config errors', () => {
+ it.each(['{', 'null', '[]', '{"extends":"base.json"}', '{"extends":[1]}'])(
+ 'rejects invalid config %s in both loaders',
+ async (content) => {
+ const file = join(root, 'devup.json')
+ writeFileSync(file, content)
+ expect(() => loadDevupConfigSync(file)).toThrow(ConfigLoadError)
+ await expect(loadDevupConfig(file)).rejects.toThrow(file)
+ },
+ )
+ it('reports a missing parent rather than silently clearing its theme', async () => {
+ const file = join(root, 'devup.json')
+ const missing = join(root, 'missing.json')
+ writeFileSync(file, JSON.stringify({ extends: ['./missing.json'] }))
+ expect(() => loadDevupConfigSync(file)).toThrow(missing)
+ await expect(loadDevupConfig(file)).rejects.toThrow(missing)
+ })
+ it('reports directory read errors with the file and original cause', async () => {
+ try {
+ loadDevupConfigSync(root)
+ throw new Error('Expected a read failure')
+ } catch (error) {
+ expect(error).toBeInstanceOf(ConfigLoadError)
+ if (!(error instanceof ConfigLoadError)) throw error
+ expect(error.file).toBe(root)
+ expect(error.cause).toBeInstanceOf(Error)
+ }
+ await expect(loadDevupConfig(root)).rejects.toThrow(root)
+ })
+ it('detects canonical inheritance cycles in both loaders', async () => {
+ const file = join(root, 'a.json')
+ writeFileSync(file, JSON.stringify({ extends: ['./b.json'] }))
+ writeFileSync(
+ join(root, 'b.json'),
+ JSON.stringify({ extends: ['./nested/../a.json'] }),
+ )
+ mkdirSync(join(root, 'nested'))
+ expect(() => loadDevupConfigSync(file)).toThrow('a.json ->')
+ await expect(loadDevupConfig(file)).rejects.toThrow('a.json ->')
+ })
+ it('allows repeated parents across separate branches of a diamond', async () => {
+ writeFileSync(
+ join(root, 'base.json'),
+ JSON.stringify({ theme: { colors: { default: { text: 'red' } } } }),
+ )
+ writeFileSync(
+ join(root, 'left.json'),
+ JSON.stringify({ extends: ['./base.json'] }),
+ )
+ writeFileSync(
+ join(root, 'right.json'),
+ JSON.stringify({ extends: ['./base.json'] }),
+ )
+ const file = join(root, 'devup.json')
+ writeFileSync(
+ file,
+ JSON.stringify({ extends: ['./left.json', './right.json'] }),
+ )
+ const expected = { theme: { colors: { default: { text: 'red' } } } }
+ expect(loadDevupConfigSync(file)).toEqual(expected)
+ expect(await loadDevupConfig(file)).toEqual(expected)
+ })
+ it('returns an empty config for a missing top file in both loaders', async () => {
+ const file = join(root, 'missing.json')
+ expect(loadDevupConfigSync(file)).toEqual({})
+ expect(await loadDevupConfig(file)).toEqual({})
+ })
+})
diff --git a/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts
new file mode 100644
index 000000000..604a38c3d
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts
@@ -0,0 +1,233 @@
+import * as fs from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test'
+
+import { createDirectoryExclusion } from '../directory-exclusion'
+import { buildStaticImportGraph, listSourceFiles } from '../import-graph'
+import { collectNumberedFiles } from '../numbering'
+
+let root: string
+beforeEach(() => {
+ const parent = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'shared-api',
+ )
+ fs.mkdirSync(parent, { recursive: true })
+ root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'exclude-')))
+})
+afterEach(() => fs.rmSync(root, { recursive: true, force: true }))
+function file(path: string, source = 'export {}'): string {
+ const target = join(root, path)
+ fs.mkdirSync(dirname(target), { recursive: true })
+ fs.writeFileSync(target, source)
+ return target
+}
+
+it('matches bare names at every depth and absolute directories only within their boundary', () => {
+ const match = createDirectoryExclusion(
+ ['generated', '/project/build/next'],
+ 'linux',
+ )
+ expect(match('/project/src/nested/generated')).toBe(true)
+ expect(match('/project/build/next/deep')).toBe(true)
+ expect(match('/project/other/next')).toBe(false)
+ expect(match('/project/build/next-other')).toBe(false)
+ expect(match('/project/Generated')).toBe(false)
+})
+
+it('normalizes Windows separators, drive case and directory case but retains boundaries', () => {
+ const match = createDirectoryExclusion(
+ ['Generated', 'C:/Project/build/Next/'],
+ 'win32',
+ )
+ expect(match('c:\\PROJECT\\BUILD\\next\\deep')).toBe(true)
+ expect(match('D:/project/deep/GENERATED')).toBe(true)
+ expect(match('C:/Project/other/Next')).toBe(false)
+ expect(match('c:/project/build/next-other')).toBe(false)
+ expect(createDirectoryExclusion(['C:/'], 'win32')('c:/project')).toBe(true)
+})
+
+it('keeps a same-basename source folder while never reading, parsing, preparing or numbering excluded source', async () => {
+ const entry = file(
+ 'src/main.ts',
+ "import '../build/next/poison'\nimport './next/value'\nimport './nested/generated/poison'\nimport 'provider'\nimport 'included'",
+ )
+ const kept = file('src/next/value.ts')
+ const poison = file('build/next/poison.ts', "import 'invalid-manifest'")
+ file('src/nested/generated/poison.ts', "import 'invalid-manifest'")
+ file('node_modules/invalid-manifest/package.json', '{')
+ file('node_modules/included/package.json', '{"main":"index.js"}')
+ file('node_modules/included/index.js', "import 'invalid-manifest'")
+ const excluded = [
+ dirname(poison),
+ 'generated',
+ join(root, 'node_modules/included'),
+ ]
+ const originalRead = fs.readFileSync
+ const read = spyOn(fs, 'readFileSync').mockImplementation(
+ new Proxy(originalRead, {
+ apply(target, thisArg, args) {
+ if (
+ typeof args[0] === 'string' &&
+ /poison|included[/\\]index/.test(args[0])
+ )
+ throw new Error('Excluded source read')
+ return Reflect.apply(target, thisArg, args)
+ },
+ }),
+ )
+ try {
+ const options = {
+ cwd: root,
+ exclude: excluded,
+ alias: { provider: poison },
+ include: ['included'],
+ }
+ const raw = buildStaticImportGraph('src', undefined, options)
+ const visits: string[] = []
+ const prepared = await buildStaticImportGraph('src', undefined, {
+ ...options,
+ prepareSource: (filename) => {
+ visits.push(filename)
+ return undefined
+ },
+ })
+ expect(raw.files).toEqual([entry, kept].sort())
+ expect(prepared.files).toEqual(raw.files)
+ expect(visits.sort()).toEqual(raw.files)
+ expect(
+ collectNumberedFiles({
+ roots: [join(root, 'src'), dirname(poison)],
+ include: ['included'],
+ cwd: root,
+ exclude: excluded,
+ needles: ['export'],
+ toId: (path) => path,
+ }),
+ ).toEqual([kept])
+ expect(listSourceFiles(dirname(poison), excluded)).toEqual([])
+ } finally {
+ read.mockRestore()
+ }
+})
+
+it('uses native Windows absolute-path normalization in a real scanner fixture', () => {
+ const excluded = file('build/next/poison.ts')
+ const kept = file('src/next/value.ts')
+ const path = dirname(excluded)
+ const exclude =
+ process.platform === 'win32'
+ ? path.toUpperCase().replaceAll('\\', '/')
+ : path
+ expect(listSourceFiles(root, [exclude])).toEqual([kept])
+})
+
+it.each([
+ 'package',
+ 'package-alias',
+ 'package-subpath',
+ 'package-subpath-alias',
+ 'absolute',
+ 'absolute-alias',
+])(
+ 'never reads a poisoned excluded manifest or source when resolving %s',
+ async (kind) => {
+ // Given an included package whose excluded manifest cannot be parsed.
+ const manifest = file('node_modules/excluded/package.json', '{')
+ const subpath = kind.includes('subpath')
+ const poison = file(
+ `node_modules/excluded/${subpath ? 'blocked/' : ''}index.ts`,
+ "import 'invalid'",
+ )
+ const request = kind.startsWith('absolute')
+ ? poison.replaceAll('\\', '/')
+ : `excluded${subpath ? '/blocked/index' : ''}`
+ const aliased = kind.endsWith('alias')
+ const entry = file(
+ 'src/main.ts',
+ `import '${aliased ? 'provider' : request}'`,
+ )
+ const excluded = subpath ? dirname(poison) : dirname(manifest)
+ const alias: Readonly> = aliased
+ ? { provider: request }
+ : {}
+ const options = {
+ cwd: root,
+ include: ['excluded'],
+ exclude: [excluded],
+ alias,
+ }
+ const raw = buildStaticImportGraph(['src', excluded], undefined, options)
+ const match = createDirectoryExclusion([excluded])
+ const originalRead = fs.readFileSync
+ const read = spyOn(fs, 'readFileSync').mockImplementation(
+ new Proxy(originalRead, {
+ apply(target, thisArg, args) {
+ if (typeof args[0] === 'string' && match(dirname(args[0])))
+ throw new Error('Excluded manifest/source read')
+ return Reflect.apply(target, thisArg, args)
+ },
+ }),
+ )
+ try {
+ // When raw/prepared graphs and numbering follow the same excluded edge/root.
+ const visits: string[] = []
+ const prepared = await buildStaticImportGraph(
+ ['src', excluded],
+ undefined,
+ {
+ ...options,
+ prepareSource: (filename) => {
+ visits.push(filename)
+ return undefined
+ },
+ },
+ )
+ const numbered = collectNumberedFiles({
+ ...options,
+ roots: [join(root, 'src'), excluded],
+ needles: ['import'],
+ toId: (path) => path,
+ })
+ // Then no excluded source is visited, prewarmed or needle-read.
+ expect(raw.files).toEqual([entry])
+ expect(prepared.files).toEqual([entry])
+ expect(visits).toEqual([entry])
+ expect(raw.externalImports?.get(entry)).toEqual(new Set())
+ expect(prepared.externalImports?.get(entry)).toEqual(new Set())
+ expect(numbered).toEqual([entry])
+ } finally {
+ read.mockRestore()
+ }
+ },
+)
+
+it.each(['paths', 'baseUrl'])(
+ 'keeps excluded %s candidates terminal rather than prewarming a package fallback',
+ (kind) => {
+ const poison = file('blocked/index.ts')
+ const entry = file('src/main.ts', "import 'blocked'")
+ file('node_modules/blocked/package.json', '{')
+ file(
+ 'tsconfig.json',
+ JSON.stringify({
+ compilerOptions:
+ kind === 'paths'
+ ? { paths: { blocked: ['./blocked/index.ts'] } }
+ : { baseUrl: '.' },
+ }),
+ )
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ include: ['blocked'],
+ exclude: [dirname(poison)],
+ })
+ expect(graph.files).toEqual([entry])
+ expect(graph.externalImports?.get(entry)).toEqual(new Set())
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts
new file mode 100644
index 000000000..329095f72
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts
@@ -0,0 +1,83 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, listSourceFiles } from '../import-graph'
+import {
+ GRAPH_SOURCE_FILE_RE,
+ MDX_FILE_RE,
+ POST_COMPILED_MDX_RE,
+ SOURCE_FILE_RE,
+} from '../shared'
+
+const parent = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'mdx-shared',
+)
+let root: string
+beforeEach(() => {
+ mkdirSync(parent, { recursive: true })
+ root = realpathSync(mkdtempSync(join(parent, 'exclude-')))
+})
+afterEach(() => rmSync(root, { recursive: true, force: true }))
+
+it('skips named directories at every depth without reintroducing imported files', () => {
+ const fixtures = {
+ 'src/z.ts':
+ "import './generated/a'\nimport('./nested/generated/b')\nimport './node_modules/local/c'\nimport './nested/A.MDX'",
+ 'src/generated/a.ts': 'a',
+ 'src/nested/generated/b.ts': 'b',
+ 'src/node_modules/local/c.ts': 'c',
+ 'src/nested/A.MDX': "import '../value.CTS'\n\n# Heading",
+ 'src/value.CTS': 'value',
+ 'src/nested/ignored.TEST.TS': 'test',
+ 'app/generated/d.ts': 'd',
+ 'app/a.mjs': 'a',
+ }
+ for (const [path, code] of Object.entries(fixtures)) {
+ mkdirSync(dirname(join(root, path)), { recursive: true })
+ writeFileSync(join(root, path), code)
+ }
+ const graph = buildStaticImportGraph(['src', 'app'], undefined, {
+ includeMdx: true,
+ cwd: root,
+ exclude: ['generated'],
+ })
+ expect(graph.files).toEqual(
+ ['app/a.mjs', 'src/nested/A.MDX', 'src/value.CTS', 'src/z.ts'].map((path) =>
+ join(root, path),
+ ),
+ )
+ expect(graph.staticImports.get(join(root, 'src/nested/A.MDX'))).toEqual(
+ new Set([join(root, 'src/value.CTS')]),
+ )
+ expect(
+ listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: true }),
+ ).toEqual(graph.files.slice(1))
+})
+
+it.each(['.mts', '.cts', '.mjs', '.cjs', '.tsx', '.jsx'])(
+ 'matches uppercase modern %s extensions without treating compiled MDX as raw MDX',
+ (extension) => {
+ expect(SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe(true)
+ expect(GRAPH_SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe(
+ true,
+ )
+ expect(
+ POST_COMPILED_MDX_RE.test(`file.MDX${extension.toUpperCase()}`),
+ ).toBe(true)
+ expect(MDX_FILE_RE.test(`file.MDX${extension}`)).toBe(false)
+ expect(MDX_FILE_RE.test('file.MDX')).toBe(true)
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/graph-modern.test.ts b/packages/plugin-utils/src/__tests__/graph-modern.test.ts
new file mode 100644
index 000000000..ed9c8ac49
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts
@@ -0,0 +1,416 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+import {
+ buildCanonicalMap,
+ buildStaticImportGraph,
+ computeCompiledFiles,
+ computeFileReach,
+ computeFileRoutes,
+ computeReachableFiles,
+ createModuleResolver,
+} from '../import-graph'
+import { SOURCE_EXTENSIONS } from '../shared'
+
+const parent = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'shared',
+)
+let root: string
+beforeEach(() => {
+ mkdirSync(parent, { recursive: true })
+ root = realpathSync(mkdtempSync(join(parent, 'graph-')))
+})
+afterEach(() => rmSync(root, { recursive: true, force: true }))
+
+function fixture(path: string, source: string): string {
+ const file = join(root, path)
+ mkdirSync(dirname(file), { recursive: true })
+ writeFileSync(file, source)
+ return file
+}
+
+describe('multi-root source graph', () => {
+ it('resolves dotted specifiers and directories without replacing real asset extensions', () => {
+ const entry = fixture(
+ 'src/main.ts',
+ "import './a.generated'\nimport './b.config'\nimport './folder.with.dot'\nimport './c.css'",
+ )
+ const generated = fixture(
+ 'src/a.generated.ts',
+ 'export const generated = 1',
+ )
+ const config = fixture('src/b.config.tsx', 'export const config = 1')
+ const index = fixture(
+ 'src/folder.with.dot/index.ts',
+ 'export const index = 1',
+ )
+ const css = fixture('src/c.css', 'body{color:red}')
+ fixture('src/c.css.ts', 'export const competing = 1')
+ const resolver = createModuleResolver({ cwd: root })
+ expect(resolver('./a.generated', entry)?.path).toBe(generated)
+ expect(resolver('./b.config', entry)?.path).toBe(config)
+ expect(resolver('./folder.with.dot', entry)?.path).toBe(index)
+ expect(resolver('./c.css', entry)?.path).toBe(css)
+ const graph = buildStaticImportGraph(join(root, 'src'), undefined, {
+ cwd: root,
+ })
+ expect(graph.staticImports.get(entry)).toEqual(
+ new Set([generated, config, index]),
+ )
+ expect(graph.files).not.toContain(css)
+ expect(
+ computeReachableFiles({
+ srcDir: join(root, 'src'),
+ graph,
+ entries: [join(root, 'src/a.generated')],
+ }),
+ ).toEqual([generated])
+ })
+
+ it('follows only eligible package closures when an entry uses the project root layout', () => {
+ const entry = fixture('entry.ts', "import 'included'\nimport 'excluded'")
+ fixture(
+ 'node_modules/included/package.json',
+ JSON.stringify({ main: 'index.js' }),
+ )
+ const included = fixture(
+ 'node_modules/included/index.js',
+ "import './leaf.cjs'",
+ )
+ const leaf = fixture(
+ 'node_modules/included/leaf.cjs',
+ 'export const value = 1',
+ )
+ fixture(
+ 'node_modules/excluded/package.json',
+ JSON.stringify({ main: 'index.js' }),
+ )
+ fixture('node_modules/excluded/index.js', 'export const value = 2')
+ const graph = buildStaticImportGraph(root, undefined, {
+ cwd: root,
+ include: ['included'],
+ })
+ expect(graph.files).toEqual([entry, included, leaf].sort())
+ expect(graph.staticImports.get(entry)).toEqual(new Set([included]))
+ expect(graph.externalImports?.get(entry)).toEqual(new Set(['excluded']))
+ })
+
+ it('shares one graph across reach, route, compiled and collapse consumers', () => {
+ const entry = fixture(
+ 'app/page.mts',
+ "import '../src/value.cts'\nimport('included')",
+ )
+ const value = fixture('src/value.cts', 'export const value = 1')
+ fixture(
+ 'node_modules/included/package.json',
+ JSON.stringify({
+ exports: { browser: './browser.mjs', import: './server.js' },
+ }),
+ )
+ const library = fixture(
+ 'node_modules/included/browser.mjs',
+ "export { value } from './leaf.cjs'\nimport('other')",
+ )
+ const leaf = fixture(
+ 'node_modules/included/leaf.cjs',
+ 'export const value = 2',
+ )
+ fixture('node_modules/included/unreachable.ts', 'unused')
+ fixture('node_modules/included/server.js', 'server')
+ fixture(
+ 'node_modules/other/package.json',
+ JSON.stringify({ main: 'index.js' }),
+ )
+ const other = fixture('node_modules/other/index.js', 'other')
+ const srcDir = ['src', 'app']
+ const graph = buildStaticImportGraph(srcDir, undefined, {
+ cwd: root,
+ include: ['included'],
+ conditions: ['browser', 'import'],
+ })
+ expect(graph.files).toEqual([entry, library, leaf, value].sort())
+ expect(graph.externalImports?.get(library)).toEqual(new Set(['other']))
+ expect(graph.fileSet.has(other)).toBe(false)
+ expect(computeReachableFiles({ srcDir, graph, entries: [entry] })).toEqual(
+ graph.files,
+ )
+ const opts = { cwd: root, srcDir, graph }
+ expect(buildCanonicalMap(opts)['src/value.cts']).toBe('app/page.mts')
+ expect(
+ computeFileReach({ ...opts, entries: ['app/page.mts'] })['src/value.cts'],
+ ).toEqual([0])
+ expect(computeFileRoutes(opts)['src/value.cts']).toEqual([0])
+ expect(computeCompiledFiles(opts)).toEqual(
+ graph.files
+ .map((file) => file.slice(root.length + 1).replaceAll('\\', '/'))
+ .sort(),
+ )
+ })
+ it('follows reachable static included dependencies without enumerating their package', () => {
+ const entry = fixture('src/main.ts', "import '@scope/pkg'")
+ fixture(
+ 'node_modules/@scope/pkg/package.json',
+ JSON.stringify({ main: 'index.ts' }),
+ )
+ const library = fixture(
+ 'node_modules/@scope/pkg/index.ts',
+ "import './nested/a'\nimport('included-two')",
+ )
+ const nested = fixture(
+ 'node_modules/@scope/pkg/nested/a.ts',
+ 'export const a = 1',
+ )
+ fixture(
+ 'node_modules/included-two/package.json',
+ JSON.stringify({ main: 'index.ts' }),
+ )
+ const second = fixture(
+ 'node_modules/included-two/index.ts',
+ 'export const second = 1',
+ )
+ fixture('node_modules/@scope/pkg/orphan.ts', 'orphan')
+ const graph = buildStaticImportGraph(join(root, 'src'), undefined, {
+ cwd: root,
+ include: ['@scope/pkg', 'included-two'],
+ })
+ expect(
+ computeReachableFiles({
+ srcDir: join(root, 'src'),
+ graph,
+ entries: [entry],
+ }),
+ ).toEqual([library, nested, second, entry].sort())
+ expect(
+ buildCanonicalMap({ cwd: root, srcDir: join(root, 'src'), graph })[
+ 'node_modules/@scope/pkg/nested/a.ts'
+ ],
+ ).toBe('src/main.ts')
+ })
+ it.each(SOURCE_EXTENSIONS)(
+ 'discovers and resolves %s modules and index files',
+ (extension) => {
+ const entry = fixture(
+ `src/main${extension}`,
+ "import './value'\nimport './folder'",
+ )
+ const value = fixture(`src/value${extension}`, 'export const value = 1')
+ const index = fixture(
+ `src/folder/index${extension}`,
+ 'export const index = 1',
+ )
+ const graph = buildStaticImportGraph(join(root, 'src'))
+ expect(graph.staticImports.get(entry)).toEqual(new Set([value, index]))
+ const resolver = createModuleResolver({ cwd: root })
+ expect(resolver(`./value${extension}`, entry)?.path).toBe(value)
+ },
+ )
+})
+
+describe('MDX ESM discovery', () => {
+ it.each(['src', '.'])(
+ 'excludes raw MDX roots and direct edges by default under %s',
+ (layout) => {
+ const entry = fixture(
+ 'src/main.ts',
+ "import './page.mdx'\nimport('./lazy.mdx')",
+ )
+ const page = fixture(
+ 'src/page.mdx',
+ "import '../outside/provider.js'\n\n# Raw markdown",
+ )
+ fixture('src/lazy.mdx', '# Lazy markdown')
+ fixture('outside/provider.js', 'export const value = 1')
+ const graph = buildStaticImportGraph(join(root, layout), undefined, {
+ cwd: root,
+ })
+ expect(graph.files.filter((file) => file.endsWith('.mdx'))).toEqual([])
+ expect(graph.staticImports.get(entry)).toEqual(new Set())
+ expect(graph.dynamicImports.get(entry)).toEqual(new Set())
+ expect(() =>
+ createModuleResolver({ cwd: root })('./page.mdx', entry),
+ ).toThrow(page)
+ },
+ )
+
+ it.each([false, true])(
+ 'follows included JS and MDX provider closures only with MDX opt-in %s',
+ (includeMdx) => {
+ const entry = fixture(
+ 'src/main.ts',
+ "import 'included'\nimport 'mdx-entry'",
+ )
+ fixture(
+ 'node_modules/included/package.json',
+ JSON.stringify({ main: 'index.js' }),
+ )
+ const library = fixture(
+ 'node_modules/included/index.js',
+ "import './page.mdx'",
+ )
+ const page = fixture(
+ 'node_modules/included/page.mdx',
+ "import './provider.js'\n\n# Page",
+ )
+ const provider = fixture(
+ 'node_modules/included/provider.js',
+ 'export const value = 1',
+ )
+ fixture(
+ 'node_modules/mdx-entry/package.json',
+ JSON.stringify({ main: 'index.mdx' }),
+ )
+ const mdxEntry = fixture(
+ 'node_modules/mdx-entry/index.mdx',
+ "import './provider.js'\n\n# Entry",
+ )
+ const secondProvider = fixture(
+ 'node_modules/mdx-entry/provider.js',
+ 'export const value = 2',
+ )
+ const graph = buildStaticImportGraph(join(root, 'src'), undefined, {
+ cwd: root,
+ include: ['included', 'mdx-entry'],
+ includeMdx,
+ })
+ expect(graph.files).toEqual(
+ (includeMdx
+ ? [entry, library, page, provider, mdxEntry, secondProvider]
+ : [entry, library]
+ ).sort(),
+ )
+ expect(graph.staticImports.get(library)).toEqual(
+ new Set(includeMdx ? [page] : []),
+ )
+ },
+ )
+
+ it('discovers real ESM blocks while ignoring markdown, code fences, indented code and comments', () => {
+ const entry = fixture(
+ 'app/page.mdx',
+ [
+ '# Heading',
+ '',
+ "Prose about import './fake'",
+ '',
+ '```tsx',
+ "import './fake'",
+ '```',
+ '',
+ '~~~js',
+ "import './fake'",
+ '~~~',
+ '',
+ " import './fake'",
+ '',
+ '',
+ '',
+ '',
+ '',
+ 'import {',
+ ' value',
+ "} from '../src/value.mts'",
+ '',
+ "export { other } from '../src/other.cts'",
+ '',
+ "export const load = () => import('../src/lazy.cjs')",
+ '',
+ 'Content
',
+ ].join('\n'),
+ )
+ const value = fixture('src/value.mts', 'export const value = 1')
+ const other = fixture('src/other.cts', 'export const other = 1')
+ const lazy = fixture('src/lazy.cjs', 'export const lazy = 1')
+ fixture('app/fake.ts', 'fake')
+ const srcDir = [join(root, 'app'), join(root, 'src')]
+ const graph = buildStaticImportGraph(srcDir, undefined, {
+ cwd: root,
+ includeMdx: true,
+ })
+ expect(graph.staticImports.get(entry)).toEqual(new Set([value, other]))
+ expect(graph.dynamicImports.get(entry)).toEqual(new Set([lazy]))
+ expect(computeCompiledFiles({ srcDir, cwd: root, graph })).toEqual([
+ 'app/page.mdx',
+ 'src/lazy.cjs',
+ 'src/other.cts',
+ 'src/value.mts',
+ ])
+ })
+})
+
+describe('conditional package resolution', () => {
+ it('propagates invalid module path I/O errors instead of reporting an unresolved import', () => {
+ fixture('src/main.ts', 'export const main = 1')
+ expect(() =>
+ createModuleResolver({ cwd: root })('./\0.ts', 'src/main.ts'),
+ ).toThrow()
+ })
+ it('honors active conditions in declaration order rather than condition input order', () => {
+ fixture(
+ 'node_modules/pkg/package.json',
+ JSON.stringify({
+ exports: {
+ browser: './browser.js',
+ import: './import.js',
+ default: './default.js',
+ },
+ }),
+ )
+ fixture('node_modules/pkg/browser.js', 'browser')
+ fixture('node_modules/pkg/import.js', 'import')
+ fixture('node_modules/pkg/default.js', 'default')
+ expect(
+ createModuleResolver({ cwd: root, conditions: ['import', 'browser'] })(
+ 'pkg',
+ 'src/main.ts',
+ )?.code,
+ ).toBe('browser')
+ expect(
+ createModuleResolver({ cwd: root, conditions: [] })('pkg', 'src/main.ts')
+ ?.code,
+ ).toBe('default')
+ })
+ it('honors early default branches and null exclusions', () => {
+ fixture(
+ 'node_modules/pkg/package.json',
+ JSON.stringify({
+ exports: {
+ '.': { default: './default.js', browser: './browser.js' },
+ './blocked': { browser: null, default: './default.js' },
+ './empty': [],
+ },
+ }),
+ )
+ fixture('node_modules/pkg/default.js', 'default')
+ const resolver = createModuleResolver({
+ cwd: root,
+ conditions: ['browser'],
+ })
+ expect(resolver('pkg', 'src/main.ts')?.code).toBe('default')
+ expect(resolver('pkg/blocked', 'src/main.ts')).toBeUndefined()
+ expect(resolver('pkg/empty', 'src/main.ts')).toBeUndefined()
+ })
+ it.each(['{', 'null', '[]'])(
+ 'propagates invalid manifest %s with location and cause',
+ (content) => {
+ const file = fixture('node_modules/pkg/package.json', content)
+ expect(() =>
+ createModuleResolver({ cwd: root })('pkg', 'src/main.ts'),
+ ).toThrow(file)
+ },
+ )
+})
diff --git a/packages/plugin-utils/src/__tests__/ignored-alias.test.ts b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts
new file mode 100644
index 000000000..69ab7e858
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts
@@ -0,0 +1,258 @@
+import { join } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+import { ConfigLoadError } from '../load-config'
+import { ModuleAliasPackageError, resolveModuleAlias } from '../module-alias'
+import { collectNumberedFiles, seedFileNumbers } from '../numbering'
+import { aliasFixture } from './alias-array-fixture'
+
+let fixture: ReturnType
+beforeEach(() => {
+ fixture = aliasFixture()
+})
+afterEach(() => fixture.dispose())
+
+it.each(['provider', 'provider/feature'])(
+ 'ignores scalar false when request is %s',
+ (request) => {
+ const { root, entry, installed } = fixture
+ const alias = { provider: false } as const
+ expect(installed(request, { alias })).toBe(false)
+ expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({
+ ignored: true,
+ })
+ },
+)
+
+it.each([false, true])(
+ 'selects ordered false candidates when earlier file exists=%s',
+ (exists) => {
+ const { root, entry, file } = fixture
+ const chosen = join(root, 'chosen.js')
+ if (exists) file('chosen.js')
+ const resolve = createModuleResolver({
+ cwd: root,
+ alias: { provider: [chosen, false] },
+ })
+ expect(resolve('provider', entry)).toEqual(
+ exists ? { path: chosen, code: 'export {}' } : { ignored: true },
+ )
+ },
+)
+
+it('stops before later strings when false is first', () => {
+ const { root, entry, file } = fixture
+ const chosen = file('chosen.js')
+ expect(
+ createModuleResolver({ cwd: root, alias: { provider: [false, chosen] } })(
+ 'provider',
+ entry,
+ ),
+ ).toEqual({ ignored: true })
+})
+
+it('does not claim public factory parity for mixed false arrays', () => {
+ const { root, installed } = fixture
+ expect(() =>
+ installed('provider', {
+ alias: { provider: [join(root, 'missing'), false] },
+ }),
+ ).toThrow(TypeError)
+})
+
+it('keeps exact false aliases from matching subpaths', () => {
+ const { root, entry, installed } = fixture
+ const alias = { provider$: false } as const
+ expect(installed('provider', { alias })).toBe(false)
+ expect(
+ createModuleResolver({ cwd: root, alias })('provider/feature', entry),
+ ).toBeUndefined()
+})
+
+it.each(['provider', 'provider/feature'])(
+ 're-enters scalar false chains for %s',
+ (request) => {
+ const { root, entry, installed } = fixture
+ const alias = { provider: 'nested', nested: 'empty', empty: false } as const
+ expect(installed(request, { alias })).toBe(false)
+ expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({
+ ignored: true,
+ })
+ },
+)
+
+it.each(['provider', 'provider/feature'])(
+ 'reaches false after equal/below-target guards for %s',
+ (request) => {
+ const { root, entry } = fixture
+ expect(
+ createModuleResolver({
+ cwd: root,
+ alias: { provider: ['provider', false] },
+ })(request, entry),
+ ).toEqual({ ignored: true })
+ },
+)
+
+it('distinguishes excluded false from ignored success after a missing candidate', () => {
+ const { entry } = fixture
+ expect(
+ resolveModuleAlias('provider', {
+ importer: entry,
+ alias: { provider: ['excluded', false] },
+ resolveRequest: () => false,
+ }),
+ ).toEqual({ ignored: true })
+})
+
+it('keeps fatal cycles before a later false candidate', () => {
+ const { root, entry } = fixture
+ expect(() =>
+ createModuleResolver({
+ cwd: root,
+ alias: { provider: ['nested', false], nested: 'provider' },
+ })('provider', entry),
+ ).toThrow('Module alias cycle')
+})
+
+it('reaches false after a complete nested ordinary miss', () => {
+ const { root, entry } = fixture
+ expect(
+ createModuleResolver({
+ cwd: root,
+ alias: { provider: ['nested', false], nested: join(root, 'missing') },
+ })('provider', entry),
+ ).toEqual({ ignored: true })
+})
+
+it.each(['exports', 'manifest'])(
+ 'preserves fatal %s faults before a false candidate',
+ (kind) => {
+ const { root, entry, file } = fixture
+ file(
+ 'node_modules/broken/package.json',
+ kind === 'exports' ? '{"exports":{"browser":"./missing.js"}}' : '{',
+ )
+ expect(() =>
+ createModuleResolver({
+ cwd: root,
+ alias: { provider: ['broken', false] },
+ })('provider', entry),
+ ).toThrow(kind === 'exports' ? ModuleAliasPackageError : ConfigLoadError)
+ },
+)
+
+it('keeps directory exclusion false separate from ordinary misses', () => {
+ const { entry } = fixture
+ expect(
+ resolveModuleAlias('provider', {
+ importer: entry,
+ alias: { provider: ['excluded'] },
+ resolveRequest: () => false,
+ }),
+ ).toBe(false)
+ expect(() =>
+ resolveModuleAlias('provider', {
+ importer: entry,
+ alias: { provider: ['missing', 'excluded'] },
+ resolveRequest: (name) => (name === 'excluded' ? false : undefined),
+ }),
+ ).toThrow('cannot resolve candidates')
+})
+
+it('seeds only physically scanned source files when graph requests are ignored', () => {
+ const { root, entry, file } = fixture
+ const token = file('src/token.ts')
+ file('src/main.ts', "import 'provider';")
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias: { provider: false },
+ })
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src')],
+ toId: (path) => path,
+ })
+ const seeded: string[][] = []
+ seedFileNumbers({ seedFileMap: (paths) => seeded.push(paths) }, files)
+ expect(graph.files).toEqual([entry, token].sort())
+ expect(seeded).toEqual([[entry, token].sort()])
+})
+
+it.each(['absent', 'root', 'src'])(
+ 'resolves stock-shaped instrumentation chain when file is %s',
+ (location) => {
+ const { root, entry, file, installed } = fixture
+ const request = 'private-next-instrumentation-client-user'
+ const alias = {
+ [request]: [
+ join(root, 'src/instrumentation-client'),
+ join(root, 'instrumentation-client'),
+ 'private-next-empty-module',
+ ],
+ 'private-next-empty-module': false,
+ } as const
+ const expected =
+ location === 'absent'
+ ? undefined
+ : file(`${location === 'src' ? 'src/' : ''}instrumentation-client.js`)
+ expect(installed(request, { alias })).toBe(expected ?? false)
+ expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual(
+ expected ? { path: expected, code: 'export {}' } : { ignored: true },
+ )
+ },
+)
+
+it('retries earlier candidates after a previously ignored resolution', () => {
+ const { root, entry, file } = fixture
+ const resolve = createModuleResolver({
+ cwd: root,
+ alias: { provider: [join(root, 'earlier'), 'empty'], empty: false },
+ })
+ expect(resolve('provider', entry)).toEqual({ ignored: true })
+ const path = file('earlier.js')
+ expect(resolve('provider', entry)).toEqual({ path, code: 'export {}' })
+})
+
+it('bypasses preparation and numbering IDs for ignored requests', () => {
+ const { root, entry } = fixture
+ const seen: string[] = []
+ const resolve = createModuleResolver({
+ cwd: root,
+ alias: { provider: false },
+ prepareSource: (path) => {
+ seen.push(path)
+ return ''
+ },
+ toId: (path) => {
+ seen.push(path)
+ return path
+ },
+ })
+ expect(resolve('provider', entry)).toEqual({ ignored: true })
+ expect(seen).toEqual([])
+})
+
+it.each([false, true])(
+ 'omits ignored static/dynamic/reexport edges when prepared=%s',
+ async (prepared) => {
+ const { root, entry, file } = fixture
+ file(
+ 'src/main.ts',
+ "import 'provider'; import('provider/feature'); export * from 'provider';",
+ )
+ const options = { cwd: root, alias: { provider: false } } as const
+ const graph = prepared
+ ? await buildStaticImportGraph('src', undefined, {
+ ...options,
+ prepareSource: () =>
+ "import 'provider'; import('provider/feature'); export * from 'provider';",
+ })
+ : buildStaticImportGraph('src', undefined, options)
+ expect(graph.files).toEqual([entry])
+ expect([...(graph.staticImports.get(entry) ?? [])]).toEqual([])
+ expect([...(graph.dynamicImports.get(entry) ?? [])]).toEqual([])
+ expect([...(graph.externalImports.get(entry) ?? [])]).toEqual([])
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/import-mask.test.ts b/packages/plugin-utils/src/__tests__/import-mask.test.ts
new file mode 100644
index 000000000..a74082d7a
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-mask.test.ts
@@ -0,0 +1,55 @@
+import { expect, it } from 'bun:test'
+
+import { maskImportText } from '../import-mask'
+
+it('masks escaped strings, nested templates and comments without consuming subsequent code', () => {
+ const code = [
+ String.raw`const quote = "escaped \" import './fake'";`,
+ 'const value = `outer ${ {a: "}", b: `nested ${1}`} /* comment */ // comment',
+ '} import fake`;',
+ '/* multiline\n import fake */',
+ '// import fake',
+ "import './real'",
+ ].join('\n')
+ const masked = maskImportText(code, false)
+ expect(masked).not.toContain('fake')
+ expect(masked).toContain('import')
+ expect(masked.length).toBe(code.length)
+})
+
+it('masks regex quotes, escaped slashes and character classes', () => {
+ const code = String.raw`const value = /[/'"]import '\/fake'/gi; return /import "fake"/; import './real'`
+ expect(maskImportText(code, false)).not.toContain('fake')
+})
+
+it('masks JSX fragments, nesting, attributes and self-closing tags but keeps JavaScript expressions', () => {
+ const code =
+ 'const value = <>import fake { {value: import("real")} } tail
>; import "after"'
+ const masked = maskImportText(code, true)
+ expect(masked).not.toContain('fake')
+ expect(masked.match(/\bimport\b/g)).toHaveLength(2)
+})
+
+it('keeps TypeScript assertions and division as code rather than JSX or regex', () => {
+ const code =
+ 'const result = value / 2; const object = {x: 1}; import "real"'
+ expect(maskImportText(code, false)).toContain('value / 2')
+})
+
+it('handles JSX attribute expressions', () => {
+ const masked = maskImportText(
+ 'const node = import fake
;',
+ true,
+ )
+ expect(masked).toContain('import')
+ expect(masked).not.toContain('fake')
+})
+
+it('masks regex literals following control parentheses', () => {
+ expect(
+ maskImportText(
+ 'if (true) /import "fake"/.test(value); import "real"',
+ false,
+ ),
+ ).not.toContain('fake')
+})
diff --git a/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts
new file mode 100644
index 000000000..99ffec472
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts
@@ -0,0 +1,55 @@
+import { expect, it } from 'bun:test'
+
+import { scanImports } from '../import-scanner'
+
+it.each(
+ ['function f() {}', 'class C {}'].flatMap((body) =>
+ [
+ 'const x = 1\n',
+ 'async // marker\n',
+ 'const x = call()\n',
+ 'const x = []\n',
+ ].map((prefix) => ({
+ code: `${prefix}${body} /import("phantom")/.test(x); import("./real");`,
+ })),
+ ),
+)('masks regex after an ASI declaration in $code', ({ code }) => {
+ // Given: valid syntax, checked without evaluating the fixture.
+ new Bun.Transpiler({ loader: 'js' }).transformSync(code)
+ // When: scan through the real lexical boundary.
+ const actual = scanImports(code, false)
+ // Then: regex text adds no phantom dependency.
+ expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }])
+})
+
+it.each(
+ ['function f() {}', 'class C {}'].flatMap((body) =>
+ [
+ ['const value =\n', ';'],
+ ['const value = (\n', ');'],
+ ['const value = [\n', '];'],
+ ['const value = { field:\n', '};'],
+ ['const value = ok ?\n', ': 0;'],
+ ['call(0,\n', ');'],
+ ['function outer() { return ', '; }'],
+ ['function outer() { throw ', '; }'],
+ ['const value = void\n', ';'],
+ ['const value = typeof\n', ';'],
+ ['const value = delete\n', ';'],
+ ['const value = new\n', ';'],
+ ['const value = !\n', ';'],
+ ['const value = ~\n', ';'],
+ ['const value = +\n', ';'],
+ ['const value = -\n', ';'],
+ ].map(([prefix, suffix]) => ({
+ code: `${prefix}${body} / import("./real")${suffix}`,
+ })),
+ ),
+)('keeps literal imports in expression division in $code', ({ code }) => {
+ // Given: each expression prefix is real syntax, not a token-only example.
+ new Bun.Transpiler({ loader: 'js' }).transformSync(code)
+ // When: scan without executing the expression or resolving its import.
+ const actual = scanImports(code, false)
+ // Then: division retains the literal import dependency.
+ expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }])
+})
diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts
new file mode 100644
index 000000000..6d68cd7bd
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from 'bun:test'
+
+import { scanImports } from '../import-scanner'
+import { jsxBoundaryFixtures } from './jsx-boundary-fixtures'
+
+it.each([...jsxBoundaryFixtures])(
+ 'keeps independent edges when scanning $code',
+ ({ code, jsx, edges }) => {
+ // Given: syntax-checked source, never evaluated or used to derive expectations.
+ new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code)
+ // When: scan the original source.
+ const actual = scanImports(code, jsx).map(
+ ({ kind, specifier }) => `${kind}:${specifier}`,
+ )
+ // Then: only the fixture's independently named edges survive.
+ expect(actual).toEqual([...edges])
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts
new file mode 100644
index 000000000..e78ae7910
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts
@@ -0,0 +1,148 @@
+import { expect, it } from 'bun:test'
+
+import { scanImports } from '../import-scanner'
+
+it.each([
+ {
+ code: "const jsx = {import('phantom') && import('./real')}
;",
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: "const jsx = {(import('phantom') ) && import('./real')}
;",
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: "const jsx = {require('phantom') && require('./required')}
;",
+ edges: ['static:./required'],
+ },
+ {
+ code: "const jsx = {(require('phantom') ) && require('./required')}
;",
+ edges: ['static:./required'],
+ },
+ {
+ code: "const jsx = import('phantom')}>require('phantom'){import('./real')} ;",
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: "const jsx = require('phantom')}>import('phantom'){require('./required')} ;",
+ edges: ['static:./required'],
+ },
+ {
+ code: "const jsx = && require('./required'))} label=\"import('phantom')\">require('phantom'){import('./real')} ;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {ok ? import('phantom') : import('./real')}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {ok ? import('./real') : require('phantom') }{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {({node: import('phantom') , leaf: import('./real')})}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {[import('phantom') , import('./real'), require('./required')]}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {call(require('phantom') , import('./real'), require('./required'))}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {<>import('phantom') > && import('./real')}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = { && import('./real')}require('phantom'){require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {{import('phantom') && import('./real')}require('phantom') && require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = import('phantom')}>require('phantom'){import('./real')}}>import('phantom'){require('./required')} ;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = } next={import('./real')} label='require("phantom")'/>; require('./required');",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = import('phantom'), leaf: import('./real')}}>require('phantom'){require('./required')} ;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {(() => { const node = import('phantom') ; return import('./real') })()}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {(() => require('phantom') )() && import('./real')}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {`${import('phantom') && import('./real')} require('phantom')`}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "const jsx = {!import('phantom') && import('./real')}{require('./required')}
;",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "async function f() { return {await import('phantom') && import('./real')}{require('./required')}
}",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ {
+ code: "function* f() { return {(yield require('phantom') ) && import('./real')}{require('./required')}
}",
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+])(
+ 'keeps only real nested JSX edges when scanning $code',
+ ({ code, edges }) => {
+ // Given: valid TSX, checked without evaluating its imports or expressions.
+ new Bun.Transpiler({ loader: 'tsx' }).transformSync(code)
+ // When: scan the original source, not the compiler output.
+ const actual = scanImports(code, true)
+ .map((edge) => `${edge.kind}:${edge.specifier}`)
+ .sort()
+ // Then: fixture-derived dependencies exclude every text/quoted phantom.
+ expect(actual).toEqual([...edges])
+ },
+)
+
+it.each([
+ '~',
+ '+',
+ '-',
+ 'void ',
+ 'typeof ',
+ 'delete ',
+ '0 + ',
+ '0 - ',
+ '0 * ',
+ '0 / ',
+ '0 % ',
+ '0 ** ',
+ '0 ^ ',
+ '0 & ',
+ '0 | ',
+ '0 << ',
+ '0 > ',
+ '0 < ',
+ '0 in ',
+ '0 instanceof ',
+])('restores parent code when JSX follows expression prefix %s', (prefix) => {
+ // Given: valid unary/binary operand syntax surrounding nested JSX.
+ const code = `const jsx = {${prefix}import('phantom') && import('./real')}{require('./required')}
;`
+ new Bun.Transpiler({ loader: 'tsx' }).transformSync(code)
+ // When: scan the original operand expression.
+ const actual = scanImports(code, true)
+ // Then: expression operators neither leak text nor hide following dependencies.
+ expect(actual).toEqual([
+ { kind: 'dynamic', specifier: './real' },
+ { kind: 'static', specifier: './required' },
+ ])
+})
diff --git a/packages/plugin-utils/src/__tests__/import-scanner.test.ts b/packages/plugin-utils/src/__tests__/import-scanner.test.ts
new file mode 100644
index 000000000..dab92e615
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-scanner.test.ts
@@ -0,0 +1,147 @@
+import { expect, it } from 'bun:test'
+
+import { scanImports } from '../import-scanner'
+
+it.each([
+ {
+ code: 'export const result = `${import("./leaf")}`',
+ edges: ['dynamic:./leaf'],
+ },
+ {
+ code: 'export const result = `${require("./leaf")}`',
+ edges: ['static:./leaf'],
+ },
+ {
+ code: 'const s = `text ${ {a: `nested ${import("./a")}`, b: require("./b")} } tail import("fake")`',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'const s = tag`escaped \\` \\${import("fake")} ${ /[`"}]/.test("}") ? import("./a") : require("./b") } tail`',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'const s = `${ (() => { /* } ` */ // } `\n return import("./a") })() }`',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'const s = `${ import("fake"){import("./a")}
}`',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'const a = 10 / import("./a"); const b = /[`"\']import("fake")/.test(a); const c = require("./b") / 2;',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'const x = {a: 1} / import("./a"); x++ / require("./b"); --x / import("./c");',
+ edges: ['dynamic:./a', 'dynamic:./c', 'static:./b'],
+ },
+ {
+ code: 'if (x) { y() } /import("fake")/.test(x); while (x) /import("fake")/.test(x); import("./a")',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'const x = "import(\\"fake\\")"; /* require("fake") */ // import("fake")\n const y = <>require("fake")
{require("./b")} >;',
+ edges: ['static:./b'],
+ },
+ {
+ code: 'export*from"./a"; export{x}from"./b"; export * as ns from "./c"; import"./d";',
+ edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'],
+ },
+ {
+ code: 'import /*x*/ {x} /*y*/ from "./a"; require /*x*/ ( /*y*/ "./b" /*z*/ ); import("./c", {with:{type:"json"}});',
+ edges: ['dynamic:./c', 'static:./a', 'static:./b'],
+ },
+ {
+ code: 'import(name); require(name); import("./a" + name); require("./b" + name); import(`./c`); object.require("fake"); object?.import("fake");',
+ edges: [],
+ },
+ {
+ code: 'import(`${"fake"}`); require(`text ${"fake"}`); import(`text ${import("./a")}`);',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'import type {A} from "fake"; export type*from"fake"; import{type A,type B,}from"fake"; export{type A}from"fake"; import {type A,b} from "./a"; import Default,{type A}from"./b"; import type from "./c"; import{type as renamed}from"./d";',
+ edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'],
+ },
+ {
+ code: String.raw`import "./\x61"; export{x}from'./\u0062'; require('./\u{63}'); import("./quo\"te"); import('./quo\'te'); import('./back\\slash');`,
+ edges: [
+ 'dynamic:./quo"te',
+ "dynamic:./quo'te",
+ 'dynamic:./back\\slash',
+ 'static:./a',
+ 'static:./b',
+ 'static:./c',
+ ].sort(),
+ },
+ {
+ code: 'const s = `${ "}\\"" + /[}\\/`]/gi.test(x) + import("./a") } after`;',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'const s = `${ x in /import("fake")/ ? import("./a") : "" }`;',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'import from from "./a"; import {from} from "./b"; export {from as x} from "./c";',
+ edges: ['static:./a', 'static:./b', 'static:./c'],
+ },
+ {
+ code: 'import type, {value} from "./a"; import type, * as values from "./b"; import {"string-name" as name} from "./c"; export {name as "string-name"} from "./d";',
+ edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'],
+ },
+ {
+ code: 'const fn = function() {} / import("./a"); const C = class {} / require("./b");',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'try {} finally {} /import("fake")/.test(x); class C {} /require("fake")/.test(x); function f() {} /import("fake")/.test(x); import("./a");',
+ edges: ['dynamic:./a'],
+ },
+ {
+ code: 'async function f() {} /import("fake")/.test(x); const fn = async function() {} / import("./a"); const g = function(a={x:1}, b=()=>{}) { return function() {} / require("./b") };',
+ edges: ['dynamic:./a', 'static:./b'],
+ },
+ {
+ code: 'export async /* marker */ function f() {} /import("phantom")/.test(value); import("./real");',
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: 'const fn = async /*marker*/ function() {} / import("./real")',
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: 'async/*marker*//*next*/function f() {} /import("phantom")/.test(value); import("./real");',
+ edges: ['dynamic:./real'],
+ },
+ {
+ code: 'const value = async /*marker*/ / import("./real") / 2; /*next*/ function f() {}',
+ edges: ['dynamic:./real'],
+ },
+])('discovers only fixture-derived edges in $code', ({ code, edges }) => {
+ // Given: independent expected edge sets, including text that resembles imports.
+ // When: scan without executing source or consulting a parser.
+ const actual = scanImports(code, true)
+ .map((edge) => `${edge.kind}:${edge.specifier}`)
+ .sort()
+ // Then: only literal dependencies in code are edges.
+ expect(actual).toEqual([...edges])
+})
+
+it('preserves TypeScript angle assertions without interpreting them as JSX', () => {
+ const code = 'const value = input / 2; import("./leaf")'
+ expect(scanImports(code, false)).toEqual([
+ { kind: 'dynamic', specifier: './leaf' },
+ ])
+})
+
+it('decodes escaped control characters and line continuations without evaluation', () => {
+ const code = String.raw`require('a\n\r\t\b\f\v\0\z'); import('a\
+b'); import('a\
+b');`
+ expect(scanImports(code, false)).toEqual([
+ { kind: 'static', specifier: 'a\n\r\t\b\f\v\0z' },
+ { kind: 'dynamic', specifier: 'ab' },
+ { kind: 'dynamic', specifier: 'ab' },
+ ])
+})
diff --git a/packages/plugin-utils/src/__tests__/import-type-fixtures.ts b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts
new file mode 100644
index 000000000..34be6b3ed
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts
@@ -0,0 +1,108 @@
+export const importTypeFixtures: readonly {
+ readonly code: string
+ readonly jsx: boolean
+ readonly edges: readonly string[]
+}[] = [
+ ...[
+ "type X = typeof import('./types');",
+ "type X = import('./types').T;",
+ "export type X = typeof import('./types');",
+ "export type X = import('./types').T;",
+ "type X = {value: import('./types').T; loader: typeof import('./types')};",
+ "type X = T extends import('./types').T ? import('./types').U : never;",
+ "type X = import('./types').T | import('./other-types').T;",
+ "type X = (value: import('./types').T) => import('./types').U;",
+ "type X = `prefix${import('./types').T}`;",
+ "interface X {value: import('./types').T; loader: typeof import('./types')}",
+ "type X = typeof /* marker */ import /* next */ ('./types');",
+ "type X = typeof import('./types')\n",
+ ].map((prefix) => ({
+ code: `${prefix}\nconst x = typeof import('./real'); require('./required');`,
+ jsx: false,
+ edges: ['dynamic:./real', 'static:./required'],
+ })),
+ ...[
+ "const x: import('./types').T = import('./real');",
+ "const x: typeof import('./types') = typeof import('./real');",
+ "let x: Promise = import('./real');",
+ "const x: T extends U ? import('./types').T : never = import('./real');",
+ "const f = (x: () => import('./types').T = () => import('./real')) => import('./body');",
+ "const x = import('./real') as {fn: () => import('./types').T};",
+ "const x = import('./real') as (() => import('./types').T);",
+ "const x = f(import('./real'));",
+ "function f(x = import('./real')) {return import('./body')}",
+ "const f = (x = import('./real')) => import('./body');",
+ "const x = import('./real');",
+ "const x = a < import('./real');",
+ "class C extends mixin({value: import('./real')}) {f() {return import('./body')}}",
+ "type X = typeof import('./types')\nimport('./real');",
+ "type X = import('./types').T\nimport('./real');",
+ "function f(x: import('./types').T = import('./real')) {return import('./body')}",
+ "function f(x: import('./types').T = import('./real')): Promise {return import('./body')}",
+ "const f = (x: import('./types').T = import('./real')): Promise => import('./body');",
+ "function f({x}: import('./types').T = import('./real')) {return import('./body')}",
+ "class C {x: import('./types').T = import('./real'); f(x: import('./types').T) {return import('./body')}}",
+ "const x = import('./real') as import('./types').T;",
+ "const x = import('./real') satisfies import('./types').T;",
+ "const x = {type: typeof import('./real'), value: import('./body')};",
+ "const f = (x = typeof import('./real')) => import('./body');",
+ "const x = typeof import('./real');",
+ "const x = ok ? f() : typeof import('./real');",
+ "const x = ok ? f(import('./body')) : import('./real');",
+ "const x = ok ? f() : ((x = import('./real')) => x);",
+ "const as = (x) => x; const x = as(import('./real'));",
+ "const satisfies = (x) => x; const x = satisfies(import('./real'));",
+ "const x = {as: typeof import('./real')};",
+ "obj.class; const x = {value: import('./real')};",
+ "const x = {interface: import('./real'), const: import('./body')};",
+ "const x = import('./real') as import('./types').T && import('./body');",
+ "const x = import('./real') as number ? import('./body') : null;",
+ "const x = ok ? (f()) : import('./real');",
+ "const x = ok ? (value) : typeof import('./real');",
+ "const x = ok ? ((f(g()))) : import('./real');",
+ "const x = ok ? (f(import('./body'))) : import('./real');",
+ "const x = ok ? (value) : {loader: import('./real')};",
+ "const x = ok ? (value) : (() => import('./real'));",
+ "const x = ok ? (value) : (x) => import('./real');",
+ "const x = ok ? (value) : other ? (f()) : import('./real');",
+ "const x = ok ? (value) : typeof (import('./real'));",
+ "const x = ok ? (value) : (x = import('./real')): import('./types').T => x;",
+ "const x = ok ? f() : (x = import('./real')): import('./types').T => x;",
+ "const x = ok ? (value) : (): {loader: import('./types').T} => ({loader: import('./real')});",
+ "const x = {async f(): import('./types').T {return import('./real')}};",
+ "class C {async f(): import('./types').T {return import('./real')}}",
+ "const x = {async f(x: import('./types').T = import('./real')): Promise {return import('./body')}};",
+ "class C {static async f(x: import('./types').T = import('./real')): Promise {return import('./body')}}",
+ "const f = async (x: import('./types').T = import('./real')): Promise => import('./body');",
+ "async function f(x: import('./types').T = import('./real')): Promise {return import('./body')}",
+ "function f(): import('./types').T; function f() {return import('./real')}",
+ ].map((code) => ({
+ code,
+ jsx: false,
+ edges: code.includes('f(import')
+ ? ['dynamic:./body', 'dynamic:./real']
+ : code.includes('./body')
+ ? ['dynamic:./real', 'dynamic:./body']
+ : ['dynamic:./real'],
+ })),
+ {
+ code: "type X = import('./types').T",
+ jsx: false,
+ edges: [],
+ },
+ {
+ code: "type X = import('./types').T; const x = typeof import('./types');",
+ jsx: false,
+ edges: ['dynamic:./types'],
+ },
+ {
+ code: "const x: import('./types').T = {typeof import('./real')}import('phantom')
;",
+ jsx: true,
+ edges: ['dynamic:./real'],
+ },
+ ...[
+ "const x = {ok ? (f()) : import('./real')}import('phantom')
;",
+ "const x = {ok ? (value) : typeof import('./real')}
;",
+ "const x = {ok ? ((f(g()))) : import('./real')}
;",
+ ].map((code) => ({ code, jsx: true, edges: ['dynamic:./real'] })),
+]
diff --git a/packages/plugin-utils/src/__tests__/import-type-graph.test.ts b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts
new file mode 100644
index 000000000..504bf497f
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts
@@ -0,0 +1,90 @@
+import { join } from 'node:path'
+
+import { expect, it, mock } from 'bun:test'
+
+import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph'
+import { ConfigLoadError } from '../load-config'
+import { importTypeFixtures } from './import-type-fixtures'
+import { createPreparedFixture } from './prepared-graph-fixture'
+
+let root: string
+const file = createPreparedFixture((directory) => {
+ root = directory
+})
+
+it.each(
+ importTypeFixtures
+ .filter(({ edges }) => !edges.includes('dynamic:./types'))
+ .flatMap((fixture) =>
+ [false, true].map((prepared) => ({ ...fixture, prepared })),
+ ),
+)(
+ 'ignores poisoned type modules with prepared=$prepared when scanning $code',
+ async ({ code, jsx, edges, prepared }) => {
+ // Given: real runtime leaves and a malformed manifest for type-only requests.
+ const source = code
+ .replaceAll('./types', 'phantom')
+ .replaceAll('./other-types', 'phantom')
+ const compiled = new Bun.Transpiler({
+ loader: jsx ? 'tsx' : 'ts',
+ }).transformSync(source)
+ const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, source)
+ for (const name of ['real', 'required', 'body']) file(`src/${name}.ts`)
+ file('node_modules/phantom/package.json', '{')
+ const programRead = mock(() => ({
+ type: 'ImportExpression',
+ source: { value: 'phantom' },
+ }))
+ const parseSync = mock(() => ({
+ errors: [],
+ get program() {
+ return programRead()
+ },
+ }))
+ const options = {
+ cwd: root,
+ prepareSource: (filename: string) =>
+ prepared && filename === entry ? compiled : undefined,
+ }
+ // When: traverse the actual filesystem with diagnostics disabled and injected.
+ __setOxcParserForTest(false)
+ const disabled = await buildStaticImportGraph('src', undefined, options)
+ __setOxcParserForTest({ parseSync })
+ const injected = await buildStaticImportGraph('src', undefined, options)
+ // Then: only fixture-derived runtime edges load; no type/AST phantom is resolved.
+ expect(injected).toEqual(disabled)
+ const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic']
+ for (const kind of kinds) {
+ expect(
+ { static: injected.staticImports, dynamic: injected.dynamicImports }[
+ kind
+ ].get(entry),
+ ).toEqual(
+ new Set(
+ edges
+ .filter((edge) => edge.startsWith(`${kind}:`))
+ .map((edge) =>
+ join(root, 'src', `${edge.slice(kind.length + 3)}.ts`),
+ ),
+ ),
+ )
+ }
+ expect(injected.externalImports?.get(entry)).toEqual(new Set())
+ expect(parseSync).toHaveBeenCalledTimes(injected.files.length)
+ expect(programRead).not.toHaveBeenCalled()
+ },
+)
+
+it('reaches the poisoned manifest when typeof import is genuinely runtime code', () => {
+ // Given: the same poisoned dependency, in both a type and a runtime expression.
+ file(
+ 'src/main.ts',
+ "type X = typeof import('phantom'); const x = typeof import('phantom');",
+ )
+ file('node_modules/phantom/package.json', '{')
+ __setOxcParserForTest(false)
+ // When/Then: runtime dependency resolution must still reach the malformed manifest.
+ expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow(
+ ConfigLoadError,
+ )
+})
diff --git a/packages/plugin-utils/src/__tests__/import-type.test.ts b/packages/plugin-utils/src/__tests__/import-type.test.ts
new file mode 100644
index 000000000..2a16b4d48
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/import-type.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from 'bun:test'
+
+import { scanImports } from '../import-scanner'
+import { importTypeFixtures } from './import-type-fixtures'
+
+it.each([...importTypeFixtures])(
+ 'elides only type imports when scanning $code',
+ ({ code, jsx, edges }) => {
+ // Given: syntax-valid source and independently named runtime dependencies.
+ new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code)
+ // When: scan original source, never compiler output or evaluated modules.
+ const actual = scanImports(code, jsx).map(
+ ({ kind, specifier }) => `${kind}:${specifier}`,
+ )
+ // Then: types disappear while runtime typeof-import and initializers survive.
+ expect(actual).toEqual([...edges])
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts
new file mode 100644
index 000000000..c9b5ccfc3
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts
@@ -0,0 +1,79 @@
+export const jsxBoundaryFixtures: readonly {
+ readonly code: string
+ readonly jsx: boolean
+ readonly edges: readonly string[]
+}[] = [
+ ...[
+ 'ab',
+ 'a<>b',
+ 'a>>>b',
+ 'a>>=b',
+ 'a>>>=b',
+ '1 <(x)',
+ 'f>(x)',
+ 'new Map()',
+ 'a[0] ({
+ code: `const x = ${expression}; import('./real'); require('./required');`,
+ jsx: true,
+ edges: ['dynamic:./real', 'static:./required'],
+ })),
+ ...[
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '>(x: T)=>x',
+ '(x: T): T=>x',
+ '(x = import("./inside"))=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ '(x: T)=>x',
+ ].map((expression) => ({
+ code: `const fn = ${expression}; import('./real'); require('./required');`,
+ jsx: true,
+ edges: expression.includes('./inside')
+ ? ['dynamic:./inside', 'dynamic:./real', 'static:./required']
+ : ['dynamic:./real', 'static:./required'],
+ })),
+ {
+ code: "const x = value; type X = Map>; import('./real'); require('./required');",
+ jsx: false,
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+ ...[
+ { prefix: 'const x = ', suffix: ';' },
+ { prefix: 'function f(){return ', suffix: '}' },
+ { prefix: 'const x = (', suffix: ');' },
+ { prefix: 'const x = ok ? ', suffix: ' : null;' },
+ { prefix: 'const x = ok ? null : ', suffix: ';' },
+ { prefix: 'const x = ok && ', suffix: ';' },
+ { prefix: 'const x = ok || ', suffix: ';' },
+ { prefix: 'const x = {node: ', suffix: '};' },
+ { prefix: 'const x = [', suffix: '];' },
+ { prefix: 'const x = () => ', suffix: ';' },
+ { prefix: 'const x = 0 << ', suffix: ';' },
+ { prefix: 'const x = 0 <= ', suffix: ';' },
+ { prefix: 'const x = 0 === ', suffix: ';' },
+ { prefix: 'const x = ok ?? ', suffix: ';' },
+ { prefix: 'const x = 0 ** ', suffix: ';' },
+ ].map(({ prefix, suffix }) => ({
+ code: `${prefix}require('phantom'){require('./child')} ${suffix} import('./real');`,
+ jsx: true,
+ edges: ['dynamic:./attribute', 'static:./child', 'dynamic:./real'],
+ })),
+ {
+ code: "const jsx = import('phantom'){import('./real')} ; require('./required');",
+ jsx: true,
+ edges: ['dynamic:./real', 'static:./required'],
+ },
+]
diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts
new file mode 100644
index 000000000..0848bde9b
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts
@@ -0,0 +1,63 @@
+import { join } from 'node:path'
+
+import { expect, it, mock } from 'bun:test'
+
+import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph'
+import { jsxBoundaryFixtures } from './jsx-boundary-fixtures'
+import { createPreparedFixture } from './prepared-graph-fixture'
+
+let root: string
+const file = createPreparedFixture((directory) => {
+ root = directory
+})
+
+it.each([...jsxBoundaryFixtures])(
+ 'preserves expected filesystem graph edges when scanning $code',
+ async ({ code, jsx, edges }) => {
+ // Given: real leaves and a poisoned phantom dependency under a unique fixture root.
+ const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, code)
+ for (const name of ['real', 'required', 'inside', 'attribute', 'child'])
+ file(`src/${name}.ts`)
+ file('node_modules/phantom/package.json', '{')
+ const programRead = mock(() => ({
+ type: 'ImportDeclaration',
+ source: { value: 'phantom' },
+ }))
+ const parseSync = mock(() => ({
+ errors: [],
+ get program() {
+ return programRead()
+ },
+ }))
+ const options = {
+ cwd: root,
+ prepareSource: (filename: string) =>
+ jsx && filename === entry ? code : undefined,
+ }
+ // When: traverse the same prepared filesystem graph with diagnostics disabled and injected.
+ __setOxcParserForTest(false)
+ const disabled = await buildStaticImportGraph('src', undefined, options)
+ __setOxcParserForTest({ parseSync })
+ const injected = await buildStaticImportGraph('src', undefined, options)
+ // Then: each expected edge resolves independently, with no AST reads or phantom resolution.
+ expect(injected).toEqual(disabled)
+ const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic']
+ for (const kind of kinds) {
+ const expected = new Set(
+ edges
+ .filter((edge) => edge.startsWith(`${kind}:`))
+ .map((edge) =>
+ join(root, 'src', `${edge.slice(kind.length + 3)}.ts`),
+ ),
+ )
+ expect(
+ { static: injected.staticImports, dynamic: injected.dynamicImports }[
+ kind
+ ].get(entry),
+ ).toEqual(expected)
+ }
+ expect(injected.externalImports?.get(entry)).toEqual(new Set())
+ expect(parseSync).toHaveBeenCalledTimes(injected.files.length)
+ expect(programRead).not.toHaveBeenCalled()
+ },
+)
diff --git a/packages/plugin-utils/src/__tests__/load-config.test.ts b/packages/plugin-utils/src/__tests__/load-config.test.ts
index bc3510ba7..8f5c1fdf0 100644
--- a/packages/plugin-utils/src/__tests__/load-config.test.ts
+++ b/packages/plugin-utils/src/__tests__/load-config.test.ts
@@ -91,12 +91,11 @@ describe('loadDevupConfigSync', () => {
})
})
- it('should return empty object for invalid JSON', () => {
+ it('throws a located error for invalid JSON', () => {
const configPath = join(testDir, 'invalid.json')
writeFileSync(configPath, 'not valid json {{{')
- const result = loadDevupConfigSync(configPath)
- expect(result).toEqual({})
+ expect(() => loadDevupConfigSync(configPath)).toThrow(configPath)
})
it('should resolve extends', () => {
diff --git a/packages/plugin-utils/src/__tests__/mdx-errors.test.ts b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts
new file mode 100644
index 000000000..3ba25cb46
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts
@@ -0,0 +1,246 @@
+import { describe, expect, it } from 'bun:test'
+
+import { remapMdxError } from '../index'
+
+const filename = 'C:\\docs\\page.mdx'
+const map = {
+ version: 3,
+ sources: ['page.mdx', 'other.mdx'],
+ names: ['Box'],
+ mappings: ';EAIEA,KCEG;ADFH',
+}
+
+describe('MDX error locations', () => {
+ it.each([map, JSON.stringify(map)])(
+ 'maps multiline Windows locations with real VLQ deltas',
+ (input) => {
+ const error = new Error(
+ `${filename}:2:3: first\n${filename}:2:9: second\n${filename}:3:1: third\nother.ts:2:3: unchanged\nnote ${filename}:2:3: unchanged`,
+ )
+ const result = remapMdxError(error, filename, input)
+ expect(result.message).toBe(
+ 'page.mdx:5:3: first\nother.mdx:7:6: second\npage.mdx:5:3: third\nother.ts:2:3: unchanged\nnote C:\\docs\\page.mdx:2:3: unchanged',
+ )
+ expect(result.cause).toBe(error)
+ },
+ )
+ it.each([undefined, null])(
+ 'labels compiled coordinates immediately after the column when no map exists',
+ (input) => {
+ expect(
+ remapMdxError(
+ `${filename}:2:3: failure\n${filename}:4:5 details`,
+ filename,
+ input,
+ ).message,
+ ).toBe(
+ `${filename}:2:3 (in compiled MDX): failure\n${filename}:4:5 (in compiled MDX) details`,
+ )
+ },
+ )
+ it('does not change a message without a matching location', () => {
+ expect(remapMdxError(`${filename}:not a location`, filename).message).toBe(
+ `${filename}:not a location`,
+ )
+ })
+ it('labels gaps, before-first segments, unmapped segments and missing lines', () => {
+ const input = {
+ version: 3,
+ sources: ['page.mdx'],
+ mappings: ';EAAA,E;AAAA;;',
+ }
+ const text = [1, 2, 4, 8]
+ .map((line) => `${filename}:${line}:1: missing`)
+ .concat(`${filename}:2:5: gap`)
+ .join('\n')
+ expect(remapMdxError(text, filename, input).message).toBe(
+ text
+ .replace(/:(\d+):1:/g, ':$1:1 (in compiled MDX):')
+ .replace(':2:5:', ':2:5 (in compiled MDX):'),
+ )
+ })
+ it.each([
+ ['', 'page.mdx', 'page.mdx'],
+ ['/docs', 'page.mdx', '/docs/page.mdx'],
+ ['C:\\docs', 'page.mdx', 'C:\\docs\\page.mdx'],
+ [
+ 'https://example.com/docs/',
+ '../page.mdx',
+ 'https://example.com/page.mdx',
+ ],
+ ['/docs', '/absolute.mdx', '/absolute.mdx'],
+ ['/docs', 'C:\\absolute.mdx', 'C:\\absolute.mdx'],
+ ['/docs', 'webpack:///page.mdx', 'webpack:///page.mdx'],
+ ])('resolves sourceRoot %s and source %s', (sourceRoot, source, expected) => {
+ expect(
+ remapMdxError(`${filename}:1:1`, filename, {
+ version: 3,
+ mappings: 'AAAA',
+ sources: [source],
+ sourceRoot,
+ }).message,
+ ).toBe(`${expected}:1:1`)
+ })
+ it('decodes multi-digit VLQ fields and chooses the preceding mapping without inventing column offsets', () => {
+ expect(
+ remapMdxError(`${filename}:1:30`, filename, {
+ version: 3,
+ sources: ['page.mdx'],
+ mappings: 'gBAgBgB',
+ }).message,
+ ).toBe('page.mdx:17:17')
+ })
+ it('tracks name deltas across segments and preserves the original error cause chain', () => {
+ const cause = new TypeError('original')
+ const error = new Error(`${filename}:1:3`, { cause })
+ const result = remapMdxError(error, filename, {
+ version: 3,
+ sources: ['page.mdx'],
+ names: ['a', 'b'],
+ mappings: 'AAAAC,EAAAD',
+ })
+ expect(result.message).toBe('page.mdx:1:1')
+ expect(result.cause).toBe(error)
+ expect(error.cause).toBe(cause)
+ })
+ it.each([
+ '{',
+ 'null',
+ '[]',
+ '{}',
+ { version: 2, sections: [] },
+ { ...map, version: 2 },
+ { ...map, sources: [null] },
+ { ...map, names: [1] },
+ { ...map, sourceRoot: 1 },
+ ...[
+ '?',
+ 'g',
+ 'ggggggggggggA',
+ '////////////A',
+ 'AA',
+ 'AAA',
+ 'AAAAAA',
+ ',',
+ 'D',
+ 'EAAA,D',
+ 'ACAA',
+ 'ADAA',
+ 'AADA',
+ 'AAAD',
+ 'AAAAC',
+ 'AAAAD',
+ ].map((mappings) => ({ ...map, sources: ['page.mdx'], mappings })),
+ ])(
+ 'diagnoses invalid or unsupported maps with location and cause: %j',
+ (input) => {
+ let caught: unknown
+ try {
+ remapMdxError('failure', filename, input)
+ } catch (error) {
+ caught = error
+ }
+ expect(caught).toBeInstanceOf(Error)
+ if (!(caught instanceof Error)) throw caught
+ expect(caught.message).toStartWith(
+ `${filename}:1:1: Invalid MDX source map:`,
+ )
+ expect(caught.cause).toBeInstanceOf(Error)
+ },
+ )
+ it.each(['0:1', '1:0', '9007199254740992:1', '1:9007199254740992'])(
+ 'diagnoses invalid extractor coordinates %s',
+ (location) => {
+ expect(() => remapMdxError(`${filename}:${location}`, filename)).toThrow(
+ `${filename}:1:1:`,
+ )
+ },
+ )
+
+ it('remaps indexed maps across offsets, gaps and nested sections', () => {
+ const input = {
+ version: 3,
+ sections: [
+ {
+ offset: { line: 0, column: 2 },
+ map: { version: 3, sources: ['first.mdx'], mappings: 'AAAA;AACE' },
+ },
+ {
+ offset: { line: 1, column: 4 },
+ map: {
+ version: 3,
+ sections: [
+ {
+ offset: { line: 0, column: 0 },
+ map: {
+ version: 3,
+ sources: ['second.mdx'],
+ mappings: 'AAGE;AACA',
+ },
+ },
+ ],
+ },
+ },
+ { offset: { line: 3, column: 0 }, map: { version: 3, sections: [] } },
+ ],
+ }
+ expect(
+ remapMdxError(
+ `${filename}:1:3: first\n${filename}:2:5: second\n${filename}:3:1: nested\n${filename}:4:1: gap`,
+ filename,
+ JSON.stringify(input),
+ ).message,
+ ).toBe(
+ `first.mdx:1:1: first\nsecond.mdx:4:3: second\nsecond.mdx:5:3: nested\n${filename}:4:1 (in compiled MDX): gap`,
+ )
+ })
+
+ it('accepts an empty indexed map and labels the unmapped compiled position', () => {
+ expect(
+ remapMdxError(`${filename}:1:1: missing`, filename, {
+ version: 3,
+ sections: [],
+ }).message,
+ ).toBe(`${filename}:1:1 (in compiled MDX): missing`)
+ })
+
+ it.each([
+ { version: 3, sections: null },
+ { version: 3, sections: [null] },
+ { version: 3, sections: [{ offset: { line: -1, column: 0 } }] },
+ { version: 3, sections: [{ offset: { line: '0', column: 0 } }] },
+ { version: 3, sections: [{ offset: { line: 0, column: -1 } }] },
+ { version: 3, sections: [{ offset: { line: 0, column: '0' } }] },
+ { version: 3, sections: [{ offset: { line: 0, column: 0.5 } }] },
+ {
+ version: 3,
+ sections: [0, 0].map(() => ({
+ offset: { line: 0, column: 0 },
+ map: { version: 3, sections: [] },
+ })),
+ },
+ {
+ version: 3,
+ sections: [1, 0].map((line) => ({
+ offset: { line, column: 0 },
+ map: { version: 3, sections: [] },
+ })),
+ },
+ {
+ version: 3,
+ sections: [
+ {
+ offset: { line: 0, column: Number.MAX_SAFE_INTEGER },
+ map: { version: 3, sources: ['page.mdx'], mappings: 'CAAA' },
+ },
+ ],
+ },
+ ])(
+ 'diagnoses invalid indexed maps with the original filename: %j',
+ (input) => {
+ expect(() => remapMdxError(`${filename}:1:1`, filename, input)).toThrow(
+ `${filename}:1:1: Invalid MDX source map:`,
+ )
+ },
+ )
+})
diff --git a/packages/plugin-utils/src/__tests__/module-alias.test.ts b/packages/plugin-utils/src/__tests__/module-alias.test.ts
new file mode 100644
index 000000000..7c7a50d24
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/module-alias.test.ts
@@ -0,0 +1,223 @@
+import * as fs from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+import { afterEach, beforeEach, expect, it } from 'bun:test'
+
+import { buildStaticImportGraph, createModuleResolver } from '../import-graph'
+
+const installedRequire = createRequire(
+ resolve(
+ 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json',
+ ),
+)
+const enhancedResolve = installedRequire('enhanced-resolve')
+let root: string
+let entry: string
+beforeEach(() => {
+ const parent = join(
+ tmpdir(),
+ 'opencode',
+ 'workers',
+ 'w20-plugins-core',
+ 'shared-api',
+ )
+ fs.mkdirSync(parent, { recursive: true })
+ root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'alias-')))
+ entry = file('src/main.ts')
+})
+afterEach(() => fs.rmSync(root, { recursive: true, force: true }))
+function file(path: string, source = 'export {}'): string {
+ const target = join(root, path)
+ fs.mkdirSync(dirname(target), { recursive: true })
+ fs.writeFileSync(target, source)
+ return target
+}
+
+function installed(
+ request: string,
+ alias: Readonly>,
+ conditions: readonly string[] = ['import', 'browser'],
+): string | undefined {
+ const resolver = enhancedResolve.ResolverFactory.createResolver({
+ fileSystem: fs,
+ useSyncFileSystemCalls: true,
+ extensions: ['.js'],
+ alias,
+ conditionNames: conditions,
+ })
+ try {
+ return resolver.resolveSync({}, dirname(entry), request) || undefined
+ } catch (cause) {
+ if (cause instanceof Error && /Can't resolve/.test(cause.message))
+ return undefined
+ throw cause
+ }
+}
+
+it('matches installed resolver declaration order, exact keys and subpath boundaries', () => {
+ const broad = file('broad/index.js')
+ const broadSub = file('broad/feature.js')
+ const specific = file('specific.js')
+ const dir = dirname(broad)
+ const cases = [
+ {
+ request: '@x/feature',
+ alias: { '@x': dir, '@x/feature': specific },
+ expected: broadSub,
+ },
+ {
+ request: '@x/feature',
+ alias: { '@x/feature': specific, '@x': dir },
+ expected: specific,
+ },
+ {
+ request: '@x',
+ alias: { '@x$': specific, '@x': dir },
+ expected: specific,
+ },
+ {
+ request: '@x/feature',
+ alias: { '@x$': specific, '@x': dir },
+ expected: broadSub,
+ },
+ { request: '@xyz', alias: { '@x': dir }, expected: undefined },
+ { request: '@x', alias: { '@x': dir }, expected: broad },
+ ]
+ for (const { request, alias, expected } of cases) {
+ expect(installed(request, alias)).toBe(expected)
+ expect(
+ createModuleResolver({ cwd: root, alias })(request, entry)?.path,
+ ).toBe(expected)
+ }
+})
+
+it.each([
+ { conditions: ['browser', 'import'] },
+ { conditions: ['import'] },
+ { conditions: [] },
+])(
+ 'matches package exports conditions after alias rewrite (%j)',
+ ({ conditions }) => {
+ file(
+ 'node_modules/target/package.json',
+ JSON.stringify({
+ exports: {
+ '.': {
+ browser: './browser.js',
+ import: './import.js',
+ default: './default.js',
+ },
+ './feature': './feature.js',
+ },
+ }),
+ )
+ const browser = file('node_modules/target/browser.js')
+ const imported = file('node_modules/target/import.js')
+ const fallback = file('node_modules/target/default.js')
+ const feature = file('node_modules/target/feature.js')
+ const alias = { '@x': 'target' }
+ const expected = conditions.includes('browser')
+ ? browser
+ : conditions.includes('import')
+ ? imported
+ : fallback
+ expect(installed('@x', alias, conditions)).toBe(expected)
+ expect(
+ createModuleResolver({ cwd: root, alias, conditions })('@x', entry)?.path,
+ ).toBe(expected)
+ expect(installed('@x/feature', alias, conditions)).toBe(feature)
+ expect(
+ createModuleResolver({ cwd: root, alias, conditions })(
+ '@x/feature',
+ entry,
+ )?.path,
+ ).toBe(feature)
+ },
+)
+
+it('matches missing-target terminal failure even when the original and a later alias exist', () => {
+ file('node_modules/original/package.json', '{"main":"index.js"}')
+ file('node_modules/original/index.js')
+ const later = file('later.js')
+ const alias = { original: join(root, 'missing'), original$: later }
+ expect(installed('original', alias)).toBeUndefined()
+ expect(() =>
+ createModuleResolver({ cwd: root, alias })('original', entry),
+ ).toThrow(
+ `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify([alias.original])}`,
+ )
+})
+
+it('matches finite chains and self-alias skips', () => {
+ const target = file('target.js')
+ const alias = { '@a': '@a', '@a/feature': '@b', '@b': target }
+ expect(installed('@a/feature', alias)).toBe(target)
+ expect(
+ createModuleResolver({ cwd: root, alias })('@a/feature', entry)?.path,
+ ).toBe(target)
+})
+
+it('fails alias cycles at the real importer location', () => {
+ const alias = { '@a': '@b', '@b': '@a' }
+ expect(() => installed('@a', alias)).toThrow()
+ expect(() => createModuleResolver({ cwd: root, alias })('@a', entry)).toThrow(
+ `${entry}:1:1`,
+ )
+})
+
+it('matches installed self-target subpath guards without an unbounded rewrite', () => {
+ file(
+ 'node_modules/pkg/package.json',
+ '{"exports":{"./feature":"./feature.js"}}',
+ )
+ const feature = file('node_modules/pkg/feature.js')
+ const alias = { pkg: 'pkg/feature' }
+ expect(installed('pkg', alias)).toBe(feature)
+ expect(createModuleResolver({ cwd: root, alias })('pkg', entry)?.path).toBe(
+ feature,
+ )
+})
+
+it('follows the actual aliased included-package closure rather than the alias name', () => {
+ fs.writeFileSync(entry, "import 'provider'")
+ file('node_modules/actual/package.json', '{"main":"index.js"}')
+ const provider = file(
+ 'node_modules/actual/index.js',
+ "import './nested/value.generated'",
+ )
+ const leaf = file('node_modules/actual/nested/value.generated.mjs')
+ const graph = buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias: { provider: 'actual' },
+ include: ['actual'],
+ })
+ expect(graph.files).toEqual([entry, provider, leaf].sort())
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ alias: { provider: 'actual' },
+ }).files,
+ ).toEqual([entry])
+})
+
+it('follows an absolute alias into the actual included package and honors its exclusions', () => {
+ fs.writeFileSync(entry, "import 'provider'")
+ file('node_modules/actual/package.json', '{"main":"index.js"}')
+ const provider = file(
+ 'node_modules/actual/index.js',
+ "import './nested/leaf'",
+ )
+ const leaf = file('node_modules/actual/nested/leaf.mjs')
+ const options = { cwd: root, alias: { provider }, include: ['actual'] }
+ expect(buildStaticImportGraph('src', undefined, options).files).toEqual(
+ [entry, provider, leaf].sort(),
+ )
+ expect(
+ buildStaticImportGraph('src', undefined, {
+ ...options,
+ exclude: [dirname(leaf)],
+ }).files,
+ ).toEqual([entry, provider].sort())
+})
diff --git a/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts
new file mode 100644
index 000000000..f2d37040f
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts
@@ -0,0 +1,79 @@
+import { readFileSync } from 'node:fs'
+
+import { expect, it, mock } from 'bun:test'
+
+import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph'
+import { ConfigLoadError } from '../load-config'
+import { createPreparedFixture } from './prepared-graph-fixture'
+
+let root: string
+const file = createPreparedFixture((directory) => {
+ root = directory
+})
+
+it.each(
+ [
+ "const jsx = {import('phantom') && import('./real')}{require('phantom') && require('./required')}
;",
+ "const jsx = {(import('phantom') ) && import('./real')}{(require('phantom') ) && require('./required')}
;",
+ "const jsx = import('phantom')} next={require('phantom') }>require('phantom'){import('./real')}{require('./required')} ;",
+ ].flatMap((code) =>
+ [false, true].flatMap((enabled) =>
+ [false, true].map((prepared) => ({ code, enabled, prepared })),
+ ),
+ ),
+)(
+ 'resolves only real nested JSX files with diagnostics=$enabled prepared=$prepared in $code',
+ async ({ code, enabled, prepared }) => {
+ // Given: independently created targets and a manifest that fails if resolved.
+ new Bun.Transpiler({ loader: 'tsx' }).transformSync(code)
+ const entry = file('src/main.tsx', code)
+ const real = file('src/real.ts', 'export const real = 1')
+ const required = file('src/required.ts', 'export const required = 2')
+ file('node_modules/phantom/package.json', '{')
+ const programRead = mock(() => ({
+ type: 'ImportExpression',
+ source: { value: 'phantom' },
+ }))
+ const parseSync = mock(() => ({
+ errors: [],
+ get program() {
+ return programRead()
+ },
+ }))
+ const loaded: string[] = []
+ __setOxcParserForTest(enabled ? { parseSync } : false)
+ // When: traverse the real filesystem, never executing a fixture module.
+ const graph = prepared
+ ? await buildStaticImportGraph('src', undefined, {
+ cwd: root,
+ prepareSource: (filename: string) => {
+ loaded.push(filename)
+ return readFileSync(filename, 'utf8')
+ },
+ })
+ : buildStaticImportGraph('src', undefined, { cwd: root })
+ // Then: real edges load real files; no textual/AST phantom reaches resolution.
+ expect(graph.dynamicImports.get(entry)).toEqual(new Set([real]))
+ expect(graph.staticImports.get(entry)).toEqual(new Set([required]))
+ expect(graph.staticImporters.get(required)).toEqual(new Set([entry]))
+ expect(graph.dynamicTargets).toEqual(new Set([real]))
+ expect(graph.files).toEqual([entry, real, required].sort())
+ expect(graph.externalImports?.get(entry)).toEqual(new Set())
+ expect(loaded.sort()).toEqual(
+ prepared ? [entry, real, required].sort() : [],
+ )
+ expect(parseSync).toHaveBeenCalledTimes(enabled ? 3 : 0)
+ expect(programRead).not.toHaveBeenCalled()
+ },
+)
+
+it('rejects the poisoned manifest when an actual literal edge requests phantom', () => {
+ // Given: the same poison is reachable by a real dependency, not excluded.
+ file('src/main.tsx', "import('phantom')")
+ file('node_modules/phantom/package.json', '{')
+ __setOxcParserForTest(false)
+ // When/Then: resolution of that real edge must fail.
+ expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow(
+ ConfigLoadError,
+ )
+})
diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts
new file mode 100644
index 000000000..86d7d09df
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/numbering.test.ts
@@ -0,0 +1,186 @@
+import * as fs from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+ spyOn,
+} from 'bun:test'
+
+import { listSourceFiles } from '../import-graph'
+import {
+ collectNumberedFiles,
+ extractedNeedles,
+ seedFileNumbers,
+} from '../numbering'
+
+describe('collectNumberedFiles', () => {
+ let root: string
+
+ const write = (path: string) => {
+ fs.mkdirSync(dirname(join(root, path)), { recursive: true })
+ fs.writeFileSync(join(root, path), 'export {}')
+ }
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(join(tmpdir(), 'devup-ui-numbering-'))
+ write('src/b.tsx')
+ write('src/a.tsx')
+ write('src/nested/c.ts')
+ write('src/readme.md')
+ write('node_modules/@acme/ui/Button.tsx')
+ write('node_modules/@acme/ui/node_modules/dep/skipped.tsx')
+ })
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true })
+ })
+
+ it('lists the project source and the included packages in path order', () => {
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src'), join(root, 'missing')],
+ include: ['@acme/ui', 'not-installed'],
+ cwd: root,
+ })
+ expect(
+ files.map((file) => file.slice(file.indexOf('devup-ui-numbering-'))),
+ ).toEqual(
+ [...files]
+ .sort()
+ .map((file) => file.slice(file.indexOf('devup-ui-numbering-'))),
+ )
+ expect(files.filter((file) => file.includes('/src/'))).toHaveLength(3)
+ expect(files.some((file) => file.endsWith('/@acme/ui/Button.tsx'))).toBe(
+ true,
+ )
+ expect(files.some((file) => file.includes('skipped'))).toBe(false)
+ })
+
+ it('numbers only the files that mention what the build extracts', () => {
+ fs.writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'")
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src')],
+ cwd: root,
+ needles: extractedNeedles('@devup-ui/react', {
+ '@emotion/styled': 'styled',
+ }),
+ toId: (path) => path.slice(root.length).replaceAll('\\', '/'),
+ })
+ expect(files).toEqual(['/src/a.tsx'])
+ expect(extractedNeedles('p', { a: 1, b: 2 })).toEqual([
+ 'p',
+ '@stylexjs/stylex',
+ 'a',
+ 'b',
+ ])
+ })
+ it('does not depend on the order of the roots', () => {
+ const first = collectNumberedFiles({
+ roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')],
+ cwd: root,
+ })
+ const second = collectNumberedFiles({
+ roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')],
+ cwd: root,
+ })
+ expect(first).toEqual(second)
+ })
+
+ it.each([undefined, false, true])(
+ 'numbers project and included-package MDX only when includeMdx is true (%s)',
+ (includeMdx) => {
+ // Given source and package candidates with matching extraction needles.
+ write('src/docs/page.mdx')
+ write('node_modules/@acme/ui/Guide.mdx')
+ for (const path of [
+ 'src/a.tsx',
+ 'src/docs/page.mdx',
+ 'node_modules/@acme/ui/Button.tsx',
+ 'node_modules/@acme/ui/Guide.mdx',
+ ]) {
+ fs.writeFileSync(
+ join(root, path),
+ "import '@devup-ui/react'\n# Raw MDX",
+ )
+ }
+
+ // When numbering uses the plugin's deterministic extraction IDs.
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src'), join(root, 'src/docs')],
+ include: ['@acme/ui'],
+ cwd: root,
+ needles: ['@devup-ui/react'],
+ ...(includeMdx === undefined ? {} : { includeMdx }),
+ toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`,
+ })
+
+ // Then only explicit MDX extraction admits the raw MDX candidates.
+ expect(files).toEqual(
+ includeMdx
+ ? [
+ 'id:/node_modules/@acme/ui/Button.tsx',
+ 'id:/node_modules/@acme/ui/Guide.mdx',
+ 'id:/src/a.tsx',
+ 'id:/src/docs/page.mdx',
+ ]
+ : ['id:/node_modules/@acme/ui/Button.tsx', 'id:/src/a.tsx'],
+ )
+ },
+ )
+
+ it('excludes raw MDX before reading source for extraction needles', () => {
+ // Given uncompiled MDX that remains visible to the standalone scanner.
+ write('src/page.mdx')
+ fs.writeFileSync(
+ join(root, 'src/page.mdx'),
+ "import '@devup-ui/react'\n# <",
+ )
+ expect(
+ listSourceFiles(join(root, 'src'), [], { includeMdx: true }),
+ ).toContain(join(root, 'src/page.mdx'))
+ const read = spyOn(fs, 'readFileSync')
+ try {
+ // When a caller uses the default non-MDX extraction set.
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src')],
+ needles: ['@devup-ui/react'],
+ })
+
+ // Then no MDX source read occurs and no MDX ID is numbered.
+ expect(files).toEqual([])
+ expect(
+ read.mock.calls.some(([path]) => path === join(root, 'src/page.mdx')),
+ ).toBe(false)
+ } finally {
+ read.mockRestore()
+ }
+ })
+
+ it('names files the way the plugin extracts them', () => {
+ const files = collectNumberedFiles({
+ roots: [join(root, 'src')],
+ cwd: root,
+ toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`,
+ })
+ expect(files).toEqual([
+ 'id:/src/a.tsx',
+ 'id:/src/b.tsx',
+ 'id:/src/nested/c.ts',
+ ])
+ })
+})
+
+describe('seedFileNumbers', () => {
+ it('seeds files, and nothing when there are none', () => {
+ const seedFileMap = mock()
+ seedFileNumbers({ seedFileMap }, [])
+ expect(seedFileMap).not.toHaveBeenCalled()
+ seedFileNumbers({ seedFileMap }, ['a'])
+ expect(seedFileMap).toHaveBeenCalledWith(['a'])
+ })
+})
diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts
new file mode 100644
index 000000000..bcfb9e530
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts
@@ -0,0 +1,122 @@
+export const oracleScannerFixtures: readonly {
+ readonly code: string
+ readonly loader: 'js' | 'jsx' | 'ts' | 'tsx'
+ readonly edges: readonly string[]
+}[] = [
+ ...[
+ "const x = a < import('./real') > (b);",
+ "const x = a < import('./real') > b;",
+ "const x = a < import('./real') > (import('./body'));",
+ "const x = a < import('./real') > (b) / 2;",
+ "const x = a < import('./real') > (b); const node = import('phantom'){require('./required')}
;",
+ ].map((code) => ({
+ code,
+ loader: code.includes('') ? ('jsx' as const) : ('js' as const),
+ edges: code.includes('./required')
+ ? ['dynamic:./real', 'static:./required']
+ : code.includes('./body')
+ ? ['dynamic:./real', 'dynamic:./body']
+ : ['dynamic:./real'],
+ })),
+ ...[
+ "type X = string\nfoo(import('./real'));",
+ "type X = string\nvoid import('./real');",
+ "type X = string\n(() => import('./real'))();",
+ "type X = import('./types').T\nfoo(import('./real'));",
+ "type X = {x: import('./types').T}\n(() => import('./real'))();",
+ "type X = string /* newline\n */ foo(import('./real'));",
+ "type X =\n import('./types').T\n | import('./types').U\nfoo(import('./real'));",
+ "type X = T\n & import('./types').T\nvoid import('./real');",
+ "type X = import('./types').T\n['x']\nfoo(import('./real'));",
+ "type X = T extends\n import('./types').T\n ? import('./types').U\n : import('./types').V\nfoo(import('./real'));",
+ "type X = (x: import('./types').T)\n => import('./types').U\nfoo(import('./real'));",
+ "type X = \n(x: T) => import('./types').U\nfoo(import('./real'));",
+ "type X = typeof import('./types')\nfoo(import('./real'));",
+ "type X = keyof\n import('./types').T\nvoid import('./real');",
+ "type X = new\n(x: import('./types').T) => import('./types').U\nfoo(import('./real'));",
+ "let x: import('./types').T\nfoo(import('./real'));",
+ "const x = value as import('./types').T\nvoid import('./real');",
+ "const x = foo! as import('./types').T; import('./real');",
+ "const x = foo! satisfies import('./types').T; import('./real');",
+ "const x = import('./real')! as import('./types').T;",
+ "const x = import('./real')! satisfies import('./types').T;",
+ "const x = !import('./real') as import('./types').T;",
+ "const x = 'text' as import('./types').T; import('./real');",
+ "const x = {} satisfies import('./types').T; import('./real');",
+ "const x = foo! / import('./real');",
+ "const x = !import('./real');",
+ "const x = import('./real') as import('./types').T;",
+ "declare function f(): import('./types').T; import('./real');",
+ "declare function f(): import('./types').T; import('./real');",
+ "declare function f>(x: import('./types').T): import('./types').T; import('./real');",
+ "declare function f(): import('./types').T\nimport('./real');",
+ "function f(): import('./types').T; function f() {return import('./real')}",
+ "function f(x: import('./types').T = import('./real')): import('./types').T {return import('./body')}",
+ "const f = (x = import('./real')): import('./types').T => import('./body');",
+ "const x = f(import('./real'));",
+ "const x = ok ? (f()) : import('./real');",
+ "const x = a < import('./real') > b;",
+ "const x = import('./real');",
+ "const x =