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 -

- Build Status -

+`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. -

- Tutorial - | - Chat -

+`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 = value / import('./real');", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + ...[ + "const x = foo! as import('./types').T;", + "const x = foo! satisfies import('./types').T;", + "declare function f(): import('./types').T;", + "declare function f>(): import('./types').T;", + "declare function f(): import('./types').T", + "const x = a < import('./types') > (b);", + "import { type as } from './types';", + "export { type as } from './types';", + "import { type as as as } from './types';", + "export { type as as as } from './types';", + "import { type as as renamed } from './types';", + "export { type as as renamed } from './types';", + "import {type A, type as,} from './types';", + "export {type as as 'renamed'} from './types';", + ].map((code) => ({ code, loader: 'ts' as const, edges: [] })), + ...[ + "async function f(){for await(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for /*marker*/ await /*next*/ (const x of xs) /require('phantom')/.test(x); import('./real');}", + "async function f(){for //marker\n await (const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of xs) import('./real');}", + "async function f(){for await(const x of xs) {import('./real');} /import('phantom')/.test(xs);}", + "async function f(){const x = await value / import('./real');}", + "async function f(){const x = await (value) / import('./real');}", + "async function f(){for(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of import('./real')) /import('phantom')/.test(x);}", + "async function f(){for await(const x of xs) constCall(import('./real'));}", + ].map((code) => ({ code, loader: 'js' as const, edges: ['dynamic:./real'] })), + ...[ + "import {type as renamed} from './required';", + "export {type as renamed} from './required';", + "import {type as as} from './required';", + "export {type as as} from './required';", + "import {type} from './required';", + "import {from} from './required';", + "import {'type' as renamed} from './required';", + "export {type as 'as'} from './required';", + "import {type A, type as renamed} from './required';", + "export {type as, value} from './required';", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: ['static:./required'], + })), + { + code: "const f = (x = import('./real')) =>
import('phantom'){require('./required')}
;", + loader: 'tsx', + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const x = `import('phantom') ${
import('phantom'){import('./real')}
}`; import(name); require('./' + name);", + loader: 'jsx', + edges: ['dynamic:./real'], + }, +] diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts new file mode 100644 index 000000000..65b1f624e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts @@ -0,0 +1,117 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + oracleScannerFixtures.flatMap((fixture) => + [false, true].map((prepared) => ({ ...fixture, prepared })), + ), +)( + 'resolves independent real paths without poisoned false edges when $loader prepared=$prepared: $code', + async ({ code, loader, edges, prepared }) => { + // Given: real source-language filenames, live leaves, and poisoned phantom manifests. + const source = code.replaceAll('./types', 'phantom') + const entry = file(`src/main.${loader}`, 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() + }, + })) + // When: scan raw or same-language prepared source with diagnostics disabled/injected. + const options = { cwd: root } + const preparedOptions = { + ...options, + prepareSource: (filename: string) => + filename === entry ? source : undefined, + } + __setOxcParserForTest(false) + const disabled = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + // Then: full graph parity, independent expected paths, and zero AST authority. + expect(injected).toEqual(disabled) + for (const kind of ['static', 'dynamic'] as const) { + 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('keeps JavaScript comparison operands under a real compiled Markdown filename', async () => { + // Given: compiled JSX under the original custom extension, never a renamed shim. + const entry = file('src/page.mdown', '# source') + const real = file('src/real.ts') + file('node_modules/phantom/package.json', '{') + const code = + "const x = a < import('./real') > (b); const node =
import('phantom')
;" + const programRead = mock(() => ({})) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: (filename: string) => + filename === entry ? code : undefined, + } + // When: traverse compiled JS/JSX with both optional diagnostic modes. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: the real path and dynamic operand survive; quoted JSX text stays masked. + expect(injected).toEqual(disabled) + expect(injected.dynamicImports.get(entry)).toEqual(new Set([real])) + expect(injected.staticImports.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() +}) + +it('reaches a poisoned manifest when an ambiguous type-named binding is a value', () => { + // Given: the value spelling adjacent to the type-only as countercases. + file('src/main.ts', "import {type as renamed} from 'phantom';") + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: a genuine value dependency still reaches manifest validation. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts new file mode 100644 index 000000000..4eacba67c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it.each([...oracleScannerFixtures])( + 'preserves language-sensitive runtime edges when scanning $loader: $code', + ({ code, loader, edges }) => { + // Given: syntax-only compilation and independently specified dependencies. + new Bun.Transpiler({ loader }).transformSync(code) + // When: scan the original source in its actual language, without executing it. + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + // Then: lexical/type-only imports disappear and runtime operands survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts new file mode 100644 index 000000000..59b81afd4 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts @@ -0,0 +1,28 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { recordPackagedConfigInputs } from '../packaged-config-inputs' +import { createResolutionInputs } from '../resolution-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records only preceding missing package directories when Node selects a config', () => { + // Given a native public lookup resolving below an absent nearer package directory. + const importer = file('nested/tsconfig.json', '{}') + file('node_modules/preset/base.json', '{}') + const resolved = createRequire(importer).resolve('preset/base.json') + const inputs = createResolutionInputs() + // When recording public search-directory checks for the completed native lookup. + recordPackagedConfigInputs({ importer, request: 'preset', resolved }, inputs) + // Then no Node-internal manifest or in-package probe has been invented. + expect(inputs.snapshot()).toEqual({ + fileDependencies: [], + missingDependencies: [join(root, 'nested/node_modules/preset')], + }) +}) diff --git a/packages/plugin-utils/src/__tests__/parser-corpus.test.ts b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts new file mode 100644 index 000000000..abdc1884a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts @@ -0,0 +1,183 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { extname, join, relative, resolve } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + listSourceFiles, +} from '../import-graph' +import { scanImports } from '../import-scanner' +import { importTypeFixtures } from './import-type-fixtures' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it('keeps all package, landing and real compiled docs edges parser-independent', async () => { + // Given: actual repository sources and the landing compiler/provider configuration. + const cwd = resolve(import.meta.dir, '../../../..') + const projectRequire = createRequire(join(cwd, 'apps/landing/package.json')) + const compilerRequire = createRequire( + projectRequire.resolve('@mdx-js/loader'), + ) + const { + compile, + }: { + readonly compile: ( + input: { readonly path: string; readonly value: string | undefined }, + options: { readonly providerImportSource: string }, + ) => Promise<{ readonly value: string }> + } = compilerRequire('@mdx-js/mdx') + const roots = readdirSync(join(cwd, 'packages'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(cwd, 'packages', entry.name, 'src')) + const landing = join(cwd, 'apps/landing/src') + roots.push(landing) + const exclude = [ + '__tests__', + '__regression__', + '__fixtures__', + 'dist', + 'pkg', + 'df', + 'generated', + ] + const selection = { includeMdx: ['.mdx'] } + const files = [ + ...new Set( + roots.flatMap((root) => listSourceFiles(root, exclude, selection)), + ), + ].sort() + const code = new Map( + files.map((filename) => [filename, readFileSync(filename, 'utf8')]), + ) + const docs = files.filter((filename) => /\.mdx?$/.test(filename)) + for (const filename of docs) { + const compiled = await compile( + { path: filename, value: code.get(filename) }, + { providerImportSource: '@/mdx-components' }, + ) + code.set(filename, String(compiled)) + } + const options = { + cwd, + exclude, + ...selection, + prepareSource: (filename: string) => + /\.mdx?$/.test(filename) ? code.get(filename) : undefined, + } + const programRead = mock(() => ({ + type: 'ImportDeclaration', + source: { value: 'corpus-phantom' }, + })) + const parseSync = mock((filename: string, source: string) => { + expect(code.get(filename)).toBe(source) + return { + errors: [], + get program() { + return programRead() + }, + } + }) + try { + // When: run the real graph/resolution path both without and with optional diagnostics. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph(roots, undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph(roots, undefined, options) + // Then: compare every edge, not just aggregate counts; prove the optional path ran. + expect(injected).toEqual(disabled) + expect(injected.files).toEqual(files) + expect(parseSync.mock.calls.map(([filename]) => filename).sort()).toEqual( + files, + ) + expect(programRead).not.toHaveBeenCalled() + const packages = files.filter((filename) => !filename.startsWith(landing)) + const app = files.filter( + (filename) => filename.startsWith(landing) && !docs.includes(filename), + ) + expect(packages.length).toBeGreaterThan(0) + expect(app.length).toBeGreaterThan(0) + expect(docs.length).toBeGreaterThan(0) + for (const filename of docs) { + expect(disabled.externalImports?.get(filename)).toContain( + 'react/jsx-runtime', + ) + expect(disabled.externalImports?.get(filename)).toContain( + '@/mdx-components', + ) + } + const entries = files.map((filename) => ({ + file: relative(cwd, filename).replaceAll('\\', '/'), + extension: extname(filename), + static: [...(disabled.staticImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + dynamic: [...(disabled.dynamicImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + external: [...(disabled.externalImports?.get(filename) ?? [])].sort(), + })) + const artifact = { + roots: roots.map((root) => relative(cwd, root).replaceAll('\\', '/')), + exclude, + filter: + 'JS/TS source extensions plus .mdx; test/spec files and node_modules omitted', + compiler: + '@mdx-js/mdx via landing @mdx-js/loader; providerImportSource=@/mdx-components; no remark/rehype plugins', + counts: { + packageSources: packages.length, + landingSources: app.length, + compiledDocs: docs.length, + total: files.length, + diagnosticCalls: parseSync.mock.calls.length, + programReads: programRead.mock.calls.length, + staticEdges: entries.reduce( + (sum, entry) => sum + entry.static.length, + 0, + ), + dynamicEdges: entries.reduce( + (sum, entry) => sum + entry.dynamic.length, + 0, + ), + externalEdges: entries.reduce( + (sum, entry) => sum + entry.external.length, + 0, + ), + }, + parity: true, + targeted: jsxBoundaryFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + typeTargeted: importTypeFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + oracleTargeted: oracleScannerFixtures.map(({ code, loader, edges }) => { + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + expect(actual).toEqual([...edges]) + return { code, loader, expected: edges, actual } + }), + entries, + } + if (process.env.DEVUP_SCANNER_CORPUS_ARTIFACT) + writeFileSync( + process.env.DEVUP_SCANNER_CORPUS_ARTIFACT, + JSON.stringify(artifact, null, 2), + ) + } finally { + __setOxcParserForTest(undefined) + } +}, 30000) diff --git a/packages/plugin-utils/src/__tests__/parser-independent.test.ts b/packages/plugin-utils/src/__tests__/parser-independent.test.ts new file mode 100644 index 000000000..6a601b968 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-independent.test.ts @@ -0,0 +1,72 @@ +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'discovers the real template interpolation import with parser enabled %s', + (enabled) => { + // Given: valid source and a real leaf; the injected AST describes the prior divergence. + const entry = file( + 'src/main.ts', + "export const result = `${import('./leaf')}`", + ) + const leaf = file('src/leaf.ts') + const parseSync = mock(() => ({ + program: { type: 'ImportExpression', source: { value: './leaf' } }, + errors: [], + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: build the real filesystem graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: interpolation code loads the leaf regardless of parser presence. + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + if (enabled) expect(parseSync).toHaveBeenCalledTimes(2) + }, +) + +it.each([false, true])( + 'ignores contradictory AST edges with optional diagnostics %s', + (enabled) => { + // Given: literal code edges and poisoned textual/AST dependencies. + const entry = file( + 'src/main.tsx', + [ + 'export * from "./static"; export {value} from "./reexport"; import "./side-effect";', + 'const value = `text import("phantom") ${import("./dynamic")} ${require("./required")}`;', + 'const nested = `${ `inner ${import("./nested")}` }`;', + 'const regex = /[`"\']import("phantom")/; const division = 1 / import("./division");', + 'const jsx =
import("phantom"){require("./attribute")}
;', + '/* import("phantom") */ import(name); require(name); import("phantom" + name);', + ].join('\n'), + ) + const staticTargets = [ + 'static', + 'reexport', + 'side-effect', + 'required', + 'attribute', + ].map((name) => file(`src/${name}.ts`)) + const dynamicTargets = ['dynamic', 'nested', 'division'].map((name) => + file(`src/${name}.ts`), + ) + file('node_modules/phantom/package.json', '{') + const parseSync = mock(() => ({ + errors: [], + program: { type: 'ImportExpression', source: { value: 'phantom' } }, + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: resolve the fixture through the actual graph path. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: expected paths come only from the fixture's real literal dependencies. + expect(graph.staticImports.get(entry)).toEqual(new Set(staticTargets)) + expect(graph.dynamicImports.get(entry)).toEqual(new Set(dynamicTargets)) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(enabled ? graph.files.length : 0) + }, +) diff --git a/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts new file mode 100644 index 000000000..3b9e66b74 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts @@ -0,0 +1,221 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'scans real compiled MDX rather than fenced examples (async %s)', + async (asyncHook) => { + const markdown = [ + "import './real.js'", + '', + '# Heading', + '', + '```js', + "import './missing'", + "import { Box } from 'fake-package'", + "export {Box} from './missing'", + "require('./missing')", + "import('./missing')", + '```', + ].join('\n') + const entry = file('src/page.mdx', markdown) + file('node_modules/fake-package/package.json', '{') + const real = file('src/real.js') + const compiled = await compile({ value: markdown, path: entry }) + const hook = (filename: string) => + filename === entry + ? { code: String(compiled), map: compiled.map } + : undefined + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: asyncHook ? async (filename) => hook(filename) : hook, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set()) + }, +) + +it('rejects compiler errors with the actual Markdown filename and original cause', async () => { + const entry = file('src/page.mdx', '<') + let compilerCause: unknown + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: async (filename) => { + try { + return String(await compile({ value: '<', path: filename })) + } catch (cause) { + compilerCause = cause + throw cause + } + }, + }) + const error = await promise.catch((cause: unknown) => cause) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error('Expected compiler error') + expect(error.message).toContain(entry) + expect(error.cause).toBe(compilerCause) +}) + +it('selects and compiles a project-defined .mdown extension in scanner, graph and numbering', async () => { + const markdown = + "import './leaf'\n\n# Custom Markdown\n\n```js\nimport './missing'\n```" + const entry = file('src/page.mdown', markdown) + const leaf = file('src/leaf.mdown', '# Leaf') + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: async (filename) => { + return String( + await compile( + { value: filename === entry ? markdown : '# Leaf', path: filename }, + { format: 'mdx' }, + ), + ) + }, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.mdown', + 'src/page.mdown', + ]) +}) + +it('follows a real compiler-injected project-root provider and prepares included dependencies once', async () => { + const entry = file('src/page.mdx', '# Heading') + const provider = file('mdx-components.js', "import 'aliased-library'") + const remark = file('remark-extra.js') + const rehype = file('rehype-extra.js') + file('node_modules/actual/package.json', '{"main":"index.js"}') + const library = file('node_modules/actual/index.js', "import './leaf.config'") + const leaf = file('node_modules/actual/leaf.config.mjs') + const visits: string[] = [] + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + include: ['actual'], + alias: { + provider, + 'aliased-library': 'actual', + 'remark-extra': remark, + 'rehype-extra': rehype, + }, + prepareSource: async (filename) => { + visits.push(filename) + if (filename !== entry) return undefined + const inject = + (specifier: string) => () => (tree: { children: unknown[] }) => { + tree.children.unshift({ + type: 'mdxjsEsm', + value: `import '${specifier}'`, + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + specifiers: [], + source: { type: 'Literal', value: specifier }, + }, + ], + }, + }, + }) + } + const compiled = await compile( + { value: '# Heading', path: filename }, + { + providerImportSource: 'provider', + SourceMapGenerator, + remarkPlugins: [inject('remark-extra')], + rehypePlugins: [inject('rehype-extra')], + }, + ) + return { code: String(compiled), map: compiled.map } + }, + }) + expect(graph.files).toEqual( + [entry, provider, library, leaf, remark, rehype].sort(), + ) + expect(visits.sort()).toEqual(graph.files) +}) + +it('remaps available-parser diagnostic positions through a real MDX compiler map', async () => { + const entry = file('src/page.mdx', '# Heading') + const compiled = await compile( + { value: '# Heading', path: entry }, + { SourceMapGenerator }, + ) + expect(compiled.map).toBeDefined() + const positions: { + line: number + column: number + originalLine: number + originalColumn: number + }[] = [] + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping: ( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + }) => void, + ) => void + }) => { + consumer.eachMapping((mapping) => { + if (mapping.originalLine) + positions.push({ + line: mapping.generatedLine, + column: mapping.generatedColumn + 1, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn + 1, + }) + }) + }, + ) + const position = positions[0] + if (!position) throw new Error('Real compiler map has no original mappings') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'available parser fixture', + line: position.line, + column: position.column, + }, + ], + }), + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }), + ).rejects.toThrow( + `${entry}:${position.originalLine}:${position.originalColumn}`, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts new file mode 100644 index 000000000..26a7fcaf9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'bun:test' + +import { preparedDiagnostics } from '../prepared-diagnostics' + +it('formats label-less diagnostic fixtures and unknown diagnostic values', () => { + expect( + preparedDiagnostics('file.mdx', '', ['failure', { message: 'empty' }]), + ).toBe('file.mdx:1:1: failure\nfile.mdx:1:1: empty') +}) + +it('uses the first compiled position when a diagnostic label has no offset', () => { + expect( + preparedDiagnostics('file.mdx', '', [ + { message: 'label', labels: [null, {}] }, + ]), + ).toBe('file.mdx:1:1: label') +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts new file mode 100644 index 000000000..33504a0fd --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts @@ -0,0 +1,39 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach } from 'bun:test' + +import { __setOxcParserForTest } from '../import-graph' + +export function createPreparedFixture(setRoot: (root: string) => void) { + let root: string + beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'prepared-'))) + setRoot(root) + }) + afterEach(() => { + __setOxcParserForTest(undefined) + rmSync(root, { recursive: true, force: true }) + }) + return (path: string, source = 'export {}'): string => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target + } +} diff --git a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts new file mode 100644 index 000000000..4ba045459 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -0,0 +1,214 @@ +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, + createModuleResolver, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('reads raw source only for undefined, not for an empty prepared string', async () => { + const entry = file('src/page.ts', "import './real'") + const real = file('src/real.ts') + const raw = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => undefined, + }) + const empty = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => '', + }) + expect(raw.staticImports.get(entry)).toEqual(new Set([real])) + expect(empty.staticImports.get(entry)).toEqual(new Set()) +}) + +it.each([false, true])( + 'wraps located preparer throws/rejections (async %s)', + async (asyncHook) => { + const entry = file('src/main.ts') + const cause = Object.assign(new Error('Compiler failed'), { + line: 3, + column: 4, + }) + const hook = () => { + throw cause + } + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: asyncHook ? async () => hook() : hook, + }) + expect(promise).toBeInstanceOf(Promise) + const error = await promise.catch((failure: unknown) => failure) + if (!(error instanceof Error)) throw new Error('Expected error') + expect(error.message).toBe( + `${entry}:3:4 (in compiled output): Graph source preparation failed: Compiler failed`, + ) + expect(error.cause).toBe(cause) + }, +) + +it('turns asynchronous setup faults into Promise rejections', async () => { + const config = file('tsconfig.json', '{') + const promise = buildStaticImportGraph('src', config, { + cwd: root, + prepareSource: () => '', + }) + expect(promise).toBeInstanceOf(Promise) + await expect(promise).rejects.toThrow(config) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA;AACA' }, +])('reports every available parser diagnostic with map %j', async (map) => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { message: 'first', line: 1, column: 1 }, + { message: 'second', line: 2, column: 1 }, + ], + }), + }) + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'compiled', map }), + }) + const error = await promise.catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain( + map ? 'original.mdx:1:1' : `${entry}:1:1 (in compiled output)`, + ) + expect(error.message).toContain( + map ? 'original.mdx:2:1' : `${entry}:2:1 (in compiled output)`, + ) + if (map) expect(error.message).not.toContain('(in compiled output)') +}) + +it('does not fall back to raw reads or lexical scanning after a prepared parser throws', async () => { + const entry = file('src/page.mdx', "import './raw'") + const cause = new Error(`${entry}:2:3: parse failed`) + __setOxcParserForTest({ + parseSync: () => { + throw cause + }, + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => "import './compiled'", + }), + ).rejects.toThrow(`${entry}:2:3 (in compiled output)`) +}) + +it('uses real prepared source rather than contradictory AST edges', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + const phantom = file('src/phantom.ts') + __setOxcParserForTest({ + parseSync: () => ({ + type: 'ImportDeclaration', + source: { value: './phantom' }, + }), + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.staticImporters.get(phantom)).toEqual(new Set()) +}) + +it('reads diagnostic getters without consulting the available-parser program getter', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + const result = Object.create({ + get program() { + throw new Error('AST must not decide edges') + }, + get errors() { + return [] + }, + }) + __setOxcParserForTest({ parseSync: () => result }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) +}) + +it('reports every documented available-parser diagnostic label and remaps each position', async () => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'labels', + labels: [ + { start: 0, end: 1, message: 'first' }, + { start: 2, end: 3, message: null }, + ], + }, + ], + }), + }) + const map = { + version: 3, + sources: ['original.mdx'], + names: [], + mappings: 'AAAA;AACA', + } + const error = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'a\nb', map }), + }).catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain('original.mdx:1:1') + expect(error.message).toContain('original.mdx:2:1') + expect(error.message).not.toContain(`${entry}:`) + expect(error.message).not.toContain('(in compiled output)') +}) + +it('selects Markdown routes and extensionless imports only with exact opt-in', () => { + const entry = file('src/page.md', "import './leaf'\n\n# Heading") + const leaf = file('src/leaf.md', '# Leaf') + file('src/other.mdx') + expect(() => createModuleResolver({ cwd: root })('./leaf.md', entry)).toThrow( + leaf, + ) + expect(createModuleResolver({ cwd: root })('./leaf', entry)).toBeUndefined() + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.md'], + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.md', + 'src/page.md', + ]) +}) + +it('rejects unprepared legacy extensionless MDX while the graph remains default-off', () => { + const entry = file('src/main.ts', "import './page'") + const page = file('src/page.mdx', '# Heading') + expect(() => createModuleResolver({ cwd: root })('./page', entry)).toThrow( + page, + ) + expect(buildStaticImportGraph('src', undefined, { cwd: root }).files).toEqual( + [entry], + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts new file mode 100644 index 000000000..7deb84897 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts @@ -0,0 +1,206 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + wasm.registerTheme({}) + wasm.setDebug(debug) +}) + +function extract(filename: string, source: string) { + const output = wasm.codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + return { code: output.code, css: wasm.getCss(undefined, false) } + } finally { + output.free() + } +} + +it.each(['absolute', 'relative'])( + 'uses actual compiled MDX constants like TS constants (%s ids)', + async (mode) => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + file('src/plain.ts', "export const PRIMARY = 'tomato'") + const entry = file( + 'src/main.tsx', + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const cache = new Map([ + [module, { code: String(compiled), map: compiled.map }], + ]) + const prepareSource = (filename: string) => cache.get(filename) + const toId = (filename: string) => + (mode === 'absolute' ? filename : relative(root, filename)).replaceAll( + '\\', + '/', + ) + const resolver = createModuleResolver({ cwd: root, prepareSource, toId }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdx', toId(entry))?.code).toBe(String(compiled)) + wasm.setModuleResolver(resolver) + const prepared = extract( + toId(entry), + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + wasm.resetBuildState() + wasm.setModuleResolver(resolver) + const ordinary = extract( + toId(entry), + "import {PRIMARY} from './plain'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + expect(prepared.css).toBe(ordinary.css) + expect(prepared.css).toContain('color:tomato') + expect(prepared.code).not.toContain('--') + }, +) + +it.each(['raw', 'promise'])( + 'rejects %s imported Markdown through the actual WASM callback', + (mode) => { + const module = file( + 'src/value.mdx', + "export const PRIMARY = 'tomato'\n\n# Heading", + ) + const entry = file('src/main.tsx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => + mode === 'promise' ? Promise.resolve('') : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + } catch (cause) { + const error = resolver.remapError(cause) + expect(error.message).toContain(`${entry}:1:1`) + expect(error.message).toContain(module) + expect(error.cause).toBe(cause) + return + } + throw new Error('Expected raw/preparation rejection') + }, +) + +it('leaves the real WASM importing-site error untouched after prepared-module evaluation', async () => { + const markdown = "export const bad = () => 'red'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const entry = file('src/main.tsx') + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => + filename === module + ? { code: String(compiled), map: compiled.map } + : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {bad} from './value.mdx'; import {css} from '@devup-ui/react'; export const style = css({color:bad});", + ) + } catch (cause) { + const error = resolver.remapError(cause) + const original = cause instanceof Error ? cause.message : String(cause) + expect(error.message).toBe(original) + expect(error.cause).toBe(cause) + expect(resolver.remapError(error)).toBe(error) + expect(error.message).toContain(`${entry}:1:`) + return + } + throw new Error('Expected imported extraction failure') +}) + +it('remaps synthetic extractor-format diagnostics through a real MDX compiler map', async () => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + toId: () => 'src/value.mdx', + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }) + resolver(module, 'main.ts') + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + source: string + }) => void, + ): void + }) => { + const positions: string[] = [] + consumer.eachMapping((mapping) => { + if (mapping.originalLine) { + const error = resolver.remapError( + `src/value.mdx:${mapping.generatedLine}:${mapping.generatedColumn + 1}: diagnostic`, + ) + expect(error.message).toBe( + `${module}:${mapping.originalLine}:${mapping.originalColumn + 1}: diagnostic`, + ) + positions.push(error.message) + } + }) + expect(positions.length).toBeGreaterThan(0) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts new file mode 100644 index 000000000..6d246e25e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts @@ -0,0 +1,177 @@ +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each(['.mdx', '.md', '.mdown'])( + 'rejects unprepared %s at the importer', + (extension) => { + const module = file(`src/value${extension}`, '# Raw') + const resolver = createModuleResolver({ cwd: root, includeMdx: ['.mdown'] }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + `${resolve(root, 'src/main.tsx')}:1:1`, + ) + expect(() => resolver(module, 'src/main.tsx')).toThrow(module) + }, +) + +it.each([ + Promise.resolve(''), + { + code: '', + then() { + throw new Error('must not invoke') + }, + }, + Object.assign(() => {}, { code: '', then() {} }), +])('rejects a thenable without awaiting or invoking it', (value) => { + const module = file('src/value.mdx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => value, + }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + 'prepare Markdown before extraction', + ) +}) + +it('preserves a hook exception and the real aliased module before toId', () => { + const module = file('src/value.mdx') + const cause = new Error('compiler failed') + const resolver = createModuleResolver({ + cwd: root, + alias: { value: module }, + toId: () => { + throw new Error('too early') + }, + prepareSource: (filename) => { + expect(filename).toBe(module) + throw cause + }, + }) + try { + resolver('value', 'src/main.tsx') + } catch (error) { + if (!(error instanceof Error)) throw error + expect(error.cause).toBe(cause) + expect(error.message).toContain(module) + return + } + throw new Error('Expected preparation failure') +}) + +it('accepts empty preparation and reads ordinary undefined sources', () => { + const module = file('src/value.mdx', '# Raw') + const ordinary = file('src/plain.ts', 'export const value = 1') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => (filename === module ? '' : undefined), + }) + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + expect(resolver(ordinary, 'main.ts')?.code).toBe('export const value = 1') + expect(resolver('./missing', 'main.ts')).toBeUndefined() +}) + +it('remaps every exact id occurrence once, leaving other filenames alone', () => { + const module = file('src/value.mdx') + const other = file('src/other.mdx') + const id = 'src/[value]+(x).mdx' + const map = { version: 3, sources: [id], mappings: 'AAKA', names: [] } + const resolver = createModuleResolver({ + cwd: root, + toId: (filename) => (filename === module ? id : 'other.mdx'), + prepareSource: (filename) => ({ + code: '', + map: filename === module ? map : undefined, + }), + }) + resolver(module, 'main.ts') + resolver(other, 'main.ts') + const original = new Error( + `${id}:1:1: bad\nsee ${id}:1:1 and other.mdx:2:3: bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + const error = resolver.remapError(original) + expect(error.message).toBe( + `${id}:6:1: bad\nsee ${id}:6:1 and ${other}:2:3 (in compiled output): bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + expect(error.cause).toBe(original) + expect(resolver.remapError(error)).toBe(error) + expect(createModuleResolver({ cwd: root }).remapError(original).message).toBe( + original.message, + ) +}) + +it('replaces maps for the same id and deletes a no-longer-prepared generation', () => { + const module = file('src/value.ts') + let generation: { code: string; map?: unknown } | undefined = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAKA' }, + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => generation, + }) + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:6:1: bad`, + ) + generation = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAQA' }, + } + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:9:1: bad`, + ) + generation = undefined + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('discards stale Markdown mappings when preparation disappears', () => { + const module = file('src/value.mdx') + let prepared: string | undefined = '' + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + resolver(module, 'main.ts') + prepared = undefined + expect(() => resolver(module, 'main.ts')).toThrow(module) + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('keeps exact Windows ids, uncovered positions and non-Error hook causes', () => { + const module = file('src/value.mdx') + const id = 'C:\\folder\\[value]+.mdx' + const resolver = createModuleResolver({ + cwd: root, + toId: () => id, + prepareSource: () => ({ + code: '', + map: { version: 3, sources: [module], mappings: 'AAAA' }, + }), + }) + resolver(module, 'main.ts') + expect( + resolver.remapError(`${id}:2:1: bad\nlong-${id}:1:1: other`).message, + ).toBe(`${module}:2:1 (in compiled output): bad\nlong-${id}:1:1: other`) + const failed = createModuleResolver({ + cwd: root, + prepareSource: () => { + throw { toString: () => 'compiler not ready' } + }, + }) + expect(() => failed(module, 'main.ts')).toThrow('compiler not ready') +}) diff --git a/packages/plugin-utils/src/__tests__/request-evidence.test.ts b/packages/plugin-utils/src/__tests__/request-evidence.test.ts new file mode 100644 index 000000000..93f2364c5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-evidence.test.ts @@ -0,0 +1,239 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('retains four request kinds, written escapes and duplicate token positions', () => { + // Given distinct literal occurrences resolving to the same module. + const source = String.raw`import './\x61'; +export * from './a'; +import('./a'); require('./a'); import './a';` + const entry = file('src/page.ts', source) + const leaf = file('src/a.ts') + // When building the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then every occurrence survives with genuine quote-token coordinates. + expect(graph.requests).toEqual( + [ + ['static-import', './\\x61', 7, 1, 8], + ['re-export', './a', 31, 2, 15], + ['literal-dynamic-import', './a', 45, 3, 8], + ['literal-require', './a', 61, 3, 24], + ['static-import', './a', 76, 3, 39], + ].map(([kind, request, offset, line, column]) => ({ + importer: entry, + kind, + request, + specifier: './a', + position: { offset, line, column }, + source: 'source', + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/a'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + })), + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + expect(Object.isFrozen(graph.requests)).toBe(true) + expect( + graph.requests.every( + (request) => + Object.isFrozen(request) && + Object.isFrozen(request.position) && + Object.isFrozen(request.outcome), + ), + ).toBe(true) +}) + +it('reports external, unresolved and ignored boundaries without inventing files', () => { + // Given requests that legacy traversal does not follow. + const entry = file( + 'src/main.ts', + "import 'external'; import './missing'; require('ignored')", + ) + // When resolving an explicit native false alias. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { ignored: false }, + }) + // Then the three successful observations are distinct. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'external', request: 'external' }, + { kind: 'unresolved' }, + { kind: 'ignored' }, + ]) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set(['external'])) +}) + +it.each(['bare', 'absolute', 'nested', 'alias', 'package', 'directory-entry'])( + 'retains the actual matched exclusion entry for %s', + (mode) => { + // Given a poisoned target that must never be read or followed. + const poison = file('blocked/nested/index.ts', "import 'bad'") + file('node_modules/blocked/package.json', '{') + const exclusion = + mode === 'bare' || mode === 'nested' ? 'blocked' : join(root, 'blocked') + const request = + mode === 'absolute' + ? poison.replaceAll('\\', '/') + : mode === 'alias' + ? 'provider' + : mode === 'package' + ? 'blocked' + : mode === 'directory-entry' + ? '../container' + : '../blocked/nested/index' + if (mode === 'directory-entry') + file('container/package.json', '{"main":"../blocked/nested/index.ts"}') + const excludes = + mode === 'package' ? [join(root, 'node_modules/blocked')] : [exclusion] + const entry = file('src/main.ts', `import '${request}'`) + // When resolving with the existing exclusion matcher. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: excludes, + alias: { provider: poison }, + }) + // Then evidence names the original matched entry, not a fabricated target. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: excludes[0] }, + ]) + expect(graph.files).toEqual([entry]) + }, +) + +it('orders requests by importer then offset rather than discovery order', () => { + // Given a followed alias target whose name sorts before the source root. + const entry = file('src/z.ts', "import 'provider'; import('external')") + const target = file('outside/a.ts', "require('other')") + // When traversal discovers the target after the entry. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: target }, + }) + // Then stable evidence ordering is independent of traversal append order. + expect( + graph.requests.map(({ importer, position }) => [importer, position.offset]), + ).toEqual([ + [target, 8], + [entry, 7], + [entry, 26], + ]) +}) + +it('retains an exclusion reached only after canonical package resolution', () => { + // Given a package symlink whose real target is excluded. + const manifest = file('blocked/pkg/package.json', '{"main":"index.js"}') + file('blocked/pkg/index.js', "import 'poison'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/linked'), + 'junction', + ) + const entry = file('src/main.ts', "import 'linked'") + // When native package resolution canonicalizes the target. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: [dirname(manifest)], + include: ['linked'], + }) + // Then the post-resolution exclusion names the actual matcher entry. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: dirname(manifest) }, + ]) + expect(graph.files).toEqual([entry]) +}) + +it('keeps the original thrown object untouched and stores only deterministic failing-edge evidence', () => { + // Given an I/O failure at a real resolution boundary. + const entry = file('src/main.ts', "import './leaf'") + const leaf = file('src/leaf.ts') + const cause = new Error('original cause') + const original = Object.freeze(new Error('original failure', { cause })) + const originalStat = fs.statSync + const stat = spyOn(fs, 'statSync').mockImplementation( + new Proxy(originalStat, { + apply(target, thisArg, args) { + if (args[0] === leaf) throw original + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + const evidence = [] + for (let run = 0; run < 2; run += 1) { + // When the unchanged resolver throws. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { cwd: root }) + } catch (error) { + caught = error + } + // Then identity, cause and the failing occurrence are preserved. + expect(caught).toBe(original) + expect(original.cause).toBe(cause) + expect(Object.keys(original)).toEqual([]) + const failure = importGraphFailureOf(caught) + expect(failure).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 7, line: 1, column: 8 }, + source: 'source', + outcome: { kind: 'error', error: original }, + }, + ]) + evidence.push(failure) + } + expect(evidence[0]).toEqual(evidence[1]) + expect(importGraphFailureOf(new Error('unrelated'))).toBeUndefined() + expect(importGraphFailureOf(null)).toBeUndefined() + expect(importGraphFailureOf('primitive')).toBeUndefined() + expect(importGraphFailureOf(() => undefined)).toBeUndefined() + } finally { + stat.mockRestore() + } +}) + +it('records alias resolution errors without changing their located diagnostic', () => { + // Given a terminal alias miss. + const entry = file('src/main.ts', "export * from 'provider'") + // When the existing resolver rejects the rewritten candidate. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: './absent' }, + }) + } catch (error) { + caught = error + } + // Then the original error is the same object exposed as evidence. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)?.[0]).toMatchObject({ + importer: entry, + kind: 're-export', + request: 'provider', + outcome: { kind: 'error', error: caught }, + }) +}) diff --git a/packages/plugin-utils/src/__tests__/request-source.test.ts b/packages/plugin-utils/src/__tests__/request-source.test.ts new file mode 100644 index 000000000..b4509c050 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-source.test.ts @@ -0,0 +1,249 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('preserves raw Markdown offsets through prose, fences, comments and CRLF', () => { + // Given an ESM block after masked Markdown. + const source = + '# Heading\r\n\r\n```js\r\nimport "fake"\r\n```\r\n\r\n\r\nimport "./leaf"\r\n\r\nProse' + const entry = file('src/page.mdx', source) + const leaf = file('src/leaf.ts') + // When scanning raw opted-in Markdown. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + }) + // Then coordinates refer to the actual source, not compacted ESM blocks. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 66, line: 8, column: 8 }, + source: 'source', + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA' }, +])('labels prepared token positions honestly with map %j', async (map) => { + // Given different raw and compiled text under the real Markdown filename. + const entry = file('src/page.mdx', '# Raw') + const leaf = file('src/leaf.ts') + const visits: string[] = [] + // When the unchanged asynchronous driver prepares actual JavaScript. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => { + visits.push(filename) + return filename === entry + ? { code: '\nimport "./leaf"', sourceType: 'compiled-mdx', map } + : undefined + }, + }) + // Then compiled coordinates and caller-owned map are not claimed as raw positions. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 8, line: 2, column: 8 }, + source: 'compiled', + map, + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) + expect(visits.sort()).toEqual(graph.files) +}) + +it.each(['.js', '.tsx', '.ts'])( + 'preserves scanner masks and type-only/nonliteral parity in %s', + (extension) => { + // Given text, expressions, type syntax and nonliteral calls. + const source = + extension === '.js' + ? 'const x = a < b; import("external"); require(name)' + : extension === '.tsx' + ? 'const x =
import("fake"){require("external")}
; import type { T } from "types"' + : 'type T = import("types").T; const x = a; import("external"); import(name)' + const entry = file(`src/main${extension}`, source) + // When the graph uses the same lexer context as the legacy scanner. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then only the runtime literal occurrence survives and old object shape is exact. + expect(graph.requests.map(({ specifier }) => specifier)).toEqual([ + 'external', + ]) + expect( + scanImports(source, extension === '.tsx', extension !== '.js'), + ).toEqual([ + { + kind: extension === '.tsx' ? 'static' : 'dynamic', + specifier: 'external', + }, + ]) + expect(graph.requests[0]?.importer).toBe(entry) + }, +) + +it('keeps explicit compiled-MDX JavaScript grammar under a TS filename', async () => { + // Given JavaScript using the value identifier type with JSX. + const entry = file('src/main.ts') + // When preparation explicitly selects JS/JSX. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => ({ + code: 'const type = 1; const x =
{import("external")}
', + sourceType: 'compiled-mdx', + }), + }) + // Then the actual compiled literal is retained under its real filename. + expect( + graph.requests.map(({ importer, specifier, source }) => ({ + importer, + specifier, + source, + })), + ).toEqual([{ importer: entry, specifier: 'external', source: 'compiled' }]) +}) + +it('does not invent failed-edge evidence for preparation errors before any request', async () => { + // Given a hook failure before scanning. + file('src/page.mdx') + // When the established driver wraps the compiler failure. + const failure = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => { + throw new Error('compiler') + }, + }).catch((error: unknown) => error) + // Then no synthetic scanner edge is attributed to it. + expect(importGraphFailureOf(failure)).toBeUndefined() +}) + +it.each([ + ['\r\n', 19], + ['\r', 18], + ['\n', 18], + ['\u2028', 18], + ['\u2029', 18], +] as const)( + 'locates raw requests after line terminator %j', + (separator, offset) => { + // Given actual source line terminators, not literal backslash escapes. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', source) + // When scanning the raw source through the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then independently known coordinates and legacy selection are preserved. + expect(graph.requests).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset, line: 2, column: 8 }, + source: 'source', + outcome: { kind: 'external', request: 'external' }, + }, + ]) + expect(scanImports(source, false)).toEqual([ + { kind: 'static', specifier: 'external' }, + ]) + }, +) + +it.each(['\u2028', '\u2029'])( + 'locates prepared requests after Unicode line terminator %j', + async (separator) => { + // Given compiled text with an actual Unicode line terminator. + const entry = file('src/main.ts', 'const raw = 1;') + // When preparation supplies the exact audit witness. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => `const n=0;${separator}import 'external';`, + }) + // Then the position describes compiled text, not the raw file. + expect(graph.requests[0]).toMatchObject({ + importer: entry, + request: 'external', + position: { offset: 18, line: 2, column: 8 }, + source: 'compiled', + outcome: { kind: 'external', request: 'external' }, + }) + }, +) + +it.each([ + ['\u2028', false], + ['\u2029', false], + ['\u2028', true], + ['\u2029', true], +] as const)( + 'locates failing requests after Unicode line terminator %j with preparation %j', + async (separator, prepared) => { + // Given a terminal alias miss in raw or compiled witness text. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', prepared ? 'const raw = 1;' : source) + // When the existing resolver rejects the requested alias. + let caught: unknown + try { + await buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { external: './absent' }, + ...(prepared ? { prepareSource: () => source } : {}), + }) + } catch (error) { + caught = error + } + // Then failing-edge evidence retains the independently known position. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset: 18, line: 2, column: 8 }, + source: prepared ? 'compiled' : 'source', + ...(prepared ? { map: undefined } : {}), + outcome: { kind: 'error', error: caught }, + }, + ]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts new file mode 100644 index 000000000..66820b8f6 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts @@ -0,0 +1,276 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../index' +import { + createResolutionInputs, + readResolutionFile, + realpathResolution, +} from '../resolution-inputs' +import { resolutionWatchPath } from '../resolution-watch-path' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records consulted manifests and missing probes when resolving a linked package', () => { + // Given a linked workspace package with an exports-only entry. + file('src/main.ts', "import 'palette'") + const manifest = file( + 'packages/palette/package.json', + '{"exports":"./red.js"}', + ) + file('packages/palette/red.js', "export const color='red'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/palette'), + 'junction', + ) + const originalRead = fs.readFileSync + const reads: string[] = [] + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When scanning with real filesystem IO. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the outcome names the lexical consulted manifest, not a guessed physical read. + const lexical = join(root, 'node_modules/palette/package.json') + expect(reads.filter((path) => path.endsWith('package.json'))).toEqual([ + lexical, + ]) + expect(graph.requests[0]?.outcome).toMatchObject({ + kind: 'resolved', + inputs: { + fileDependencies: [ + lexical, + join(root, 'node_modules/palette/red.js'), + ].sort(), + missingDependencies: [ + join(root, 'src/node_modules/palette/package.json'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }) + } finally { + read.mockRestore() + } +}) + +it('observes setup inheritance and failed earlier candidates when evaluating an import', () => { + // Given inherited aliases with eager multiple targets. + const config = file( + 'tsconfig.json', + '{"extends":"./base","compilerOptions":{}}', + ) + const base = file( + 'base.json', + '{"compilerOptions":{"paths":{"color":["absent","red.ts","later"]}}}', + ) + const red = file('red.ts', "export const color='red'") + const observations: unknown[] = [] + // When the public callable resolves and reads source. + const resolver = createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + expect(resolver('color', join(root, 'main.ts'))).toMatchObject({ path: red }) + // Then actual setup and eager failed candidates are reported without changing resolution. + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fileDependencies: [base, config].sort(), + missingDependencies: [join(root, 'base')], + }), + expect.objectContaining({ + fileDependencies: [base, red, config].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'absent'), + join(root, 'later'), + ]), + }), + ]), + ) + expect(observations.every((inputs) => Object.isFrozen(inputs))).toBe(true) +}) + +it('freezes deterministic snapshots without redoing successful resolution IO', () => { + // Given a real extensionless import and unchanged filesystem. + file('src/main.ts', "import './red'") + file('src/red.ts', "export const color='red'") + // When scanning twice independently. + const first = buildStaticImportGraph('src', undefined, { cwd: root }) + const second = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the full evidence is stable and its nested arrays cannot change. + expect(first.requests).toEqual(second.requests) + const outcome = first.requests[0]?.outcome + expect(outcome?.kind).toBe('resolved') + if (outcome && 'inputs' in outcome) { + expect(Object.isFrozen(outcome.inputs)).toBe(true) + expect(Object.isFrozen(outcome.inputs.fileDependencies)).toBe(true) + expect(Object.isFrozen(outcome.inputs.missingDependencies)).toBe(true) + } +}) + +it.each(['main', 'fallback', 'json'])( + 'records actual loaded packaged config chain when using %s resolution', + (mode) => { + // Given a package-config extension found below an absent nearer node_modules directory. + const config = file('src/tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + mode === 'main' + ? '{"main":"index.js","tsconfig":"base.json"}' + : mode === 'json' + ? '{"main":"base.json"}' + : '{}', + ) + if (mode === 'main') + file('node_modules/preset/index.js', 'module.exports={}') + const base = file( + `node_modules/preset/${mode === 'fallback' ? 'tsconfig.json' : 'base.json'}`, + '{}', + ) + const observations: unknown[] = [] + // When constructing the existing synchronous resolver. + createModuleResolver({ + cwd: root, + tsconfigPath: config, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + // Then only owned reads and public-search missing directories are recorded. + expect(observations).toContainEqual({ + fileDependencies: (mode === 'main' + ? [config, manifest, base] + : [config, base] + ).sort(), + missingDependencies: [join(root, 'src/node_modules/preset')], + }) + }, +) + +it('reports a missing inherited configuration before preserving its established failure', () => { + // Given a top config pointing at a nonexistent parent. + file('tsconfig.json', '{"extends":"./missing"}') + const observations: unknown[] = [] + // When setup reaches the original IO failure. + expect(() => + createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }), + ).toThrow(join(root, 'missing.json')) + // Then the repair paths were observed even though no callable was returned. + expect(observations).toContainEqual({ + fileDependencies: [join(root, 'tsconfig.json')], + missingDependencies: [ + join(root, 'missing'), + join(root, 'missing.json'), + ].sort(), + }) +}) + +it('canonicalizes only transport identity when watching a missing descendant below a symlink', () => { + // Given a symlink and a missing file below its existing target. + const physical = file('physical/existing.ts') + file('links/placeholder.ts') + fs.symlinkSync(dirname(physical), join(root, 'links/package'), 'junction') + const lexical = join(root, 'links/package/new.ts') + // When selecting default and preserved transport identities. + const canonical = resolutionWatchPath(lexical) + // Then resolver evidence remains lexical while transport follows the bundler setting. + expect(canonical).toBe(join(root, 'physical/new.ts')) + expect(resolutionWatchPath(lexical, true)).toBe(lexical) +}) + +it('preserves IO failure identity and records attempted repair inputs', () => { + // Given a permission failure at the narrow existing read seam. + const target = file('blocked.json') + const original = Object.assign(new Error('denied'), { code: 'EACCES' }) + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(fn, receiver, args) { + if (args[0] === target) throw original + return Reflect.apply(fn, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When reading the input through the observed seam. + let caught: unknown + try { + readResolutionFile(target, inputs) + } catch (error) { + caught = error + } + // Then the original exception and actual attempted filename survive. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [target], + missingDependencies: [], + }) + } finally { + read.mockRestore() + } +}) + +it.each(['EACCES', 'ENOENT'])( + 'retains lexical watch identity when canonicalization fails with %s', + (code) => { + // Given a transport-only realpath failure, including an unavailable root. + const path = join(root, 'unavailable.ts') + const original = Object.assign(new Error('unavailable'), { code }) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + try { + // When asking for watcher identity without changing resolution semantics. + const watched = resolutionWatchPath(path) + // Then no secondary error replaces the original extraction/config failure. + expect(watched).toBe(path) + } finally { + realpath.mockRestore() + } + }, +) + +it('records a failed canonical probe without replacing the original IO error', () => { + // Given a real file whose existing canonicalization seam fails after resolution probes. + const path = file('canonical.ts') + const original = Object.freeze( + Object.assign(new Error('removed during resolution'), { code: 'ENOENT' }), + ) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + const inputs = createResolutionInputs() + try { + // When observing that existing realpath invocation. + let caught: unknown + try { + realpathResolution(path, inputs) + } catch (error) { + caught = error + } + // Then its exact path is repairable and its thrown object is identical. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [], + missingDependencies: [path], + }) + } finally { + realpath.mockRestore() + } +}) diff --git a/packages/plugin-utils/src/__tests__/shared-api.test.ts b/packages/plugin-utils/src/__tests__/shared-api.test.ts new file mode 100644 index 000000000..bf39861c1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-api.test.ts @@ -0,0 +1,172 @@ +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, + createModuleResolver, + listSourceFiles, +} from '../import-graph' +import { collectNumberedFiles } from '../numbering' + +let root: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'source-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target +} + +it('keeps Markdown off by default and selects exact extensions in scanner and numbering', () => { + const js = file('src/main.ts') + const md = file('src/page.md') + file('src/page.mdx') + const custom = file('src/page.mdown') + file('src/generated/skip.md') + expect(listSourceFiles(join(root, 'src'))).toEqual([js]) + expect( + listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: ['.md'] }), + ).toEqual([js, md]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + exclude: ['generated'], + includeMdx: ['.md'], + toId: (path) => path, + }), + ).toEqual([js, md]) + expect( + listSourceFiles(join(root, 'src'), [], { includeMdx: ['.mdown'] }), + ).toEqual([js, custom]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + includeMdx: ['.mdown'], + toId: (path) => path, + }), + ).toEqual([js, custom]) +}) + +it('returns a Promise for a synchronous preparer and follows a project-root provider once', async () => { + const entry = file('src/page.mdx', '# Raw markdown') + const provider = file('mdx-components.js', "import './value.generated'") + const leaf = file('value.generated.mjs') + const visits: string[] = [] + const graphPromise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + alias: { provider }, + prepareSource: (filename: string) => { + visits.push(filename) + return filename === entry ? "import 'provider'" : undefined + }, + }) + expect(graphPromise).toBeInstanceOf(Promise) + const graph = await graphPromise + expect(graph.files).toEqual([entry, provider, leaf].sort()) + expect(visits.sort()).toEqual(graph.files) +}) + +it('does not discover import examples inside strings, regex or JSX text', () => { + const entry = file( + 'src/main.tsx', + [ + 'const example = "import \'./fake\'";', + 'const poison = "import \'fake-package\'";', + String.raw`const pattern = /"import '\.\/fake'"/;`, + "const template = `before ${`nested import './fake'`} after`;", + 'const element =
import \'./fake\' {import("./real")}
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + file('node_modules/fake-package/package.json', '{') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('does not leak template content after regex braces or nested escaped templates', () => { + const entry = file( + 'src/main.ts', + [ + 'const example = tag`prefix ${ /}/.test("x") ? `nested import "./fake"` : "" } tail import "./fake"`;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + expect( + buildStaticImportGraph('src', undefined, { cwd: root }).staticImports.get( + entry, + ), + ).toEqual(new Set([real])) +}) + +it('masks JSX text after arrow and conditional expression boundaries', () => { + const entry = file( + 'src/main.tsx', + [ + 'const View = () =>
import "./fake" {import("./real")}
;', + 'const branch = flag ?
import "./fake"
: ;', + 'const optional = flag &&
import "./fake"
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('rewrites aliases before tsconfig and never falls through a matched missing target', () => { + const entry = file('src/main.ts') + const target = file('actual/index.js') + file( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { paths: { name: ['./fallback.js'] } } }), + ) + file('fallback.js') + const resolver = createModuleResolver({ + cwd: root, + alias: { name$: target, missing: join(root, 'absent') }, + }) + expect(resolver('name', entry)?.path).toBe(target) + file('node_modules/missing/package.json', '{"main":"index.js"}') + file('node_modules/missing/index.js') + expect(() => resolver('missing', entry)).toThrow( + `${entry}:1:1: Module alias missing cannot resolve candidates ${JSON.stringify([join(root, 'absent')])}`, + ) +}) + +it('does not widen local graph roots merely because an empty alias table exists', () => { + const entry = file('src/main.ts', "import '../outside'") + file('outside.js') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: {}, + }) + expect(graph.files).toEqual([entry]) +}) diff --git a/packages/plugin-utils/src/__tests__/shared-paths.test.ts b/packages/plugin-utils/src/__tests__/shared-paths.test.ts new file mode 100644 index 000000000..0d19b9b70 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-paths.test.ts @@ -0,0 +1,130 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterAll, describe, expect, it } from 'bun:test' + +import { + createNodeModulesExcludeRegex, + GRAPH_SOURCE_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, +} from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +mkdirSync(parent, { recursive: true }) +const root = realpathSync(mkdtempSync(join(parent, 'paths-'))) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +describe('project roots', () => { + it('resolves default outputs against the project when cwd differs', () => { + const paths = resolveProjectPaths(root) + expect(paths).toEqual({ + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/devup-ui'), + }) + }) + it('resolves explicit paths and derives CSS from custom dist when omitted', () => { + expect( + resolveProjectPaths(root, { + devupFile: 'config/theme.json', + distDir: 'generated', + cssDir: 'styles', + }), + ).toEqual({ + devupFile: join(root, 'config/theme.json'), + distDir: join(root, 'generated'), + cssDir: join(root, 'styles'), + }) + expect(resolveProjectPaths(root, { distDir: 'generated' }).cssDir).toBe( + join(root, 'generated/devup-ui'), + ) + }) + it('discovers both src and app when present', () => { + mkdirSync(join(root, 'src')) + mkdirSync(join(root, 'app')) + expect(resolveSourceDirs(root)).toEqual([ + join(root, 'src'), + join(root, 'app'), + ]) + }) + it('retains explicit roots without requiring them to exist yet', () => { + expect( + resolveSourceDirs(root, ['custom', 'custom', resolve(root, 'more')]), + ).toEqual([join(root, 'custom'), join(root, 'more')]) + expect(resolveSourceDirs(root, 'custom')).toEqual([join(root, 'custom')]) + expect(resolveSourceDirs(join(root, 'empty'))).toEqual([]) + }) +}) + +describe('source filters', () => { + it.each(SOURCE_EXTENSIONS)( + 'accepts modern source extension %s', + (extension) => { + expect(SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + expect(POST_COMPILED_MDX_RE.test(`file.mdx${extension}`)).toBe(true) + expect(GRAPH_SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + }, + ) + it('discovers MDX without sending raw markdown through the source transform', () => { + expect(GRAPH_SOURCE_FILE_RE.test('page.mdx')).toBe(true) + expect(SOURCE_FILE_RE.test('page.mdx')).toBe(false) + expect(POST_COMPILED_MDX_RE.test('page.mdx')).toBe(false) + expect(GRAPH_SOURCE_FILE_RE.test('page.css')).toBe(false) + }) +}) + +describe('literal package boundaries', () => { + it.each(['foo.bar', 'foo+bar', '@scope/foo.bar'])( + 'includes literal package %s only', + (name) => { + const filter = createNodeModulesExcludeRegex([name]) + expect(filter.test(`node_modules/${name}/index.js`)).toBe(false) + expect( + filter.test( + `C:\\project\\node_modules\\${name.replaceAll('/', '\\')}\\index.js`, + ), + ).toBe(false) + expect(filter.test(`node_modules/${name}extra/index.js`)).toBe(true) + }, + ) + it('does not mistake scoped, substring or regex lookalike packages for includes', () => { + const filter = createNodeModulesExcludeRegex(['pkg', 'foo.bar']) + expect(filter.test('node_modules/@scope/pkg/index.js')).toBe(true) + expect(filter.test('node_modules/other-pkg/index.js')).toBe(true) + expect(filter.test('node_modules/fooXbar/index.js')).toBe(true) + expect(filter.test('not_node_modules/pkg/index.js')).toBe(false) + expect(filter.test('node_modules/@devup-ui-extra/react/index.js')).toBe( + true, + ) + }) + it('uses the innermost node_modules under pnpm or nested dependencies', () => { + const filter = createNodeModulesExcludeRegex(['pkg', '@scope/pkg']) + expect( + filter.test('node_modules/.pnpm/pkg@1/node_modules/pkg/index.js'), + ).toBe(false) + expect( + filter.test( + 'node_modules/.pnpm/@scope+pkg@1/node_modules/@scope/pkg/index.js', + ), + ).toBe(false) + expect(filter.test('node_modules/pkg/node_modules/other/index.js')).toBe( + true, + ) + expect( + filter.test( + 'node_modules/other/node_modules/@devup-editor/react/index.js', + ), + ).toBe(false) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/source-type.test.ts b/packages/plugin-utils/src/__tests__/source-type.test.ts new file mode 100644 index 000000000..f98f349a8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/source-type.test.ts @@ -0,0 +1,185 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + __setOxcParserForTest, + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { PreparedSourceTypeError, readPreparedSource } from '../prepared-source' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.resetBuildState() + wasm.setDebug(debug) + __setOxcParserForTest(undefined) +}) + +it.each([false, true])( + 'extracts compiled custom-extension exports after JSX (%s)', + async (jsx) => { + const markdown = + 'import {Box} from "@devup-ui/react"\n\n' + const module = file('src/value.mdown', markdown) + const entry = file('src/main.tsx', 'import {PRIMARY} from "./value.mdown";') + const compiled = await compile( + { path: module, value: markdown }, + { format: 'mdx', jsx }, + ) + const source: PreparedSource = { + code: `${compiled}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + map: compiled.map, + } + const prepareSource = (filename: string) => + filename === module ? source : undefined + const toId = (filename: string) => + relative(root, filename).replaceAll('\\', '/') + const resolver = createModuleResolver({ + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + toId, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdown', toId(entry))?.sourceType).toBe( + 'compiled-mdx', + ) + wasm.setModuleResolver(resolver) + const output = wasm.codeExtractWithoutSourceMap( + toId(entry), + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./value.mdown"; export const view = ;', + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + expect(wasm.getCss(undefined, false)).toContain('color:blue') + expect(output.code).not.toContain('--') + expect(output.dependencies).toEqual([toId(module)]) + } finally { + output.free() + } + }, +) + +it('uses JS JSX scanner and diagnostics when explicit mode overrides a TS filename', async () => { + const entry = file('src/page.ts') + const real = file('src/real.js') + file('node_modules/phantom/package.json', '{') + const modes: unknown[] = [] + __setOxcParserForTest({ + parseSync: (_filename, _code, options) => { + modes.push(options?.lang) + return {} + }, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: (filename) => + filename === entry + ? { + code: 'const view =
import "phantom"
; import "./real.js";', + sourceType: 'compiled-mdx', + } + : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(modes).toContain('jsx') +}) + +it.each(['invalid', 7, null, true, {}])( + 'rejects invalid hook mode before scanner fallback (%p)', + async (value) => { + const entry = file('src/page.ts', '') + const prepared = { code: '' } + Reflect.set(prepared, 'sourceType', value) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow(`${entry}:1:1:`) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') + }, +) + +it('preserves getter cause when preparation cannot read optional mode', async () => { + const entry = file('src/page.ts', '') + const cause = new Error('sourceType getter fault') + const prepared = { + code: '', + get sourceType(): 'compiled-mdx' { + throw cause + }, + } + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow('sourceType getter fault') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') +}) + +it('replaces mode when prepared generations become ordinary source', () => { + const module = file('src/value.ts', 'export const PRIMARY = "red";') + let source: PreparedSource = { + code: 'export const PRIMARY = "blue";', + sourceType: 'compiled-mdx', + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => source, + }) + expect(resolver(module, 'main.ts')?.sourceType).toBe('compiled-mdx') + source = { code: '' } + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + source = undefined + expect(resolver(module, 'main.ts')).toEqual({ + path: module, + code: 'export const PRIMARY = "red";', + }) +}) + +it('retains empty/string/undefined preparation contracts when mode is omitted', () => { + expect(readPreparedSource(undefined)).toBeUndefined() + expect(readPreparedSource('')).toBe('') + expect(readPreparedSource({ code: '' })).toEqual({ code: '', map: undefined }) + expect(new PreparedSourceTypeError('getter fault').message).toContain( + 'getter fault', + ) +}) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 000000000..661189cf2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { beginBuild } from '../build-session' +import { createStateWriter, writeFileAtomically } from '../state-writer' + +describe('createStateWriter', () => { + it('leaves the newest snapshot, whichever write would finish last', async () => { + const written: string[] = [] + const release: Array<() => void> = [] + const writer = createStateWriter( + (_path, content) => + new Promise((done) => { + release.push(() => { + written.push(content) + done() + }) + }), + ) + const first = writer.write('state', 'one') + const second = writer.write('state', 'two') + const third = writer.write('state', 'three', 'utf-8') + release[0]() + await first + await new Promise((resolve) => setTimeout(resolve, 0)) + release[1]() + await Promise.all([second, third]) + // the second snapshot was overtaken before it started + expect(written).toEqual(['one', 'three']) + expect(release).toHaveLength(2) + }) + + it('keeps writing after a write fails, and keeps paths apart', async () => { + const calls: string[] = [] + const writer = createStateWriter(async (path, content) => { + calls.push(`${path}=${content}`) + if (content === 'bad') throw new Error('disk full') + }) + const bad = writer.write('a', 'bad') + const good = writer.write('a', 'good') + await expect(bad).rejects.toThrow('disk full') + await good + await writer.write('b', 'other') + expect(calls).toEqual(['a=bad', 'a=good', 'b=other']) + }) +}) + +describe('writeFileAtomically', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-ui-writer-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('replaces the file whole and leaves no temporary file', async () => { + const path = join(dir, 'deep', 'state.json') + await writeFileAtomically(path, '1') + await writeFileAtomically(path, '22') + expect(readFileSync(path, 'utf-8')).toBe('22') + expect(readdirSync(join(dir, 'deep'))).toEqual(['state.json']) + }) + + it('is what the writer uses unless told otherwise', async () => { + const path = join(dir, 'state.json') + await createStateWriter().write(path, 'first') + await createStateWriter().write(path, 'second', 'utf-8') + expect(readFileSync(path, 'utf-8')).toBe('second') + }) + + it('cleans up when the file cannot be replaced', async () => { + const path = join(dir, 'target') + await writeFileAtomically(join(path, 'inner'), 'x') + await expect(writeFileAtomically(path, 'y')).rejects.toBeDefined() + expect(readdirSync(dir)).toEqual(['target']) + }) +}) + +describe('beginBuild', () => { + it('resets the engine only when no other build is running', () => { + let resets = 0 + const engine = { + resetBuildState: () => { + resets += 1 + }, + } + const first = beginBuild(engine) + expect(resets).toBeLessThanOrEqual(1) + const before = resets + const second = beginBuild(engine) + expect(resets).toBe(before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + expect(resets).toBe(before + 1) + third() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig.test.ts b/packages/plugin-utils/src/__tests__/tsconfig.test.ts new file mode 100644 index 000000000..9fd01d147 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig.test.ts @@ -0,0 +1,227 @@ +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 { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { readPathAliases } from '../tsconfig' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'tsconfig-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function fixture(path: string, value: unknown): string { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)) + return file +} + +describe('tsconfig inheritance', () => { + it('resolves inherited baseUrl and paths relative to their defining config', () => { + fixture('config/base.json', { + compilerOptions: { baseUrl: '../', paths: { '@base/*': ['src/*'] } }, + }) + fixture('tsconfig.json', { extends: './config/base.json' }) + const value = fixture('src/value.mts', 'export const value = 1') + const entry = fixture('src/main.ts', "import '@base/value'") + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', entry)?.path).toBe(value) + const graph = buildStaticImportGraph( + join(root, 'src'), + join(root, 'tsconfig.json'), + { cwd: root }, + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([value])) + expect(resolver('src/value', entry)?.path).toBe(value) + }) + it('uses paths origin without baseUrl and replaces rather than deep-merging paths', () => { + fixture('config/base.json', { + compilerOptions: { paths: { '@base/*': ['values/*'] } }, + }) + const value = fixture('config/values/value.cts', 'export const value = 1') + fixture('tsconfig.json', { extends: './config/base' }) + expect( + createModuleResolver({ cwd: root })('@base/value', 'main.ts')?.path, + ).toBe(value) + fixture('tsconfig.json', { + extends: './config/base', + compilerOptions: { paths: { '@child': ['child'] } }, + }) + const child = fixture('child.ts', 'child') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', 'main.ts')).toBeUndefined() + expect(resolver('@child', 'main.ts')?.path).toBe(child) + expect(resolver('@child-extra', 'main.ts')).toBeUndefined() + }) + it('applies an overridden baseUrl to inherited paths and merges array parents left to right', () => { + fixture('first.json', { + compilerOptions: { baseUrl: './old', paths: { '@x': ['value'] } }, + }) + fixture('second.json', { compilerOptions: { paths: { '@y': ['value'] } } }) + fixture('tsconfig.json', { + extends: ['./first.json', './second.json'], + compilerOptions: { baseUrl: './new' }, + }) + const value = fixture('new/value.ts', 'new') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@y', 'main.ts')?.path).toBe(value) + expect(resolver('@x', 'main.ts')).toBeUndefined() + }) + it('honors exact aliases and longest wildcard prefixes without fallback to shorter aliases', () => { + fixture('tsconfig.json', { + compilerOptions: { + paths: { + '@/*': ['fallback/*'], + '@/specific/*': ['missing/*'], + '@/exact': ['value'], + '@end/*/tail': ['value'], + }, + }, + }) + fixture('fallback/specific/item.ts', 'fallback') + const value = fixture('value.ts', 'value') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@/specific/item', 'main.ts')).toBeUndefined() + expect(resolver('@/exact', 'main.ts')?.path).toBe(value) + expect(resolver('@/exact-more', 'main.ts')).toBeUndefined() + expect(resolver('@end/tail', 'main.ts')).toBeUndefined() + expect(resolver('@end/a/tail', 'main.ts')?.path).toBe(value) + }) + it('parses comments and trailing commas without damaging strings', () => { + fixture( + 'tsconfig.json', + '{/* comment */"compilerOptions": {"paths": {"@x": ["src/value",], // trailing\n},}, "text": "quote\\"value,}",}', + ) + const value = fixture('src/value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@x', 'main.ts')?.path).toBe( + value, + ) + }) + it('inherits package configs, explicit package subpaths and package tsconfig fields', () => { + fixture('node_modules/config/package.json', {}) + fixture('node_modules/config/tsconfig.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('tsconfig.json', { extends: 'config/tsconfig.json' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('node_modules/custom/package.json', { + tsconfig: 'configs/base.json', + }) + fixture('node_modules/custom/configs/base.json', { + compilerOptions: { paths: { '@custom': ['value'] } }, + }) + const custom = fixture('node_modules/custom/configs/value.ts', 'custom') + fixture('tsconfig.json', { extends: 'custom' }) + expect( + createModuleResolver({ cwd: root })('@custom', 'main.ts')?.path, + ).toBe(custom) + }) + it('uses package tsconfig instead of JavaScript main when both are present', () => { + fixture('node_modules/config/package.json', { + main: 'index.js', + tsconfig: 'base.json', + }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/base.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) + it('defaults a package with JavaScript main to tsconfig.json', () => { + fixture('node_modules/config/package.json', { main: 'index.js' }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/tsconfig.json', {}) + fixture('tsconfig.json', { extends: 'config' }) + expect(readPathAliases(join(root, 'tsconfig.json')).aliases).toEqual([]) + }) + it('allows diamond inheritance without mistaking repeated parents for a cycle', () => { + fixture('base.json', { compilerOptions: { paths: { '@pkg': ['value'] } } }) + fixture('left.json', { extends: './base' }) + fixture('right.json', { extends: './base' }) + fixture('tsconfig.json', { extends: ['./left', './right'] }) + const value = fixture('value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) +}) + +describe('tsconfig load errors', () => { + it('reports the missing default config after a package has an unresolved main entry', () => { + fixture('node_modules/incomplete/package.json', { main: 'missing.js' }) + fixture('tsconfig.json', { extends: 'incomplete' }) + expect(() => readPathAliases(join(root, 'tsconfig.json'))).toThrow( + 'tsconfig.json', + ) + }) + + it.each([ + '{', + 'null', + '{"extends":1}', + '{"extends":[1]}', + '{"compilerOptions":1}', + '{"compilerOptions":{"baseUrl":1}}', + '{"compilerOptions":{"paths":[]}}', + '{"compilerOptions":{"paths":{"@x":[1]}}}', + '{/* unterminated', + ])('rejects invalid config %s with its location', (content) => { + const file = fixture('tsconfig.json', content) + expect(() => createModuleResolver({ cwd: root })).toThrow(file) + }) + it.each(['./missing', 'not-installed'])( + 'reports missing inherited config %s', + (parent) => { + fixture('tsconfig.json', { extends: parent }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }, + ) + it('reports canonical config cycles instead of recursing indefinitely', () => { + fixture('tsconfig.json', { extends: './base' }) + fixture('base.json', { extends: './tsconfig' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'inheritance cycle', + ) + }) + it('reports an inherited package with no tsconfig rather than using an empty config', () => { + fixture('node_modules/private/package.json', { + exports: { './allowed': './base.json' }, + }) + fixture('node_modules/private/base.json', {}) + fixture('tsconfig.json', { extends: 'private' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }) +}) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 000000000..bd16e68f3 --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,23 @@ +export interface ResettableEngine { + resetBuildState(): void +} + +let running = 0 + +/** + * Start a build in this process. The engine is shared by every plugin instance + * in the process, so what an earlier build left in it (prefix, atom hoisting, + * routes, buckets, names, numbers, styles) would otherwise leak into this one: + * it is reset unless another build is still running, whose state it would + * wipe. Returns the function that ends the build. + */ +export function beginBuild(engine: Partial): () => void { + if (running === 0) engine.resetBuildState?.() + running += 1 + let ended = false + return () => { + if (ended) return + ended = true + running -= 1 + } +} diff --git a/packages/plugin-utils/src/compiled-guard.ts b/packages/plugin-utils/src/compiled-guard.ts new file mode 100644 index 000000000..641de0f75 --- /dev/null +++ b/packages/plugin-utils/src/compiled-guard.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, extname, join, resolve } from 'node:path' + +import type { WasmImportAliases } from './types' + +const rootExports = new Set([ + 'Box', + 'Button', + 'Center', + 'Flex', + 'Grid', + 'Image', + 'Input', + 'Text', + 'VStack', + 'css', + 'globalCss', + 'keyframes', + 'styled', +]) +const compatExports = new Set(['Global', 'createGlobalStyle']) +const stylexExports = new Set([ + 'create', + 'props', + 'attrs', + 'keyframes', + 'firstThatWorks', + 'include', + 'defineVars', + 'createTheme', + 'createThemeContract', + 'defineConsts', + 'positionTry', + 'viewTransitionClass', + 'types', +]) +const compatPackages = new Set([ + '@emotion/react', + '@emotion/styled', + 'styled-components', + '@vanilla-extract/css', +]) + +export interface CompiledReference { + readonly request: string + readonly ids: readonly string[] + readonly line: number + readonly column: number +} + +export class UntransformedSourceError extends Error { + readonly name = 'UntransformedSourceError' + constructor( + readonly filename: string, + readonly reference: CompiledReference, + alias = false, + ) { + const extension = extname(filename.split('?')[0]) + super( + `${filename}:${reference.line}:${reference.column}: (in compiled output) untransformed module cannot use import '${reference.request}' (${reference.ids.join('.')}) at build time under extension '${extension}'; add '${extension}' to mdxExtensions and configure its MDX compiler${alias ? `, or set importAliases[${JSON.stringify(reference.request)}]=false for an intended runtime alias` : ''}`, + ) + } +} + +/** Same known compatibility targets as the extractor; unknown exports stay runtime. */ +export function isCompileTimeAlias( + reference: CompiledReference, + aliases: WasmImportAliases, +): boolean { + const { request, ids } = reference + if (!Object.hasOwn(aliases, request) || !ids.length) return false + const name = ids[0] === 'default' ? aliases[request] : ids[0] + if (!name) return false + if ( + request === '@vanilla-extract/css' && + ['style', 'globalStyle'].includes(name) + ) + return true + return ( + ['css', 'keyframes', 'styled', 'Global', 'createGlobalStyle'].includes( + name, + ) || + (!compatPackages.has(request) && rootExports.has(name)) + ) +} + +function targets(value: unknown): string[] { + if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : [] + if (!value || typeof value !== 'object') return [] + return Object.values(value).flatMap(targets) +} + +/** Identify an actual public export surface, never a basename/package-directory guess. */ +export function createCompileTimeClassifier(libPackage: string) { + const cache = new Map() + return (filename: string, ids: readonly string[]): boolean => { + const file = resolve(filename.split('?')[0]) + let surface = cache.get(file) + if (!cache.has(file)) { + let directory = dirname(file) + while (true) { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const pkg: unknown = JSON.parse(readFileSync(manifest, 'utf-8')) + if ( + pkg && + typeof pkg === 'object' && + 'name' in pkg && + (pkg.name === libPackage || pkg.name === '@stylexjs/stylex') && + 'exports' in pkg && + pkg.exports && + typeof pkg.exports === 'object' + ) { + for (const [entry, value] of Object.entries(pkg.exports)) { + if ( + ['.', './compat', './stylex'].includes(entry) && + targets(value).some( + (target) => resolve(directory, target) === file, + ) + ) + surface = + pkg.name === '@stylexjs/stylex' ? 'native-stylex' : entry + } + break + } + } + const parent = dirname(directory) + if (parent === directory) break + directory = parent + } + cache.set(file, surface) + } + if (surface === 'native-stylex') + return stylexExports.has(ids[0] === 'default' ? ids[1] : ids[0]) + if (surface === '.') + return ids[0] === 'stylex' + ? stylexExports.has(ids[1]) + : rootExports.has(ids[0]) + if (surface === './compat') return compatExports.has(ids[0]) + return surface === './stylex' && stylexExports.has(ids[0]) + } +} diff --git a/packages/plugin-utils/src/dependency-guard.ts b/packages/plugin-utils/src/dependency-guard.ts new file mode 100644 index 000000000..c527fe063 --- /dev/null +++ b/packages/plugin-utils/src/dependency-guard.ts @@ -0,0 +1,127 @@ +import { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +import { isSelectedSource } from './source-selection' +import type { WasmImportAliases } from './types' + +interface UsedDependency { + readonly request: string + readonly type: string + readonly ids?: unknown + readonly names?: unknown + readonly getIds?: (graph: unknown) => unknown + readonly loc?: unknown +} + +function isDependency(value: unknown): value is UsedDependency { + return ( + !!value && + typeof value === 'object' && + 'request' in value && + typeof value.request === 'string' && + 'type' in value && + typeof value.type === 'string' + ) +} + +function stringIds(value: unknown): readonly string[] { + return Array.isArray(value) && + value.every((id: unknown) => typeof id === 'string') + ? value + : [] +} + +export interface GuardModule { + readonly resource?: string + readonly type: string + readonly dependencies: readonly unknown[] +} + +export interface DependencyGuardOptions { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases +} + +export function createDependencyGuard(options: DependencyGuardOptions) { + const classify = createCompileTimeClassifier(options.package) + return ( + modules: Iterable, + context: { + readonly graph: unknown + readonly target: (dependency: unknown) => string | undefined + readonly forwarded?: ( + dependency: unknown, + ids: readonly string[], + ) => boolean + }, + ): UntransformedSourceError[] => { + const errors: UntransformedSourceError[] = [] + for (const module of modules) { + if ( + !module.resource || + !module.type.startsWith('javascript') || + isSelectedSource(module.resource.split('?')[0], options.mdxExtensions) + ) + continue + for (const dependency of module.dependencies) { + if ( + !isDependency(dependency) || + ![ + 'harmony import specifier', + 'esm import specifier', + 'cjs full require', + 'cjs require', + ].includes(dependency.type) + ) + continue + const ids = stringIds( + typeof dependency.getIds === 'function' + ? dependency.getIds(context.graph) + : (dependency.ids ?? dependency.names), + ) + if (!ids.length) continue + const loc = dependency.loc + const start = + loc && typeof loc === 'object' && 'start' in loc + ? loc.start + : undefined + const reference: CompiledReference = { + request: dependency.request, + ids, + line: + start && + typeof start === 'object' && + 'line' in start && + typeof start.line === 'number' + ? start.line + : 1, + column: + start && + typeof start === 'object' && + 'column' in start && + typeof start.column === 'number' + ? start.column + 1 + : 1, + } + const target = context.target(dependency) + if ( + isCompileTimeAlias(reference, options.importAliases) || + (target && classify(target, ids)) || + context.forwarded?.(dependency, ids) + ) + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + } + } + return errors + } +} diff --git a/packages/plugin-utils/src/directory-exclusion.ts b/packages/plugin-utils/src/directory-exclusion.ts new file mode 100644 index 000000000..1a515c89c --- /dev/null +++ b/packages/plugin-utils/src/directory-exclusion.ts @@ -0,0 +1,40 @@ +import { posix, win32 } from 'node:path' + +export function createDirectoryExclusion( + exclude: readonly string[] = [], + platform: string = process.platform, +): ((directory: string) => boolean) & { + readonly match: (directory: string) => string | undefined +} { + const windows = platform === 'win32' + const path = windows ? win32 : posix + const normalize = (value: string) => { + const normalized = path.normalize( + windows ? value.replaceAll('/', '\\') : value, + ) + const trimmed = normalized.replace(windows ? /\\+$/ : /\/+$/, '') + return windows ? trimmed.toLowerCase() : trimmed + } + const names = new Map( + exclude + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => [normalize(entry), entry]), + ) + const absolute = exclude + .filter((entry) => path.isAbsolute(entry)) + .map((entry) => ({ normalized: normalize(entry), entry })) + const match = (directory: string) => { + const candidate = normalize(directory) + for (const part of candidate.split(windows ? '\\' : '/')) { + const entry = names.get(part) + if (entry !== undefined) return entry + } + return absolute.find( + ({ normalized }) => + candidate === normalized || candidate.startsWith(normalized + path.sep), + )?.entry + } + return Object.assign((directory: string) => match(directory) !== undefined, { + match, + }) +} diff --git a/packages/plugin-utils/src/guard-facts.ts b/packages/plugin-utils/src/guard-facts.ts new file mode 100644 index 000000000..2c5b741a4 --- /dev/null +++ b/packages/plugin-utils/src/guard-facts.ts @@ -0,0 +1,157 @@ +import { + bindingScope, + createScopes, + field, + type GuardNode, + guardNode, + type GuardOrigin, + nodeName, + patternNames, +} from './guard-nodes' +import { origin, patternExpressions } from './guard-origins' + +export interface GuardFacts { + readonly references: readonly (GuardOrigin & { readonly pos: number })[] + readonly exports: ReadonlyMap + readonly stars: readonly string[] +} + +export function compiledFacts(ast: unknown): GuardFacts { + const root = guardNode(ast) + const references: (GuardOrigin & { readonly pos: number })[] = [] + const exports = new Map() + const stars: string[] = [] + if (!root) return { references, exports, stars } + const { scopes, moduleScope } = createScopes(root) + for (const statement of root.fields.get('body') ?? []) { + if (statement.kind !== 'ImportDeclaration') continue + const request = nodeName(field(statement, 'source')) + for (const specifier of statement.fields.get('specifiers') ?? []) { + const local = field(specifier, 'local') + if (!local) continue + const ids = + specifier.kind === 'ImportNamespaceSpecifier' + ? [] + : [ + specifier.kind === 'ImportDefaultSpecifier' + ? 'default' + : nodeName(field(specifier, 'imported')), + ] + moduleScope.bindings.set(local.name, { request, ids }) + } + } + function record(value: GuardOrigin | undefined, pos: number) { + if (value?.ids.length) references.push({ ...value, pos }) + } + function visit(node: GuardNode, key = '') { + const scope = scopes.get(node) ?? moduleScope + if (node.kind === 'VariableDeclaration' && node.value === 'const') { + for (const declaration of node.fields.get('declarations') ?? []) { + const id = field(declaration, 'id') + const value = origin(field(declaration, 'init'), scope) + if (id?.kind === 'Identifier' && value) + bindingScope(scope, id.name)?.bindings.set(id.name, value) + } + } + if (node.kind === 'ImportDeclaration') return + if (node.kind === 'ExportAllDeclaration') { + const request = nodeName(field(node, 'source')) + const exported = nodeName(field(node, 'exported')) + if (exported) exports.set(exported, { request, ids: [] }) + else stars.push(request) + return + } + if (node.kind === 'ExportNamedDeclaration') { + const request = nodeName(field(node, 'source')) + for (const specifier of node.fields.get('specifiers') ?? []) { + const name = nodeName(field(specifier, 'exported')) + const local = field(specifier, 'local') + exports.set( + name, + request ? { request, ids: [nodeName(local)] } : origin(local, scope), + ) + } + const declaration = field(node, 'declaration') + if (declaration) { + const ids = + declaration.kind === 'VariableDeclaration' + ? (declaration.fields.get('declarations') ?? []).flatMap((item) => { + const pattern = field(item, 'id') + return pattern ? patternNames(pattern) : [] + }) + : [nodeName(field(declaration, 'id'))] + for (const name of ids) exports.set(name, undefined) + } + } + if (node.kind === 'ExportDefaultDeclaration') + exports.set('default', origin(field(node, 'declaration'), scope)) + if (['VariableDeclarator', 'AssignmentExpression'].includes(node.kind)) { + const pattern = field( + node, + node.kind === 'VariableDeclarator' ? 'id' : 'left', + ) + const value = origin( + field(node, node.kind === 'VariableDeclarator' ? 'init' : 'right'), + scope, + ) + if (pattern?.kind === 'ObjectPattern' && value) { + for (const property of pattern.fields.get('properties') ?? []) { + const propertyKey = field(property, 'key') + if ( + property.kind !== 'Property' || + (property.computed && propertyKey?.kind !== 'Literal') + ) + continue + record( + { + ...value, + ids: [...value.ids, nodeName(propertyKey)], + }, + property.pos, + ) + } + } + } + if (node.kind === 'MemberExpression') { + record(origin(node, scope), node.pos) + const object = field(node, 'object') + if (object && !origin(object, scope)) visit(object, 'object') + const property = field(node, 'property') + if (node.computed && property?.kind !== 'Literal' && property) + visit(property, 'property') + return + } + if (node.kind === 'Identifier') { + if ( + [ + 'id', + 'params', + 'param', + 'imported', + 'exported', + 'local', + 'key', + 'label', + ].includes(key) + ) + return + record(origin(node, scope), node.pos) + return + } + if ( + [ + 'ObjectPattern', + 'ArrayPattern', + 'AssignmentPattern', + 'RestElement', + ].includes(node.kind) + ) { + for (const expression of patternExpressions(node)) visit(expression) + return + } + for (const [name, children] of node.fields) + for (const child of children) visit(child, name) + } + visit(root) + return { references, exports, stars } +} diff --git a/packages/plugin-utils/src/guard-nodes.ts b/packages/plugin-utils/src/guard-nodes.ts new file mode 100644 index 000000000..d8f88dc2c --- /dev/null +++ b/packages/plugin-utils/src/guard-nodes.ts @@ -0,0 +1,153 @@ +export interface GuardNode { + readonly kind: string + readonly name: string + readonly value: string + readonly pos: number + readonly fields: ReadonlyMap + readonly computed: boolean +} + +export function guardNode(value: unknown): GuardNode | undefined { + if ( + !value || + typeof value !== 'object' || + !('type' in value) || + typeof value.type !== 'string' + ) + return + const fields = new Map() + for (const [key, child] of Object.entries(value)) { + const children = (Array.isArray(child) ? child : [child]).flatMap( + (item: unknown) => { + const node = guardNode(item) + return node ? [node] : [] + }, + ) + if (children.length) fields.set(key, children) + } + return { + kind: value.type, + name: 'name' in value && typeof value.name === 'string' ? value.name : '', + value: + 'value' in value && typeof value.value === 'string' + ? value.value + : 'kind' in value && typeof value.kind === 'string' + ? value.kind + : '', + pos: 'start' in value && typeof value.start === 'number' ? value.start : 0, + computed: 'computed' in value && value.computed === true, + fields, + } +} + +export function field(node: GuardNode, key: string): GuardNode | undefined { + return node.fields.get(key)?.[0] +} + +export function nodeName(node: GuardNode | undefined): string { + return node?.name || node?.value || '' +} + +export interface GuardOrigin { + readonly request: string + readonly ids: readonly string[] + readonly kind?: 'require-call' +} + +export interface GuardScope { + readonly parent?: GuardScope + readonly functionScope: boolean + readonly bindings: Map +} + +export function binding( + scope: GuardScope, + name: string, +): GuardOrigin | undefined { + return bindingScope(scope, name)?.bindings.get(name) +} + +export function bindingScope( + scope: GuardScope, + name: string, +): GuardScope | undefined { + return scope.bindings.has(name) + ? scope + : scope.parent && bindingScope(scope.parent, name) +} + +export function patternNames(node: GuardNode): string[] { + if (node.kind === 'Identifier') return [node.name] + if (node.kind === 'Property') { + const value = field(node, 'value') + return value ? patternNames(value) : [] + } + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + return left ? patternNames(left) : [] + } + return [...node.fields.values()].flat().flatMap(patternNames) +} + +export function createScopes(root: GuardNode) { + const scopes = new Map() + const moduleScope: GuardScope = { functionScope: true, bindings: new Map() } + function visit(node: GuardNode, parent: GuardScope) { + const functionScope = + /^(?:Program|FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/.test( + node.kind, + ) + const classScope = ['ClassDeclaration', 'ClassExpression'].includes( + node.kind, + ) + const scope = + node === root + ? moduleScope + : functionScope || + classScope || + [ + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForOfStatement', + 'ForInStatement', + 'StaticBlock', + 'SwitchStatement', + ].includes(node.kind) + ? { + parent, + functionScope, + bindings: new Map(), + } + : parent + scopes.set(node, scope) + const id = field(node, 'id') + if (id && ['FunctionDeclaration', 'ClassDeclaration'].includes(node.kind)) + parent.bindings.set(id.name, undefined) + if (functionScope || classScope) { + if (id) scope.bindings.set(id.name, undefined) + for (const parameter of node.fields.get('params') ?? []) + for (const name of patternNames(parameter)) + scope.bindings.set(name, undefined) + } + const param = field(node, 'param') + if (param) + for (const name of patternNames(param)) + scope.bindings.set(name, undefined) + if (node.kind === 'VariableDeclaration') { + let target = scope + if (node.value === 'var') + while (!target.functionScope && target.parent) target = target.parent + for (const declaration of node.fields.get('declarations') ?? []) { + const pattern = field(declaration, 'id') + if (pattern) + for (const name of patternNames(pattern)) + target.bindings.set(name, undefined) + } + } + for (const children of node.fields.values()) + for (const child of children) visit(child, scope) + } + visit(root, moduleScope) + return { scopes, moduleScope } +} diff --git a/packages/plugin-utils/src/guard-origins.ts b/packages/plugin-utils/src/guard-origins.ts new file mode 100644 index 000000000..c4bb269cc --- /dev/null +++ b/packages/plugin-utils/src/guard-origins.ts @@ -0,0 +1,60 @@ +import { + binding, + bindingScope, + field, + type GuardNode, + type GuardOrigin, + type GuardScope, + nodeName, +} from './guard-nodes' + +export function origin( + node: GuardNode | undefined, + scope: GuardScope, +): GuardOrigin | undefined { + if (!node) return + if (node.kind === 'Identifier') return binding(scope, node.name) + if (node.kind === 'CallExpression') { + const callee = field(node, 'callee') + const args = node.fields.get('arguments') ?? [] + const request = args[0] + if ( + callee?.kind === 'Identifier' && + callee.name === 'require' && + !bindingScope(scope, 'require') && + args.length === 1 && + request?.kind === 'Literal' && + request.value + ) + return { request: request.value, ids: [], kind: 'require-call' } + } + if (node.kind === 'MemberExpression') { + const object = origin(field(node, 'object'), scope) + const property = field(node, 'property') + if (object && property && (!node.computed || property.kind === 'Literal')) + return { ...object, ids: [...object.ids, nodeName(property)] } + } +} + +/** Binding patterns are not reads; their defaults and computed keys are. */ +export function patternExpressions(node: GuardNode): readonly GuardNode[] { + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + const right = field(node, 'right') + return [ + ...(left ? patternExpressions(left) : []), + ...(right ? [right] : []), + ] + } + if (node.kind === 'Property') { + const key = field(node, 'key') + const value = field(node, 'value') + return [ + ...(node.computed && key ? [key] : []), + ...(value ? patternExpressions(value) : []), + ] + } + if (['ObjectPattern', 'ArrayPattern', 'RestElement'].includes(node.kind)) + return [...node.fields.values()].flat().flatMap(patternExpressions) + return [] +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index cc15f2346..885f501a7 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -17,10 +17,62 @@ import { computeCompiledFiles, computeFileReach, computeFileRoutes, + computeReachableFiles, createModuleResolver, planAtomHoist, runImportGraphCli, } from './import-graph' +import { ConfigLoadError } from './load-config' + +describe('computeReachableFiles', () => { + let tempRoot: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-reachable-files-')) + srcDir = join(tempRoot, 'src') + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(tempRoot, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('follows static and dynamic imports from the entries only', () => { + writeFixture('src/index.tsx', "import './a'\nimport('./lazy')\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/lazy.tsx', "import './b'\n") + writeFixture('src/unused.stories.tsx', "import './a'\n") + + expect( + computeReachableFiles({ srcDir, entries: [join(srcDir, 'index')] }), + ).toEqual( + ['a.tsx', 'b.tsx', 'index.tsx', 'lazy.tsx'].map((file) => + join(srcDir, file), + ), + ) + }) + + it('reaches nothing from entries outside the source directory', () => { + writeFixture('src/a.tsx', 'export const a = 1\n') + writeFixture('other/entry.tsx', "import '../src/a'\n") + const graph = buildStaticImportGraph(srcDir) + + expect( + computeReachableFiles({ + srcDir, + entries: [join(tempRoot, 'other/entry.tsx'), join(srcDir, 'missing')], + graph, + }), + ).toEqual([]) + }) +}) describe('buildCanonicalMap', () => { let tempRoot: string @@ -552,18 +604,25 @@ describe('buildCanonicalMap', () => { ).toEqual({}) }) - it('ignores a malformed tsconfig (JSON parse error)', () => { + it('reports a located error with cause when tsconfig is malformed', () => { writeFixture('tsconfig.json', '{ this is not json') writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - expect( + let caught: unknown + try { buildCanonicalMap({ cwd, srcDir, tsconfigPath: join(cwd, 'tsconfig.json'), - }), - ).toEqual({ 'src/b.tsx': 'src/a.tsx' }) + }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ConfigLoadError) + if (!(caught instanceof ConfigLoadError)) throw caught + expect(caught.message).toContain(`${join(cwd, 'tsconfig.json')}:1:1:`) + expect(caught.cause).toBeInstanceOf(SyntaxError) }) it('prefers the longest-prefix alias when multiple tsconfig paths overlap', () => { @@ -1015,11 +1074,7 @@ describe('planAtomHoist', () => { }) }) -// The oxc AST path is the fast parser used when `oxc-parser` is installed in -// the host project. It is absent in this repo, so we inject a fake parser to -// exercise the AST walk (module state is shared across test files, so this is -// reset after each test back to the regex fallback). -describe('oxc AST parsing path', () => { +describe('optional oxc diagnostic path', () => { let tempRoot: string let cwd: string let srcDir: string @@ -1042,115 +1097,91 @@ describe('oxc AST parsing path', () => { writeFileSync(filePath, code) } - it('collects every import/export node kind from the AST (type-only excluded)', () => { - const circular: Record = { type: 'SelfRef' } - circular.self = circular // self-reference -> exercises the `seen` guard + it('ignores every AST edge kind and derives canonical members from real source', () => { const richProgram = { type: 'Program', body: [ - // value import -> static edge (getStringLiteralValue via `.value`) { type: 'ImportDeclaration', importKind: 'value', source: { value: './val' }, }, - // `import type` -> skipped (importKind 'type') { type: 'ImportDeclaration', importKind: 'type', source: { value: './t1' }, }, - // value re-export -> static edge { type: 'ExportNamedDeclaration', exportKind: 'value', source: { value: './exp' }, }, - // `export type` -> skipped (exportKind 'type') { type: 'ExportNamedDeclaration', exportKind: 'type', source: { value: './t2' }, }, - // export-all -> static edge { type: 'ExportAllDeclaration', source: { value: './all' } }, - // dynamic import expression with `.source` { type: 'ImportExpression', source: { value: './dyn1' } }, - // dynamic import expression falling back to `.argument` { type: 'ImportExpression', argument: { value: './dyn2' } }, - // import() call via callee.type === 'Import', specifier via `.raw` { type: 'CallExpression', callee: { type: 'Import' }, arguments: [{ raw: "'./dyn3'" }], }, - // import() call via callee.name === 'import' { type: 'CallExpression', callee: { name: 'import' }, arguments: [{ value: './dyn4' }], }, - // import() with non-array arguments -> first arg undefined -> no push { type: 'CallExpression', callee: { type: 'Import' }, arguments: 'not-an-array', }, - // non-import call (isImportCallee false via name) -> falls through { type: 'CallExpression', callee: { name: 'other' }, arguments: [] }, - // non-record callee -> isImportCallee returns false { type: 'CallExpression', callee: null, arguments: [] }, - // source literal with neither `.value` nor `.raw` -> no push { type: 'ImportDeclaration', importKind: 'value', source: { kind: 'no-literal' }, }, - circular, 'primitive-child', 7, null, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program: richProgram } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'parsed by the fake oxc parser, content ignored') - writeFixture( - 'src/val.tsx', - 'parsed by the fake oxc parser, content ignored', - ) - - // Proof the AST path ran: `a` statically imports `./val` -> val collapses - // into a. The regex fallback would parse the literal content -> no imports. + writeFixture('src/a.tsx', "import './real'") + writeFixture('src/val.tsx', 'export {}') + writeFixture('src/real.tsx', 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/val.tsx': 'src/a.tsx', + 'src/real.tsx': 'src/a.tsx', }) }) - it('skips AST import/export nodes whose specifiers are all inline-type', () => { + it('elides source type-only imports even when AST import kinds disagree', () => { const program = { type: 'Program', body: [ - // all-inline-type import -> erased by the bundler -> no edge { type: 'ImportDeclaration', importKind: 'value', - specifiers: [{ type: 'ImportSpecifier', importKind: 'type' }], + specifiers: [{ type: 'ImportSpecifier', importKind: 'value' }], source: { value: './phantom' }, }, - // all-inline-type re-export -> erased -> no edge { type: 'ExportNamedDeclaration', exportKind: 'value', - specifiers: [{ type: 'ExportSpecifier', exportKind: 'type' }], + specifiers: [{ type: 'ExportSpecifier', exportKind: 'value' }], source: { value: './phantom2' }, }, - // mixed inline types -> module still imported -> edge kept { type: 'ImportDeclaration', importKind: 'value', @@ -1160,7 +1191,6 @@ describe('oxc AST parsing path', () => { ], source: { value: './mixed' }, }, - // default specifier (no importKind) alongside an inline type -> kept { type: 'ImportDeclaration', importKind: 'value', @@ -1170,38 +1200,47 @@ describe('oxc AST parsing path', () => { ], source: { value: './withdefault' }, }, - // non-record specifier entry -> not type-only -> kept { type: 'ImportDeclaration', importKind: 'value', specifiers: ['bogus'], source: { value: './bogus' }, }, - // empty specifier list (`import {} from`) -> kept (side-effect import) { type: 'ImportDeclaration', importKind: 'value', - specifiers: [] as unknown[], + specifiers: [], source: { value: './empty' }, }, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'fake parser input') - writeFixture('src/phantom.tsx', 'fake parser input') - writeFixture('src/phantom2.tsx', 'fake parser input') - writeFixture('src/mixed.tsx', 'fake parser input') - writeFixture('src/withdefault.tsx', 'fake parser input') - writeFixture('src/bogus.tsx', 'fake parser input') - writeFixture('src/empty.tsx', 'fake parser input') - - // phantom/phantom2 gain no importer (edges dropped) -> roots, not members. + writeFixture( + 'src/a.tsx', + [ + "import { type A } from './phantom'", + "export { type B } from './phantom2'", + "import { type A, b } from './mixed'", + "import Default, { type A } from './withdefault'", + "import { value } from './bogus'", + "import {} from './empty'", + ].join('\n'), + ) + for (const name of [ + 'phantom', + 'phantom2', + 'mixed', + 'withdefault', + 'bogus', + 'empty', + ]) + writeFixture(`src/${name}.tsx`, 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ 'src/bogus.tsx': 'src/a.tsx', 'src/empty.tsx': 'src/a.tsx', @@ -1210,20 +1249,19 @@ describe('oxc AST parsing path', () => { }) }) - it('falls back to the regex scan when the oxc parser throws', () => { + it('reports an optional parser fault rather than silently changing edge authority', () => { __setOxcParserForTest({ parseSync: () => { - throw new Error('boom') + throw Object.assign(new Error('boom'), { line: 2, column: 3 }) }, }) writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - // parseSync throws -> parseImportsWithOxc returns undefined -> scanImports. - expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/b.tsx': 'src/a.tsx', - }) + expect(() => buildCanonicalMap({ cwd, srcDir })).toThrow( + `${join(srcDir, 'a.tsx')}:2:3`, + ) }) }) @@ -1342,7 +1380,10 @@ describe('createModuleResolver', () => { expect(resolveModule(join(root, 'src/tokens'), 'src/App.tsx')?.path).toBe( posix('src/tokens.ts'), ) - expect(resolveModule('./style.css', 'src/App.tsx')).toBeUndefined() + expect(resolveModule('./style.css', 'src/App.tsx')).toEqual({ + path: posix('src/style.css'), + code: 'body {}', + }) expect(resolveModule('./missing.ts', 'src/App.tsx')).toBeUndefined() expect(resolveModule('not-installed', 'src/App.tsx')).toBeUndefined() }) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aa55a9bc8..faa019b06 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -7,14 +7,52 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { + freezeImportRequests, + type ImportGraphRequest, + type ImportRequestOutcome, + retainImportGraphFailure, +} from './import-requests' +import { + type ImportRequestReference, + scanImportRequests, +} from './import-scanner' +import { ConfigLoadError } from './load-config' +import { remapMdxError } from './mdx-errors' +import { + type AliasResolution, + ModuleAliasPackageError, + resolveModuleAlias, +} from './module-alias' +import { preparedDiagnostics } from './prepared-diagnostics' import { - dirname, - extname, - isAbsolute, - join, - relative, - resolve, -} from 'node:path' + createPreparedResolver, + type ModuleResolver, +} from './prepared-resolver' +import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' +import { + createResolutionInputs, + readResolutionFile, + realpathResolution, + type ResolutionInputCollector, + type ResolutionInputObserver, +} from './resolution-inputs' +import { + createNodeModulesExcludeRegex, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, +} from './shared' +import { + isSelectedSource, + type MdxSelection, + sourceExtensions, + type SourceSelectionOptions, +} from './source-selection' +import { type PathAlias, readPathAliases } from './tsconfig' +import type { ModuleAliasOptions } from './types' /** * How map keys (and bucket-root values) are stringified. @@ -33,7 +71,7 @@ function makeToKey(cwd: string, keyBy: GraphKeyMode): (file: string) => string { } export interface BuildCanonicalMapOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string hoistV?: number @@ -42,11 +80,6 @@ export interface BuildCanonicalMapOptions { graph?: StaticImportGraph } -interface ImportReference { - kind: 'static' | 'dynamic' - specifier: string -} - interface OxcParser { parseSync: ( filename: string, @@ -55,12 +88,6 @@ interface OxcParser { ) => unknown } -interface PathAlias { - prefix: string - suffix: string - targets: string[] -} - interface ResolveContext { aliases: PathAlias[] aliasBaseDir: string @@ -68,12 +95,11 @@ interface ResolveContext { srcDir: string } -const jsExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] -const jsFileRegex = /\.(?:tsx?|jsx?|mjs)$/ -const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/ +const jsExtensions: readonly string[] = SOURCE_EXTENSIONS +const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/i const routeFileRegex = - /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(tsx|ts|jsx|js)$/ -const leafRouteFileRegex = /(^|\/)page\.(tsx|ts|jsx|js)$/ + /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.[^./]+$/ +const leafRouteFileRegex = /(^|\/)page\.[^./]+$/ let cachedOxcParser: false | OxcParser | undefined @@ -85,6 +111,7 @@ let cachedOxcParser: false | OxcParser | undefined * hoisting reads and parses every source file only ONCE per build. */ export interface StaticImportGraph { + readonly requests?: readonly ImportGraphRequest[] /** absolute paths, sorted by POSIX path relative to `srcDir`. */ files: string[] fileSet: Set @@ -109,30 +136,153 @@ export interface StaticImportGraph { externalImports?: Map> } +export interface ScannedStaticImportGraph extends StaticImportGraph { + readonly requests: readonly ImportGraphRequest[] +} + /** * Scan `srcDir` once (`listSourceFiles` -> read -> parse imports -> resolve) * and return the full static import graph. All three graph consumers accept * the result, so the file I/O and parsing cost is paid a single time. */ export function buildStaticImportGraph( - srcDir: string, + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise +export function buildStaticImportGraph( + srcDir: string | string[], + tsconfigPath?: string, + options?: SyncGraphOptions, +): ScannedStaticImportGraph +export function buildStaticImportGraph( + srcDir: string | string[], tsconfigPath?: string, -): StaticImportGraph { - const resolvedSrcDir = resolve(srcDir) - const files = listSourceFiles(resolvedSrcDir) + options: SyncGraphOptions | PreparedGraphOptions = {}, +): ScannedStaticImportGraph | Promise { + if (options.prepareSource) + return drivePreparedGraph(srcDir, tsconfigPath, options) + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) step = traversal.next(undefined) + return step.value +} + +export type PreparedSource = + | string + | { + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + | undefined +export type PrepareSource = ( + filename: string, +) => PreparedSource | Promise +export interface SyncGraphOptions extends StaticImportGraphOptions { + readonly prepareSource?: never +} +export interface PreparedGraphOptions extends StaticImportGraphOptions { + readonly prepareSource: PrepareSource +} + +class GraphPreparationError extends Error { + constructor(filename: string, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause) + const location = + cause instanceof Error && + !(cause instanceof PreparedSourceTypeError) && + 'line' in cause && + typeof cause.line === 'number' + ? `${cause.line}:${'column' in cause && typeof cause.column === 'number' ? cause.column : 1}` + : '1:1' + super( + `${filename}:${location}: Graph source preparation failed: ${message}`, + { cause }, + ) + this.name = 'GraphPreparationError' + } +} + +async function drivePreparedGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise { + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) { + const filename = step.value + let prepared: PreparedSource + try { + prepared = readPreparedSource(await options.prepareSource(filename)) + } catch (cause) { + const located = new GraphPreparationError(filename, cause) + if (cause instanceof PreparedSourceTypeError) throw located + const remapped = remapMdxError(located, filename) + throw new Error( + remapped.message.replaceAll( + '(in compiled MDX)', + '(in compiled output)', + ), + { cause }, + ) + } + step = traversal.next(prepared) + } + return step.value +} + +function* traverseGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: StaticImportGraphOptions, +): Generator { + const cwd = resolve(options.cwd ?? process.cwd()) + const roots = (typeof srcDir === 'string' ? [srcDir] : srcDir).map((dir) => + resolve(cwd, dir), + ) + const files = [ + ...new Set( + roots.flatMap((root) => listSourceFiles(root, options.exclude, options)), + ), + ].sort(compareCodePoints) const fileSet = new Set(files) - const aliases = readPathAliases(tsconfigPath) - const context: ResolveContext = { - aliases: aliases.aliases, - aliasBaseDir: aliases.baseDir, - files: fileSet, - srcDir: resolvedSrcDir, + const directoryExclusion = createDirectoryExclusion(options.exclude) + const exclusionObservation: { outcome: ImportRequestOutcome } = { + outcome: { kind: 'unresolved' }, } + const excludedDirectory = (directory: string) => { + const entry = directoryExclusion.match(directory) + if (entry !== undefined) + exclusionObservation.outcome = { kind: 'excluded', entry } + return entry !== undefined + } + let resolutionInputs = createResolutionInputs().snapshot() + const graphInputs = createResolutionInputs() + const resolver = createModulePathResolver( + { + cwd, + tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), + conditions: options.conditions, + alias: options.alias, + includeMdx: options.includeMdx, + onResolutionInputs: (inputs) => { + resolutionInputs = inputs + options.onResolutionInputs?.(inputs) + }, + }, + excludedDirectory, + ) + const excluded = createNodeModulesExcludeRegex(options.include ?? []) + const includedRoots = new Set() + const localAliasRoots = new Set() const staticImporters = new Map>() const staticImports = new Map>() const dynamicImports = new Map>() const dynamicTargets = new Set() const externalImports = new Map>() + const requests: ImportGraphRequest[] = [] for (const file of files) { staticImporters.set(file, new Set()) @@ -141,32 +291,149 @@ export function buildStaticImportGraph( externalImports.set(file, new Set()) } - for (const file of files) { - const imports = parseImports(file, readFileSync(file, 'utf-8')) + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + const prepared = yield file + graphInputs.file(file) + options.onResolutionInputs?.(graphInputs.snapshot()) + const code = typeof prepared === 'string' ? prepared : prepared?.code + const imports = + code === undefined + ? parseImports(file, readFileSync(file, 'utf-8')) + : parsePreparedImports( + file, + code, + typeof prepared === 'object' ? prepared.map : undefined, + typeof prepared === 'object' ? prepared.sourceType : undefined, + ) for (const importRef of imports) { - const target = resolveImport(importRef.specifier, file, context) - if (!target) { + const request = { + importer: file, + request: importRef.request, + specifier: importRef.specifier, + kind: importRef.requestKind, + position: importRef.position, + source: code === undefined ? 'source' : 'compiled', + ...(code === undefined + ? {} + : { map: typeof prepared === 'object' ? prepared.map : undefined }), + } satisfies Omit + exclusionObservation.outcome = { kind: 'unresolved' } + try { + const resolution = resolver(importRef.specifier, file) + if (resolution === false) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue + } + if (resolution?.ignored === true) { + requests.push({ ...request, outcome: { kind: 'ignored' } }) + continue + } + const resolved = resolution?.path + if (resolved && excludedDirectory(dirname(resolved))) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue + } + requests.push({ + ...request, + outcome: + resolved !== undefined + ? { kind: 'resolved', path: resolved, inputs: resolutionInputs } + : !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ? { kind: 'external', request: importRef.specifier } + : { kind: 'unresolved' }, + }) + const rewritten = resolution?.request ?? importRef.specifier + const local = + resolved !== undefined && + isInsideDir(cwd, resolved) && + !relative(cwd, resolved).split(/[\\/]/).includes('node_modules') + if (resolved && local && rewritten !== importRef.specifier) + localAliasRoots.add(dirname(resolved)) if ( + resolved && !importRef.specifier.startsWith('.') && - !importRef.specifier.startsWith('/') && !isAbsolute(importRef.specifier) ) { - externalImports.get(file)?.add(importRef.specifier) + const request = rewritten + const parts = request.split('/') + const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') + if (!excluded.test(`node_modules/${name}/`)) { + const dir = findPackage(dirname(file), name, undefined, graphInputs) + if (dir) includedRoots.add(realpathSync(dir)) + } + if (isAbsolute(request) && !local && !excluded.test(resolved)) { + let directory = dirname(resolved) + while ( + dirname(directory) !== directory && + !isFile(join(directory, 'package.json'), graphInputs) + ) + directory = dirname(directory) + if (isFile(join(directory, 'package.json'), graphInputs)) + includedRoots.add(realpathSync(directory)) + } } - continue - } - if (importRef.kind === 'dynamic') { - dynamicTargets.add(target) - dynamicImports.get(file)?.add(target) - continue + const target = + resolved && + isSelectedSource(resolved, options.includeMdx) && + (fileSet.has(resolved) || + roots.some( + (root) => + isInsideDir(root, resolved) && + !relative(root, resolved) + .split(/[\\/]/) + .includes('node_modules'), + ) || + (local && + [...localAliasRoots].some((root) => + isInsideDir(root, resolved), + )) || + (!excluded.test(resolved) && + [...includedRoots].some((root) => isInsideDir(root, resolved)))) + ? resolved + : undefined + if (!target) { + if ( + !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ) { + externalImports.get(file)?.add(importRef.specifier) + } + continue + } + if (!fileSet.has(target)) { + fileSet.add(target) + files.push(target) + staticImporters.set(target, new Set()) + staticImports.set(target, new Set()) + dynamicImports.set(target, new Set()) + externalImports.set(target, new Set()) + } + if (importRef.kind === 'dynamic') { + dynamicTargets.add(target) + dynamicImports.get(file)?.add(target) + continue + } + staticImporters.get(target)?.add(file) + staticImports.get(file)?.add(target) + } catch (error) { + retainImportGraphFailure(error, { + ...request, + outcome: { kind: 'error', error }, + }) + throw error + } finally { + options.onResolutionInputs?.(graphInputs.snapshot()) } - staticImporters.get(target)?.add(file) - staticImports.get(file)?.add(target) } } return { - files, + requests: freezeImportRequests(requests), + files: files.sort(compareCodePoints), fileSet, staticImports, staticImporters, @@ -176,13 +443,37 @@ export function buildStaticImportGraph( } } +export interface StaticImportGraphOptions { + readonly onResolutionInputs?: ResolutionInputObserver + /** Include MDX only when the caller compiles it before extraction. */ + readonly includeMdx?: MdxSelection + readonly alias?: ModuleAliasOptions + readonly cwd?: string + readonly include?: readonly string[] + readonly conditions?: readonly string[] + /** Bare directory names at any depth, or absolute directories and descendants. */ + readonly exclude?: readonly string[] +} + +function graphRoot(srcDir: string | string[], cwd: string): string { + if (typeof srcDir === 'string') return resolve(cwd, srcDir) + let root = resolve(cwd, srcDir[0] ?? '.') + for (const dir of srcDir) { + const absolute = resolve(cwd, dir) + while (!isInsideDir(root, absolute) && dirname(root) !== root) + root = dirname(root) + } + return root +} + export function buildCanonicalMap( opts: BuildCanonicalMapOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const globalFiles = getRouteReachableGlobalFiles( files, @@ -227,7 +518,7 @@ export function buildCanonicalMap( } export interface ComputeFileRoutesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -250,14 +541,15 @@ export function computeFileRoutes( opts: ComputeFileRoutesOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -279,7 +571,7 @@ export function computeFileRoutes( } export interface ComputeCompiledFilesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -308,9 +600,10 @@ export function computeCompiledFiles( opts: ComputeCompiledFilesOptions, ): string[] { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, dynamicImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const allImports = new Map>() for (const file of files) { @@ -336,8 +629,46 @@ export function computeCompiledFiles( return [...compiled].map((file) => toPosixRelative(cwd, file)).sort() } +export interface ComputeReachableFilesOptions { + srcDir: string | string[] + tsconfigPath?: string + /** The bundler's entry modules, as absolute paths with or without extension. */ + entries: string[] + /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ + graph?: StaticImportGraph +} + +/** + * The source files under `srcDir` a bundler compiles from `entries`: their + * closure over static and dynamic `import()` edges, as absolute paths in the + * graph's order. Extracting them before bundling fills the shared stylesheet + * without the styles of files no entry imports. + */ +export function computeReachableFiles( + opts: ComputeReachableFilesOptions, +): string[] { + const { files, fileSet, staticImports, dynamicImports } = + opts.graph ?? buildStaticImportGraph(opts.srcDir, opts.tsconfigPath) + const queue = opts.entries + .map((entry) => resolveFile(resolve(entry))) + .filter( + (entry): entry is string => + typeof entry === 'string' && fileSet.has(entry), + ) + const reached = new Set() + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (reached.has(file)) continue + reached.add(file) + for (const imports of [staticImports, dynamicImports]) { + for (const target of imports.get(file) ?? []) queue.push(target) + } + } + return files.filter((file) => reached.has(file)) +} + export interface ComputeFileReachOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** @@ -372,9 +703,10 @@ export function computeFileReach( opts: ComputeFileReachOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, fileSet, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) let entries: string[] if (opts.entries && opts.entries.length > 0) { @@ -389,7 +721,7 @@ export function computeFileReach( ) } entries = [...new Set(entries)].sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') @@ -562,6 +894,11 @@ function getStaticClosure( return closure } +/** Order that is the same on every machine, unlike localeCompare. */ +export function compareCodePoints(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS @@ -569,13 +906,19 @@ function getStaticClosure( * pre-warm the extractor over exactly the file set the canonical map was built * from. Returns absolute paths. */ -export function listSourceFiles(srcDir: string): string[] { +export function listSourceFiles( + srcDir: string, + exclude: readonly string[] = [], + options: SourceSelectionOptions = {}, +): string[] { const files: string[] = [] + const excludedDirectory = createDirectoryExclusion(exclude) function visit(dir: string): void { + if (excludedDirectory(resolve(dir))) return if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -585,7 +928,7 @@ export function listSourceFiles(srcDir: string): string[] { continue } if (!entry.isFile()) continue - if (!jsFileRegex.test(entry.name)) continue + if (!isSelectedSource(entry.name, options.includeMdx)) continue if (testFileRegex.test(entry.name)) continue files.push(resolve(entryPath)) } @@ -593,31 +936,107 @@ export function listSourceFiles(srcDir: string): string[] { visit(srcDir) return files.sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) } -function parseImports(filename: string, source: string): ImportReference[] { - const astImports = parseImportsWithOxc(filename, source) - if (astImports) return astImports - return scanImports(source) +function parseImports( + filename: string, + source: string, +): ImportRequestReference[] { + if (!SOURCE_FILE_RE.test(filename)) + return scanImportRequests(mdxEsmSource(source), false, false) + return parseSourceImports(filename, source) } -function parseImportsWithOxc( +function parsePreparedImports( filename: string, source: string, -): ImportReference[] | undefined { - const parser = getOxcParser() - if (!parser) return undefined + map: unknown, + sourceType?: import('./prepared-source').SourceType, +): ImportRequestReference[] { + return parseSourceImports(filename, source, { map, sourceType }) +} +function parseSourceImports( + filename: string, + source: string, + prepared?: { + readonly map: unknown + readonly sourceType?: import('./prepared-source').SourceType + }, +): ImportRequestReference[] { + const parser = getOxcParser() try { - const ast = parser.parseSync(filename, source, { sourceType: 'module' }) - const imports: ImportReference[] = [] - collectAstImports(ast, imports) - return imports - } catch { - return undefined + const ast = parser?.parseSync( + filename, + source, + prepared && + (prepared.sourceType === 'compiled-mdx' || + !/\.[mc]?tsx?$/i.test(filename)) + ? { sourceType: 'module', lang: 'jsx' } + : { sourceType: 'module' }, + ) + if (isRecord(ast) && Array.isArray(ast.errors) && ast.errors.length) { + throw new Error(preparedDiagnostics(filename, source, ast.errors)) + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + const located = message.startsWith(`${filename}:`) + ? cause + : new GraphPreparationError(filename, cause) + if (!prepared) throw located + const remapped = remapMdxError(located, filename, prepared.map) + throw new Error( + remapped.message.replaceAll('(in compiled MDX)', '(in compiled output)'), + { cause }, + ) } + return scanImportRequests( + source, + prepared?.sourceType === 'compiled-mdx' || + /\.[jt]sx$/i.test(filename) || + (prepared !== undefined && !/\.[mc]?ts$/i.test(filename)), + prepared?.sourceType !== 'compiled-mdx' && /\.[mc]?tsx?$/i.test(filename), + ) +} + +function mdxEsmSource(source: string): string { + const blocks: string[] = [] + let fence = '' + let esm = false + let comment = false + for (const line of source.split(/(?<=\n)/)) { + const blank = line.replace(/[^\r\n]/g, ' ') + if (line.includes('')) comment = false + esm = false + blocks.push(blank) + continue + } + const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1] + if (marker) { + if (!fence) fence = marker + else if (marker[0] === fence[0] && marker.length >= fence.length) + fence = '' + esm = false + blocks.push(blank) + continue + } + if (fence) { + blocks.push(blank) + continue + } + if (!line.trim()) { + esm = false + blocks.push(blank) + continue + } + if (/^(?:import\s+(?!\()|export\s+)/.test(line)) esm = true + blocks.push(esm ? line : blank) + } + return blocks.join('') } function getOxcParser(): OxcParser | undefined { @@ -639,10 +1058,10 @@ function getOxcParser(): OxcParser | undefined { /** * @internal test-only: force the cached oxc parser. oxc-parser is an optional - * peer that is absent in this repo, so the AST path is otherwise unreachable + * peer that is absent in this repo, so the diagnostic path is otherwise unreachable * from tests; module state is shared across test files (no per-file reset), so * `mock.module` cannot toggle it deterministically. Pass `undefined` to clear - * the cache and re-detect (back to the regex fallback). + * the cache and re-detect. The scanner remains the sole edge authority. */ export function __setOxcParserForTest( parser: OxcParser | false | undefined, @@ -650,306 +1069,33 @@ export function __setOxcParserForTest( cachedOxcParser = parser } -function collectAstImports( - node: unknown, - imports: ImportReference[], - seen = new WeakSet(), -): void { - if (!isRecord(node)) return - if (seen.has(node)) return - seen.add(node) - - const type = typeof node.type === 'string' ? node.type : undefined - if ( - type === 'ImportDeclaration' || - type === 'ExportNamedDeclaration' || - type === 'ExportAllDeclaration' - ) { - // `import type`/`export type ... from` carry importKind/exportKind 'type'. - // They are erased at build time (no runtime module), so they must NOT - // become static graph edges — see the regex fallback in `scanImports`. - // The same applies when every specifier is inline-type - // (`import { type A } from` / `export { type A } from`). - if ( - node.importKind !== 'type' && - node.exportKind !== 'type' && - !hasOnlyInlineTypeSpecifiers(node) - ) { - addAstImport(imports, 'static', node.source) - } - } else if (type === 'ImportExpression') { - addAstImport(imports, 'dynamic', node.source ?? node.argument) - } else if (type === 'CallExpression' && isImportCallee(node.callee)) { - const firstArgument = Array.isArray(node.arguments) - ? node.arguments[0] - : undefined - addAstImport(imports, 'dynamic', firstArgument) - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - collectAstImports(child, imports, seen) - } - continue - } - collectAstImports(value, imports, seen) - } -} - -// AST counterpart of `isAllInlineTypeSpecifiers`: an import/re-export whose -// specifiers are ALL inline-type is erased by the bundler (no runtime module), -// so it must not become a static graph edge. Default/namespace specifiers -// carry no `type` kind, so their presence keeps the edge. -function hasOnlyInlineTypeSpecifiers(node: Record): boolean { - const specifiers = node.specifiers - if (!Array.isArray(specifiers) || specifiers.length === 0) return false - return specifiers.every( - (specifier) => - isRecord(specifier) && - (specifier.importKind === 'type' || specifier.exportKind === 'type'), - ) -} - -function addAstImport( - imports: ImportReference[], - kind: ImportReference['kind'], - node: unknown, -): void { - const specifier = getStringLiteralValue(node) - if (specifier) imports.push({ kind, specifier }) -} - -function getStringLiteralValue(node: unknown): string | undefined { - if (!isRecord(node)) return undefined - if (typeof node.value === 'string') return node.value - if (typeof node.raw === 'string') return node.raw.slice(1, -1) - return undefined -} - -function isImportCallee(node: unknown): boolean { - if (!isRecord(node)) return false - return node.type === 'Import' || node.name === 'import' -} - -// A brace clause whose specifiers are ALL inline-type (`{ type A, type B }`) -// is erased by the bundler exactly like a statement-level `import type`: -// TypeScript import elision (the Next.js/SWC default) removes the whole -// statement, so no runtime module is ever produced. Counting such an edge as -// static merges a phantom member into a bucket the bundler never compiles — -// the next-plugin coordinator then waits for a file that can never arrive. -// A mixed clause (`{ type A, b }`) still imports the module for `b` and is -// kept. A default/namespace clause is always a value import and is kept. -function isAllInlineTypeSpecifiers(clause: string | undefined): boolean { - if (!clause) return false - const trimmed = clause.trim() - if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false - const specifiers = trimmed - .slice(1, -1) - .split(',') - .map((specifier) => specifier.trim()) - .filter((specifier) => specifier.length > 0) - return ( - specifiers.length > 0 && - specifiers.every((specifier) => /^type\s/.test(specifier)) - ) -} - -function scanImports(source: string): ImportReference[] { - const imports: ImportReference[] = [] - const code = stripComments(source) - // The leading `(type\s+)?` is CAPTURED (not skipped) so we can drop type-only - // statements: `import type ... from` / `export type ... from` are erased by - // the bundler and produce NO runtime module — counting them as static graph - // edges merges phantom members into a bucket that the bundler never compiles, - // which is exactly what forced the coordinator's wall-clock fail-open to fire. - // The clause between the keyword and `from` is captured too, so all-inline- - // type specifier lists (`import { type A } from` / `export { type A } from`) - // — which the bundler also erases — are dropped via - // `isAllInlineTypeSpecifiers`. Mixed lists (`import { type A, b } from`) - // keep importing the module for `b`, so they are kept. - const staticImportRegex = - /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm - const exportFromRegex = - /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm - const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm - - for (const match of code.matchAll(staticImportRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(exportFromRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(dynamicImportRegex)) { - imports.push({ kind: 'dynamic', specifier: match[2] }) - } - - return imports -} - -function stripComments(source: string): string { - let result = '' - let index = 0 - let quote: false | '"' | "'" | '`' = false - - while (index < source.length) { - const char = source[index] - const next = source[index + 1] - - if (quote) { - // Template-literal CONTENTS are blanked (delimiters and newlines kept): - // embedded code snippets (docs sites, codegen templates) would otherwise - // look like real import statements to the scanners below and create - // phantom graph edges for files the bundler never loads. Contents of - // '/" strings are preserved — import specifiers themselves are read from - // those literals by the scan regexes. - if (quote === '`') { - if (char === '\\') { - result += ' ' - index += 2 - continue - } - result += char === '`' || char === '\n' ? char : ' ' - if (char === '`') quote = false - index += 1 - continue - } - result += char - if (char === '\\') { - result += next ?? '' - index += 2 - continue - } - if (char === quote) quote = false - index += 1 - continue - } - - if (char === '"' || char === "'" || char === '`') { - quote = char - result += char - index += 1 - continue - } - - if (char === '/' && next === '/') { - while (index < source.length && source[index] !== '\n') { - result += ' ' - index += 1 - } - continue - } - - if (char === '/' && next === '*') { - result += ' ' - index += 2 - while ( - index < source.length && - !(source[index] === '*' && source[index + 1] === '/') - ) { - result += source[index] === '\n' ? '\n' : ' ' - index += 1 - } - result += ' ' - index += 2 - continue - } - - result += char - index += 1 - } - - return result -} - -function readPathAliases(tsconfigPath: string | undefined): { - aliases: PathAlias[] - baseDir: string -} { - if (!tsconfigPath || !existsSync(tsconfigPath)) { - return { aliases: [], baseDir: process.cwd() } - } - - const configPath = resolve(tsconfigPath) - const configDir = dirname(configPath) - try { - const config = JSON.parse( - stripTrailingCommas(stripComments(readFileSync(configPath, 'utf-8'))), - ) - if (!isRecord(config) || !isRecord(config.compilerOptions)) { - return { aliases: [], baseDir: configDir } - } - - const baseUrl = - typeof config.compilerOptions.baseUrl === 'string' - ? config.compilerOptions.baseUrl - : '.' - const paths = config.compilerOptions.paths - if (!isRecord(paths)) { - return { aliases: [], baseDir: resolve(configDir, baseUrl) } - } - - const aliases: PathAlias[] = [] - for (const [alias, targetList] of Object.entries(paths)) { - if (!Array.isArray(targetList)) continue - const starIndex = alias.indexOf('*') - aliases.push({ - prefix: starIndex === -1 ? alias : alias.slice(0, starIndex), - suffix: starIndex === -1 ? '' : alias.slice(starIndex + 1), - targets: targetList.filter( - (target): target is string => typeof target === 'string', - ), - }) - } - - aliases.sort((a, b) => b.prefix.length - a.prefix.length) - return { aliases, baseDir: resolve(configDir, baseUrl) } - } catch { - return { aliases: [], baseDir: configDir } - } -} - -function stripTrailingCommas(json: string): string { - return json.replace(/,\s*([}\]])/g, '$1') -} - -function resolveImport( - specifier: string, - importer: string, - context: ResolveContext, -): string | undefined { - const candidateBases: string[] = [] - - if (specifier.startsWith('.')) { - candidateBases.push(resolve(dirname(importer), specifier)) - } else if (specifier.startsWith('/')) { - candidateBases.push(resolve(specifier)) - } else { - candidateBases.push(...resolveAliasCandidates(specifier, context)) - } - - for (const candidateBase of candidateBases) { - const resolvedFile = resolveFile(candidateBase) - if (!resolvedFile) continue - if (!context.files.has(resolvedFile)) continue - if (!isInsideDir(context.srcDir, resolvedFile)) continue - return resolvedFile - } - - return undefined -} - /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { + readonly ignored?: never path: string code: string + readonly sourceType?: import('./prepared-source').SourceType } +/** Successful native ignore: no filesystem module or manufactured source. */ +export interface IgnoredModule { + readonly ignored: true + readonly path?: never + readonly code?: never + readonly sourceType?: never + readonly request?: never +} + +export type ModuleResolution = ResolvedModule | IgnoredModule + export interface CreateModuleResolverOptions { + readonly onResolutionInputs?: ResolutionInputObserver + readonly prepareSource?: PrepareSource + readonly alias?: ModuleAliasOptions + readonly includeMdx?: MdxSelection cwd?: string tsconfigPath?: string + conditions?: readonly string[] /** * The name the plugin extracts a file under, given its absolute path. A * resolved module is extracted under this name, so it has to be the one the @@ -964,102 +1110,198 @@ export interface CreateModuleResolverOptions { * `module`, `main`), reading each module. */ export function createModuleResolver({ - cwd = process.cwd(), - tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, -}: CreateModuleResolverOptions = {}): ( + ...options +}: CreateModuleResolverOptions = {}): ModuleResolver { + const resolver = createModulePathResolver({ + ...options, + includeMdx: options.includeMdx ?? true, + }) + const prepared = createPreparedResolver(options) + return Object.assign( + (specifier: string, importer: string) => { + const resolution = resolver(specifier, importer) + if (!resolution) return undefined + if (resolution.ignored === true) return resolution + const { path } = resolution + const source = prepared.read( + path, + resolve(options.cwd ?? process.cwd(), importer), + ) + return prepared.resolved(toId(path), path, source) + }, + { remapError: prepared.remapError }, + ) +} + +function createModulePathResolver( + { + cwd = process.cwd(), + tsconfigPath = join(cwd, 'tsconfig.json'), + conditions = ['import', 'module', 'require', 'node'], + alias = {}, + includeMdx, + onResolutionInputs, + }: CreateModuleResolverOptions = {}, + excludedDirectory: ( + directory: string, + ) => boolean = createDirectoryExclusion(), +): ( specifier: string, importer: string, -) => ResolvedModule | undefined { - const { aliases, baseDir } = readPathAliases(tsconfigPath) +) => AliasResolution | IgnoredModule | false | undefined { + const setupInputs = createResolutionInputs() + const config = (() => { + try { + return readPathAliases(tsconfigPath, setupInputs) + } finally { + onResolutionInputs?.(setupInputs.snapshot()) + } + })() + const { aliases, baseDir, baseUrl } = config + const setup = setupInputs.snapshot() + const extensions = sourceExtensions(includeMdx) return (specifier, importer) => { + const inputs = createResolutionInputs(setup) + const fileResolver = (path: string) => + resolveFile(path, { extensions, excludedDirectory, inputs }) const from = resolve(cwd, importer) - const path = specifier.startsWith('.') - ? resolveSourceFile(resolve(dirname(from), specifier)) - : isAbsolute(specifier) - ? resolveSourceFile(specifier) - : (resolveAliasCandidates(specifier, { - aliases, - aliasBaseDir: baseDir, - }) - .map(resolveSourceFile) - .find(Boolean) ?? resolvePackage(specifier, from)) - return path - ? { path: toId(path), code: readFileSync(path, 'utf-8') } - : undefined + try { + return resolveModuleAlias(specifier, { + alias, + importer: from, + resolveRequest: (request, aliased) => + request.startsWith('.') + ? fileResolver(resolve(dirname(from), request)) + : isAbsolute(request) + ? fileResolver(request) + : (resolveAliasCandidates(request, { + aliases, + aliasBaseDir: baseDir, + }) + .map(fileResolver) + .find((candidate) => candidate !== undefined) ?? + (baseUrl + ? fileResolver(resolve(baseUrl, request)) + : undefined) ?? + resolvePackage(request, from, { + conditions, + fileResolver, + excludedDirectory, + aliased, + inputs, + })), + }) + } finally { + onResolutionInputs?.(inputs.snapshot()) + } } } -const sourceExtensions = [...jsExtensions, '.cjs'] - -/** `resolveFile`, also completing a name like `theme.css` to `theme.css.ts`. */ -function resolveSourceFile(base: string): string | undefined { - const candidates = sourceExtensions.includes(extname(base)) - ? [base] - : [ - ...jsExtensions.map((extension) => `${base}${extension}`), - ...jsExtensions.map((extension) => join(base, `index${extension}`)), - ] - const found = candidates.find(isFile) - return found && resolve(found) -} - -/** Conditions of package `exports`, in the order a bundler prefers them */ -const packageConditions = ['import', 'module', 'default', 'require', 'node'] - function resolvePackage( specifier: string, importer: string, -): string | undefined { + options: { + readonly conditions: readonly string[] + readonly fileResolver: (path: string) => string | false | undefined + readonly excludedDirectory: (directory: string) => boolean + readonly aliased: boolean + readonly inputs?: ResolutionInputCollector + }, +): string | false | undefined { const parts = specifier.split('/') const nameLength = specifier.startsWith('@') ? 2 : 1 const name = parts.slice(0, nameLength).join('/') - const packageDir = findPackage(dirname(importer), name) + const packageDir = findPackage( + dirname(importer), + name, + options.excludedDirectory, + options.inputs, + ) + if (packageDir === false) return false if (!packageDir) return undefined + const target = join(packageDir, ...parts.slice(nameLength)) + if ( + options.excludedDirectory(dirname(target)) || + options.excludedDirectory(target) + ) + return false + const manifestFile = join(packageDir, 'package.json') + const manifest = readPackageManifest(manifestFile, options.inputs) const found = resolvePackageEntry( packageDir, - JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf-8'), - ) as Record, + manifest, ['.', ...parts.slice(nameLength)].join('/'), + options.conditions, + options.fileResolver, ) - return found && realpathSync(found) + if (found === undefined && options.aliased && manifest.exports !== undefined) + throw new ModuleAliasPackageError(importer, specifier) + return found ? realpathResolution(found, options.inputs) : found } -function findPackage(dir: string, name: string): string | undefined { +function readPackageManifest( + file: string, + inputs?: ResolutionInputCollector, +): Record { + try { + const manifest: unknown = JSON.parse(readResolutionFile(file, inputs)) + if (!isRecord(manifest) || Array.isArray(manifest)) + throw new TypeError('Expected a package manifest object') + return manifest + } catch (cause) { + throw new ConfigLoadError(file, cause) + } +} + +function findPackage( + dir: string, + name: string, + excludedDirectory?: (directory: string) => boolean, + inputs?: ResolutionInputCollector, +): string | false | undefined { const packageDir = join(dir, 'node_modules', name) - if (isFile(join(packageDir, 'package.json'))) return packageDir + if (excludedDirectory?.(packageDir)) return false + if (isFile(join(packageDir, 'package.json'), inputs)) return packageDir const parent = dirname(dir) - return parent === dir ? undefined : findPackage(parent, name) + return parent === dir + ? undefined + : findPackage(parent, name, excludedDirectory, inputs) } function resolvePackageEntry( dir: string, manifest: Record, subpath: string, -): string | undefined { + conditions: readonly string[], + fileResolver: (path: string) => string | false | undefined, +): string | false | undefined { if (manifest.exports !== undefined) { - const target = exportsTarget(manifest.exports, subpath) - return target === undefined + const target = exportsTarget(manifest.exports, subpath, conditions) + return typeof target !== 'string' ? undefined - : resolveSourceFile(join(dir, target)) + : fileResolver(join(dir, target)) } - if (subpath !== '.') return resolveSourceFile(join(dir, subpath)) + if (subpath !== '.') return fileResolver(join(dir, subpath)) const main = [manifest.module, manifest.main].find( (entry): entry is string => typeof entry === 'string', ) - return resolveSourceFile(join(dir, main ?? 'index')) + return fileResolver(join(dir, main ?? 'index')) } -function exportsTarget(exports: unknown, subpath: string): string | undefined { +function exportsTarget( + exports: unknown, + subpath: string, + conditions: readonly string[], +): string | null | undefined { const subpaths = isRecord(exports) && !Array.isArray(exports) ? Object.keys(exports).filter((key) => key.startsWith('.')) : [] if (subpaths.length === 0) { - return subpath === '.' ? conditionTarget(exports) : undefined + return subpath === '.' ? conditionTarget(exports, conditions) : undefined } const map = exports as Record - if (subpath in map) return conditionTarget(map[subpath]) + if (subpath in map) return conditionTarget(map[subpath], conditions) for (const key of subpaths) { const [prefix, suffix = ''] = key.split('*') if ( @@ -1072,22 +1314,32 @@ function exportsTarget(exports: unknown, subpath: string): string | undefined { prefix.length, subpath.length - suffix.length, ) - return conditionTarget(map[key])?.replaceAll('*', matched) + return conditionTarget(map[key], conditions)?.replaceAll('*', matched) } } return undefined } -function conditionTarget(value: unknown): string | undefined { +function conditionTarget( + value: unknown, + conditions: readonly string[], +): string | null | undefined { if (typeof value === 'string') return value + if (value === null) return null if (Array.isArray(value)) { - return value.map(conditionTarget).find(Boolean) + for (const entry of value) { + const target = conditionTarget(entry, conditions) + if (target !== undefined) return target + } + return undefined } if (!isRecord(value)) return undefined - return packageConditions - .filter((condition) => condition in value) - .map((condition) => conditionTarget(value[condition])) - .find(Boolean) + for (const [condition, branch] of Object.entries(value)) { + if (condition !== 'default' && !conditions.includes(condition)) continue + const target = conditionTarget(branch, conditions) + if (target !== undefined) return target + } + return undefined } function resolveAliasCandidates( specifier: string, @@ -1097,7 +1349,9 @@ function resolveAliasCandidates( for (const alias of context.aliases) { if ( !specifier.startsWith(alias.prefix) || - !specifier.endsWith(alias.suffix) + !specifier.endsWith(alias.suffix) || + (alias.exact && specifier !== alias.prefix) || + specifier.length < alias.prefix.length + alias.suffix.length ) { continue } @@ -1110,34 +1364,79 @@ function resolveAliasCandidates( resolve(context.aliasBaseDir, target.replace('*', matched)), ) } + break } return candidates } -function resolveFile(candidateBase: string): string | undefined { - const ext = extname(candidateBase) - if (ext) { - if (!jsExtensions.includes(ext)) return undefined - return isFile(candidateBase) ? resolve(candidateBase) : undefined - } +function resolveFile( + candidateBase: string, + options: { + readonly extensions: readonly string[] + readonly excludedDirectory: (directory: string) => boolean + readonly inputs?: ResolutionInputCollector + } = { + extensions: jsExtensions, + excludedDirectory: createDirectoryExclusion(), + }, + stack: readonly string[] = [], +): string | false | undefined { + if ( + options.excludedDirectory(dirname(candidateBase)) || + options.excludedDirectory(candidateBase) + ) + return false + if (isFile(candidateBase, options.inputs)) return resolve(candidateBase) - for (const jsExtension of jsExtensions) { + for (const jsExtension of options.extensions) { const candidate = `${candidateBase}${jsExtension}` - if (isFile(candidate)) return resolve(candidate) + if (isFile(candidate, options.inputs)) return resolve(candidate) + } + const manifestFile = join(candidateBase, 'package.json') + if (isFile(manifestFile, options.inputs)) { + const canonical = realpathResolution(candidateBase, options.inputs) + if (stack.includes(canonical)) + throw new ConfigLoadError( + manifestFile, + new Error( + `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, + ), + ) + const manifest = readPackageManifest(manifestFile, options.inputs) + for (const main of [manifest.module, manifest.main]) { + if (typeof main !== 'string' || !main || main === '.' || main === './') + continue + const found = resolveFile(join(candidateBase, main), options, [ + ...stack, + canonical, + ]) + if (found !== undefined) return found + } } - for (const jsExtension of jsExtensions) { + for (const jsExtension of options.extensions) { const candidate = join(candidateBase, `index${jsExtension}`) - if (isFile(candidate)) return resolve(candidate) + if (isFile(candidate, options.inputs)) return resolve(candidate) } return undefined } -function isFile(path: string): boolean { +function isFile(path: string, inputs?: ResolutionInputCollector): boolean { try { - return statSync(path).isFile() - } catch { - return false + const file = statSync(path).isFile() + if (file) inputs?.file(path) + return file + } catch (cause) { + if ( + cause instanceof Error && + 'code' in cause && + (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') + ) { + inputs?.missing(path) + return false + } + inputs?.file(path) + throw cause } } diff --git a/packages/plugin-utils/src/import-mask.ts b/packages/plugin-utils/src/import-mask.ts new file mode 100644 index 000000000..dedb2802e --- /dev/null +++ b/packages/plugin-utils/src/import-mask.ts @@ -0,0 +1,251 @@ +import { maskImportTypes } from './import-type-mask' + +const typeParameterPrefix = + /^<(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:const(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+)?[A-Za-z_$][\w$]*(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:,|=|extends\b(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+[^\s=>/])/ + +function startsExpression(previous: string): boolean { + return ( + !/^(?:value|[\w$]+|\)|\]|\+\+|--|\.|\?\.)$/.test(previous) || + /^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|else|do)$/.test( + previous, + ) + ) +} + +/** Lexical masking only: the bundler, not this scanner, validates syntax. */ +export function maskImportText( + source: string, + jsx: boolean, + typescript = true, +): string { + const output = source.split('') + let index = 0 + let previous = '' + let jsxDepth = 0 + let text = false + let tag = false + let tagClosing = false + const parentheses: boolean[] = [] + const expressions: { + readonly braces: number + readonly depth: number + readonly tag: boolean + readonly tagClosing: boolean + readonly text: boolean + }[] = [] + const templates: number[] = [] + const blocks = new Set() + const bodies: { + readonly braces: number + readonly parentheses: number + readonly statement: boolean + }[] = [] + let templateText = false + let braces = 0 + function blank(start: number, end: number): void { + for ( + let offset = start; + offset < Math.min(end, source.length); + offset += 1 + ) { + if (output[offset] !== '\n' && output[offset] !== '\r') + output[offset] = ' ' + } + } + function quoted(quote: string): void { + const start = index++ + while (index < source.length) { + const char = source[index++] + if (char === '\\') index += 1 + else if (char === quote) break + } + blank(start + 1, index - 1) + previous = 'value' + } + function comment(line: boolean): void { + const start = index + index += 2 + while ( + index < source.length && + (line + ? source[index] !== '\n' + : !(source[index] === '*' && source[index + 1] === '/')) + ) + index += 1 + if (!line) index += 2 + blank(start, Math.min(index, source.length)) + } + function regexLiteral(): void { + const start = index++ + let bracket = false + while (index < source.length) { + const part = source[index++] + if (part === '\\') index += 1 + else if (part === '[') bracket = true + else if (part === ']') bracket = false + else if (part === '/' && !bracket) break + } + while (/[a-z]/i.test(source[index] ?? '') && index < source.length) + index += 1 + blank(start, index) + previous = 'value' + } + while (index < source.length) { + const char = source[index] + const next = source[index + 1] + if (templateText) { + blank(index, index + 1) + index += 1 + if (char === '\\') { + blank(index, index + 1) + index += 1 + } else if (char === '`') { + output[index - 1] = '`' + templates.pop() + templateText = false + previous = 'value' + } else if (char === '$' && next === '{') { + blank(index, index + 1) + index += 1 + braces += 1 + templateText = false + previous = '(' + } + continue + } + if ((tag || text) && char === '{') { + expressions.push({ braces, depth: jsxDepth, tag, tagClosing, text }) + braces += 1 + tag = text = false + index += 1 + previous = '{' + continue + } + if (tag) { + if (char === '"' || char === "'") { + quoted(char) + continue + } + if (char === '>') { + jsxDepth += tagClosing ? -1 : source[index - 1] === '/' ? 0 : 1 + tag = false + text = jsxDepth > (expressions.at(-1)?.depth ?? 0) + previous = 'value' + } + blank(index, index + 1) + index += 1 + continue + } + if (text && char !== '<' && char !== '{') { + blank(index, index + 1) + index += 1 + continue + } + if (char === '/' && (next === '/' || next === '*')) { + comment(next === '/') + continue + } + if (char === '`') { + templates.push(braces) + templateText = true + index += 1 + continue + } + if (char === '"' || char === "'") { + quoted(char) + continue + } + if (char === '/' && startsExpression(previous)) { + regexLiteral() + continue + } + const operator = text + ? undefined + : /^(?:>>>=|>>>|>>=|<<=|===|!==|\*\*=|&&=|\|\|=|\?\?=|=>|<<|>>|<=|>=|==|!=|&&|\|\||\?\?|\*\*|\+\+|--|\?\.|[+*/%&|^!-]=)/.exec( + source.slice(index), + )?.[0] + if (operator) { + previous = operator + index += operator.length + continue + } + if ( + jsx && + char === '<' && + (text || + (startsExpression(previous) && + !(typescript && typeParameterPrefix.test(source.slice(index))))) && + /[A-Za-z/>]/.test(next ?? '') + ) { + tag = true + tagClosing = next === '/' + text = false + blank(index, index + 1) + index += 1 + continue + } + if (char === '{') { + const body = bodies.at(-1) + if (body?.braces === braces && body.parentheses === parentheses.length) { + bodies.pop() + if (body.statement) blocks.add(braces) + } else if (/^(?:|;|\)|=>|try|finally|else|do)$/.test(previous)) + blocks.add(braces) + braces += 1 + } + if (char === '}') { + braces -= 1 + if (templates.at(-1) === braces) { + templateText = true + blank(index, index + 1) + } + const expression = expressions.at(-1) + if (expression?.braces === braces) { + expressions.pop() + tag = expression.tag + tagClosing = expression.tagClosing + text = expression.text + } + previous = blocks.delete(braces) ? '' : 'value' + index += 1 + continue + } + if (/[A-Za-z_$]/.test(char)) { + const start = index++ + while (/[\w$]/.test(source[index] ?? '') && index < source.length) + index += 1 + const word = source.slice(start, index) + if ( + word === 'async' && + /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)+function\b/.test( + source.slice(index), + ) + ) + continue + if (word === 'function' || word === 'class') + bodies.push({ + braces, + parentheses: parentheses.length, + statement: + /^(?:|;|[\w$]+|\)|\]|\+\+|--)$/.test(previous) && + !/^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|extends)$/.test( + previous, + ), + }) + previous = word === 'await' && previous === 'for' ? 'for' : word + continue + } + if (char === '(') + parentheses.push(/^(?:if|while|for|with|switch|catch)$/.test(previous)) + if (!/\s/.test(char)) + previous = + char === ')' && parentheses.pop() + ? '' + : char === '!' && typescript && !startsExpression(previous) + ? 'value' + : char + index += 1 + } + const masked = output.join('') + return typescript ? maskImportTypes(masked) : masked +} diff --git a/packages/plugin-utils/src/import-requests.ts b/packages/plugin-utils/src/import-requests.ts new file mode 100644 index 000000000..1baf71aab --- /dev/null +++ b/packages/plugin-utils/src/import-requests.ts @@ -0,0 +1,65 @@ +import type { ImportRequestReference } from './import-scanner' +import type { ResolutionInputs } from './resolution-inputs' + +export type ImportRequestOutcome = + | { + readonly kind: 'resolved' + readonly path: string + readonly inputs: ResolutionInputs + } + | { readonly kind: 'external'; readonly request: string } + | { readonly kind: 'ignored' } + | { readonly kind: 'excluded'; readonly entry: string } + | { readonly kind: 'unresolved' } + | { readonly kind: 'error'; readonly error: unknown } + +export interface ImportGraphRequest { + readonly importer: string + readonly request: string + readonly specifier: string + readonly kind: ImportRequestReference['requestKind'] + readonly position: ImportRequestReference['position'] + readonly source: 'source' | 'compiled' + readonly map?: unknown + readonly outcome: ImportRequestOutcome +} + +const failures = new WeakMap() + +export function importGraphFailureOf( + error: unknown, +): readonly ImportGraphRequest[] | undefined { + return (typeof error === 'object' && error !== null) || + typeof error === 'function' + ? failures.get(error) + : undefined +} + +export function retainImportGraphFailure( + error: unknown, + request: ImportGraphRequest, +): void { + if ( + (typeof error === 'object' && error !== null) || + typeof error === 'function' + ) + failures.set(error, freezeImportRequests([request])) +} + +export function freezeImportRequests( + requests: ImportGraphRequest[], +): readonly ImportGraphRequest[] { + requests.sort((a, b) => + a.importer < b.importer + ? -1 + : a.importer > b.importer + ? 1 + : a.position.offset - b.position.offset, + ) + for (const request of requests) { + Object.freeze(request.position) + Object.freeze(request.outcome) + Object.freeze(request) + } + return Object.freeze(requests) +} diff --git a/packages/plugin-utils/src/import-scanner.ts b/packages/plugin-utils/src/import-scanner.ts new file mode 100644 index 000000000..71d18f200 --- /dev/null +++ b/packages/plugin-utils/src/import-scanner.ts @@ -0,0 +1,189 @@ +import { maskImportText } from './import-mask' + +export interface ImportReference { + readonly kind: 'static' | 'dynamic' + readonly specifier: string +} + +export interface ImportRequestReference extends ImportReference { + readonly requestKind: + 'static-import' | 're-export' | 'literal-dynamic-import' | 'literal-require' + readonly request: string + readonly position: { + readonly offset: number + readonly line: number + readonly column: number + } +} + +function literalValue(raw: string): string { + return raw + .slice(1, -1) + .replace( + /\\(?:u\{([\da-f]+)\}|u([\da-f]{4})|x([\da-f]{2})|\r\n|[\s\S])/gi, + ( + escape: string, + point: string | undefined, + unicode: string | undefined, + hex: string | undefined, + ) => { + const digits = point ?? unicode ?? hex + if (digits) return String.fromCodePoint(Number.parseInt(digits, 16)) + const char = escape.slice(1) + const escaped: Readonly> = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + '0': '\0', + } + return /[\r\n]/.test(char) ? '' : (escaped[char] ?? char) + }, + ) +} + +function allInlineTypes(clause: readonly string[]): boolean { + if (clause[0] !== '{' || clause.at(-1) !== '}') return false + const specifiers = clause + .slice(1, -1) + .join(' ') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + return ( + specifiers.length > 0 && + specifiers.every((value) => { + const names = value.split(/\s+/) + return ( + names[0] === 'type' && + names.length > 1 && + (names[1] !== 'as' || + names.length === 2 || + (names[2] === 'as' && names.length > 3)) + ) + }) + ) +} + +/** Internal scanner seam; not part of the package export map. */ +export function scanImports( + source: string, + jsx: boolean, + typescript = true, +): ImportReference[] { + return scanImportRequests(source, jsx, typescript).map( + ({ kind, specifier }) => ({ + kind, + specifier, + }), + ) +} + +export function scanImportRequests( + source: string, + jsx: boolean, + typescript = true, +): ImportRequestReference[] { + const tokens = [ + ...maskImportText(source, jsx, typescript).matchAll( + /(['"])[^'"]*\1|[\w$]+|[^\s]/g, + ), + ].map((match) => ({ + value: match[0], + offset: match.index, + literal: match[1] + ? literalValue(source.slice(match.index, match.index + match[0].length)) + : undefined, + })) + const imports: ImportRequestReference[] = [] + function reference( + token: (typeof tokens)[number] & { readonly literal: string }, + requestKind: ImportRequestReference['requestKind'], + ): ImportRequestReference { + const before = source.slice(0, token.offset) + return { + kind: requestKind === 'literal-dynamic-import' ? 'dynamic' : 'static', + specifier: token.literal, + requestKind, + request: source.slice( + token.offset + 1, + token.offset + token.value.length - 1, + ), + position: { + offset: token.offset, + line: before.split(/\r\n|[\r\n\u2028\u2029]/).length, + column: + token.offset - + Math.max( + before.lastIndexOf('\n'), + before.lastIndexOf('\r'), + before.lastIndexOf('\u2028'), + before.lastIndexOf('\u2029'), + ), + }, + } + } + for (let index = 0; index < tokens.length; index += 1) { + const word = tokens[index].value + if ( + !['import', 'export', 'require'].includes(word) || + tokens[index - 1]?.value === '.' + ) + continue + const next = tokens[index + 1] + if (next?.value === '(' && word !== 'export') { + const argument = tokens[index + 2]?.literal + const end = tokens[index + 3]?.value + if (argument && (end === ')' || (word === 'import' && end === ','))) + imports.push( + reference( + { ...tokens[index + 2], literal: argument }, + word === 'require' ? 'literal-require' : 'literal-dynamic-import', + ), + ) + continue + } + if (word === 'require') continue + if (word === 'import' && next?.literal) { + imports.push( + reference({ ...next, literal: next.literal }, 'static-import'), + ) + continue + } + const clause: string[] = [] + let bindings = 0 + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + const token = tokens[cursor] + if ( + token.value === 'from' && + bindings === 0 && + tokens[cursor + 1]?.literal !== undefined + ) { + const specifier = tokens[cursor + 1]?.literal + if ( + specifier && + !(clause[0] === 'type' && clause.length > 1 && clause[1] !== ',') && + !allInlineTypes(clause) + ) + imports.push( + reference( + { ...tokens[cursor + 1], literal: specifier }, + word === 'export' ? 're-export' : 'static-import', + ), + ) + break + } + if ( + !/^(?:[\w$]+|[*{},])$/.test(token.value) && + token.literal === undefined + ) + break + if (token.value === '{') bindings += 1 + if (token.value === '}') bindings -= 1 + clause.push(token.value) + } + } + return imports +} diff --git a/packages/plugin-utils/src/import-type-mask.ts b/packages/plugin-utils/src/import-type-mask.ts new file mode 100644 index 000000000..58e5c1f13 --- /dev/null +++ b/packages/plugin-utils/src/import-type-mask.ts @@ -0,0 +1,205 @@ +/** Receives already-masked code; only type-context import keywords are blanked. */ +export function maskImportTypes(source: string): string { + const tokens = [ + ...source.matchAll( + /(['"])[^'"]*\1|=>|\?\.|===|!==|==|!=|&&|\|\||\?\?|[\w$]+|[^\s]/g, + ), + ] + const output = source.split('') + const pairs = new Map() + const stack: number[] = [] + const questions = new Map() + const ternaryColons = new Set() + const parameters = new Set() + const parameterEnds = new Set() + const classes = new Set() + const bindings = new Set() + const variables = new Set() + const value = (index: number): string => tokens[index]?.[0] ?? '' + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + if (/^[({[]$/.test(word)) stack.push(index) + else if (/^[)}\]]$/.test(word)) { + const opening = stack.pop() + if (opening !== undefined) pairs.set(opening, index) + } + const scope = stack.at(-1) ?? -1 + if (word === '?' && value(index + 1) !== ':') + questions.set(scope, (questions.get(scope) ?? 0) + 1) + if (word === ':' && (questions.get(scope) ?? 0) > 0) { + ternaryColons.add(index) + questions.set(scope, (questions.get(scope) ?? 0) - 1) + } + } + function typeEnd(start: number, stops: RegExp): number { + const closing: string[] = [] + let functionType = /^(?:\(|<)$/.test(value(start)) + let conditional = false + const delimiters: Readonly> = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + } + for (let cursor = start; cursor < tokens.length; cursor += 1) { + const word = value(cursor) + if (closing.length === 0) { + if ( + cursor > start && + /[\r\n]/.test( + source.slice(tokens[cursor - 1]?.index, tokens[cursor]?.index), + ) && + /^(?:[\w$]+|\)|\]|\}|>|`|(['"])[^'"]*\1)$/.test(value(cursor - 1)) && + !/^(?:typeof|keyof|extends|infer|import|new|abstract|is|in)$/.test( + value(cursor - 1), + ) && + ((/^[A-Za-z_$][\w$]*$/.test(word) && + !/^(?:extends|is|in)$/.test(word)) || + (word === '(' && !(functionType && value(cursor - 1) === '>'))) + ) + return cursor + if (word === 'extends') conditional = true + if (word === '=>' && functionType && value(cursor - 1) === ')') { + functionType = false + continue + } + if ( + (stops.test(word) && !(word === '?' && conditional)) || + /^(?:const|let|var|export|function|class|interface)$/.test(word) + ) + return cursor + } + if ( + word === '{' && + closing.length === 0 && + cursor > start && + !/^(?:[|&]|=>)$/.test(value(cursor - 1)) + ) + return cursor + const delimiter = delimiters[word] + if (delimiter) closing.push(delimiter) + else if (word === closing.at(-1)) closing.pop() + else if (/^[)}\]>]$/.test(word) && closing.length === 0) return cursor + } + return tokens.length + } + for (const [opening, closing] of pairs) { + if (value(opening) !== '(') continue + let prefix = opening - 1 + if (value(prefix) === '>') { + let depth = 1 + while (prefix > 0 && depth > 0) { + prefix -= 1 + if (value(prefix) === '>') depth += 1 + if (value(prefix) === '<') depth -= 1 + } + prefix -= 1 + } + const declaration = + value(prefix) === 'function' || + value(prefix - 1) === 'function' || + (value(prefix - 1) === '*' && value(prefix - 2) === 'function') + // A colon after grouping may belong to a ternary, not a return type. + // Require the annotation to end at an arrow/body, or a function signature. + const annotationEnd = + value(closing + 1) === ':' && !ternaryColons.has(closing + 1) + ? value(typeEnd(closing + 2, /^(?:=>|;|=|,|\?)$/)) + : '' + if ( + (/^(?:=>|\{)$/.test(value(closing + 1)) || + /^(?:=>|\{)$/.test(annotationEnd) || + (value(closing + 1) === ':' && + declaration && + !ternaryColons.has(closing + 1))) && + !/^(?:if|while|for|with|switch|catch|import|require)$/.test( + value(opening - 1), + ) + ) { + parameters.add(opening) + parameterEnds.add(closing) + } + } + function mask(start: number, end: number): void { + for (const token of tokens.slice(start, end)) { + if (token[0] !== 'import') continue + for (let offset = token.index; offset < token.index + 6; offset += 1) + output[offset] = ' ' + } + } + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + const scope = stack.at(-1) ?? -1 + if (word === '<') { + const end = typeEnd(index + 1, /^(?:>|;)$/) + const operand = /^[A-Za-z_$][\w$]*$/.test(value(index - 1)) + const prefix = + /^(?:=|\(|,|return|=>)$/.test(value(index - 1)) && + (value(index + 1) === 'import' || + /^(?:const|extends|,|=)$/.test(value(index + 2))) + if ( + value(end) === '>' && + (prefix || (operand && /^(?:\(|\{|;|,|\))$/.test(value(end + 1)))) + ) { + mask(index + 1, end) + index = end + continue + } + } + if (word === 'type' && /^[A-Za-z_$][\w$]*$/.test(value(index + 1))) { + const equals = typeEnd(index + 2, /^(?:=|;)$/) + if (value(equals) === '=') { + const end = typeEnd(equals + 1, /^;$/) + mask(equals + 1, end) + index = end - 1 + continue + } + } + if ( + /^(?:interface|class)$/.test(word) && + value(index - 1) !== '.' && + /^(?:[A-Za-z_$][\w$]*|\{)$/.test(value(index + 1)) + ) { + let body = index + 1 + while (body < tokens.length && !/^(?:\{|;)$/.test(value(body))) + body = (pairs.get(body) ?? body) + 1 + const end = pairs.get(body) + if (word === 'interface' && end !== undefined) { + mask(body, end) + index = end + continue + } + if (word === 'class') classes.add(body) + } + if ( + /^(?:const|let|var)$/.test(word) && + /^(?:[A-Za-z_$][\w$]*|\{|\[)$/.test(value(index + 1)) + ) { + variables.add(scope) + bindings.add(scope) + } + if ( + (word === ':' && (bindings.has(scope) || parameterEnds.has(index - 1))) || + (/^(?:as|satisfies)$/.test(word) && + /^(?:[\w$]+|[)\]}!]|(['"])[^'"]*\1|`)$/.test(value(index - 1)) && + !/^(?:=|:)$/.test(value(index + 1))) + ) { + const end = typeEnd(index + 1, /^(?:=|,|;|=>|\+|-|&&|\|\||\?\?|\?)$/) + mask(index + 1, end) + index = end - 1 + continue + } + if (word === '=') bindings.delete(scope) + if (word === ',' && (parameters.has(scope) || variables.has(scope))) + bindings.add(scope) + if (word === ';') { + variables.delete(scope) + if (classes.has(scope)) bindings.add(scope) + else bindings.delete(scope) + } + if (/^[({[]$/.test(word)) { + stack.push(index) + if (parameters.has(index) || classes.has(index)) bindings.add(index) + } else if (/^[)}\]]$/.test(word)) stack.pop() + } + return output.join('') +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b66b07abb..c20dfe3e2 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,16 @@ +export { beginBuild, type ResettableEngine } from './build-session' +export { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +export { + createDependencyGuard, + type DependencyGuardOptions, + type GuardModule, +} from './dependency-guard' +export { compiledFacts, type GuardFacts } from './guard-facts' export { type AtomHoistPlan, buildCanonicalMap, @@ -9,14 +22,56 @@ export { type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + computeReachableFiles, + type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, + type IgnoredModule, listSourceFiles, + type ModuleResolution, planAtomHoist, + type PreparedGraphOptions, + type PreparedSource, + type PrepareSource, type ResolvedModule, + type ScannedStaticImportGraph, type StaticImportGraph, + type StaticImportGraphOptions, + type SyncGraphOptions, } from './import-graph' -export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + importGraphFailureOf, + type ImportGraphRequest, + type ImportRequestOutcome, +} from './import-requests' +export { type ImportReference, scanImports } from './import-scanner' +export { + ConfigLoadError, + deepMerge, + loadDevupConfig, + loadDevupConfigSync, +} from './load-config' +export { remapMdxError } from './mdx-errors' +export { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from './mdx-selection' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + extractedNeedles, + type FileNumbering, + seedFileNumbers, +} from './numbering' +export type { ModuleResolver } from './prepared-resolver' +export type { SourceType } from './prepared-source' +export { + type ResolutionInputObserver, + type ResolutionInputs, +} from './resolution-inputs' +export { resolutionWatchPath } from './resolution-watch-path' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -24,12 +79,32 @@ export { type DevupThemeInterfaceNames, type DevupUIBasePluginOptions, getFileNumByFilename, + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, } from './shared' +export { + isSelectedSource, + type MdxSelection, + type SourceSelectionOptions, +} from './source-selection' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export type { CustomShorthands, DevupConfig, DevupTheme, ImportAliases, + ModuleAliasDescriptor, + ModuleAliases, + ModuleAliasOptions, ThemeColors, ThemeTypography, Typography, diff --git a/packages/plugin-utils/src/load-config.ts b/packages/plugin-utils/src/load-config.ts index a30c977ab..7564e34f7 100644 --- a/packages/plugin-utils/src/load-config.ts +++ b/packages/plugin-utils/src/load-config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -48,12 +48,48 @@ export function deepMerge(base: T, override: U): T { /** * Parse JSON content safely */ +export class ConfigLoadError extends Error { + constructor( + readonly file: string, + cause: unknown, + ) { + super( + `${file}:1:1: Cannot load configuration: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'ConfigLoadError' + } +} + +function isDevupConfig(value: unknown): value is DevupConfig { + return ( + isPlainObject(value) && + (value.extends === undefined || + (Array.isArray(value.extends) && + value.extends.every((path) => typeof path === 'string'))) + ) +} + function parseConfig(content: string): DevupConfig { - try { - return JSON.parse(content) as DevupConfig - } catch { - return {} + const config: unknown = JSON.parse(content) + if (!isDevupConfig(config)) + throw new TypeError( + 'Expected a config object with an array of extends paths', + ) + return config +} + +function configStack(configPath: string, stack: readonly string[]): string[] { + const canonical = realpathSync(configPath) + if (stack.includes(canonical) || stack.length >= 128) { + throw new ConfigLoadError( + configPath, + new Error( + `Configuration inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ), + ) } + return [...stack, canonical] } /** @@ -83,25 +119,39 @@ function mergeExtendedConfigs( * @param configPath - Path to the devup.json file * @returns Resolved configuration with all extends merged */ -export function loadDevupConfigSync(configPath: string): DevupConfig { - if (!existsSync(configPath)) { - return {} +export function loadDevupConfigSync( + configPath: string, + stack: readonly string[] = [], +): DevupConfig { + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(readFileSync(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = readFileSync(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) return mergeExtendedConfigs( config, config.extends.map((extendPath) => - loadDevupConfigSync(resolve(configDir, extendPath)), + loadDevupConfigSync(resolve(configDir, extendPath), nextStack), ), ) } @@ -115,25 +165,39 @@ export function loadDevupConfigSync(configPath: string): DevupConfig { */ export async function loadDevupConfig( configPath: string, + stack: readonly string[] = [], ): Promise { - if (!existsSync(configPath)) { - return {} + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(await readFile(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = await readFile(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) // Load extends sequentially to preserve merge order const extendedConfigs: DevupConfig[] = [] for (const extendPath of config.extends) { - extendedConfigs.push(await loadDevupConfig(resolve(configDir, extendPath))) + extendedConfigs.push( + await loadDevupConfig(resolve(configDir, extendPath), nextStack), + ) } return mergeExtendedConfigs(config, extendedConfigs) diff --git a/packages/plugin-utils/src/mdx-errors.ts b/packages/plugin-utils/src/mdx-errors.ts new file mode 100644 index 000000000..54f4d1e60 --- /dev/null +++ b/packages/plugin-utils/src/mdx-errors.ts @@ -0,0 +1,243 @@ +import { posix, win32 } from 'node:path' + +type SourceMap = { + readonly mappings: string + readonly sources: readonly string[] + readonly names: readonly string[] + readonly sourceRoot: string +} +type Segment = { + readonly generated: number + readonly original?: { + readonly source: string + readonly line: number + readonly column: number + } +} +const BASE64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +class MdxSourceMapError extends Error { + constructor(filename: string, cause: unknown) { + super( + `${filename}:1:1: Invalid MDX source map: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'MdxSourceMapError' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function strings(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ) +} + +function parseMap(input: unknown): SourceMap { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value)) throw new TypeError('Expected a source-map object') + if ( + value.version !== 3 || + typeof value.mappings !== 'string' || + !strings(value.sources) || + (value.names !== undefined && !strings(value.names)) || + (value.sourceRoot !== undefined && typeof value.sourceRoot !== 'string') + ) { + throw new TypeError( + 'Expected source-map v3 mappings, sources, names and sourceRoot', + ) + } + return { + mappings: value.mappings, + sources: value.sources, + names: value.names ?? [], + sourceRoot: value.sourceRoot ?? '', + } +} + +function decode(segment: string): number[] { + const values: number[] = [] + let value = 0 + let multiplier = 1 + for (const char of segment) { + const digit = BASE64.indexOf(char) + if (digit < 0) throw new TypeError('Invalid base64 VLQ digit') + value += (digit % 32) * multiplier + if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) + throw new RangeError('VLQ exceeds safe integer bounds') + if (digit >= 32) { + multiplier *= 32 + } else { + values.push((value % 2 === 1 ? -1 : 1) * Math.floor(value / 2)) + value = 0 + multiplier = 1 + } + } + if (multiplier !== 1) throw new TypeError('Unterminated base64 VLQ') + if (![1, 4, 5].includes(values.length)) + throw new TypeError('Expected 1, 4 or 5 VLQ fields') + return values +} + +function sourcePath(root: string, source: string): string { + if ( + !root || + posix.isAbsolute(source) || + win32.isAbsolute(source) || + /^[a-z][\w+.-]*:/i.test(source) + ) + return source + if (/^[a-z][\w+.-]*:\/\//i.test(root)) + return new URL(source, `${root.replace(/\/$/, '')}/`).href + return root.includes('\\') + ? win32.join(root, source) + : posix.join(root, source) +} + +function decodeMap(map: SourceMap): Segment[][] { + let source = 0 + let line = 0 + let column = 0 + let name = 0 + return map.mappings.split(';').map((encodedLine) => { + let generated = 0 + if (!encodedLine) return [] + return encodedLine.split(',').map((encoded) => { + const fields = decode(encoded) + const [delta, sourceDelta, lineDelta, columnDelta, nameDelta] = fields + const previous = generated + generated += delta + if (!Number.isSafeInteger(generated) || generated < previous) + throw new RangeError( + 'Generated columns must be nonnegative and ordered', + ) + if (fields.length === 1) return { generated } + source += sourceDelta + line += lineDelta + column += columnDelta + if ( + ![source, line, column].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) || + source >= map.sources.length + ) { + throw new RangeError('Original source position is out of bounds') + } + if (nameDelta !== undefined) { + name += nameDelta + if (!Number.isSafeInteger(name) || name < 0 || name >= map.names.length) + throw new RangeError('Name index is out of bounds') + } + return { + generated, + original: { + source: sourcePath(map.sourceRoot, map.sources[source]), + line: line + 1, + column: column + 1, + }, + } + }) + }) +} + +function decodeInput(input: unknown): Segment[][] { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value) || !('sections' in value)) + return decodeMap(parseMap(value)) + if (value.version !== 3 || !Array.isArray(value.sections)) + throw new TypeError('Expected source-map v3 sections') + const rows: Segment[][] = [] + let previousLine = -1 + let previousColumn = -1 + for (const section of value.sections) { + if (!isRecord(section) || !isRecord(section.offset)) + throw new TypeError('Expected a section offset and map') + const { line, column } = section.offset + if ( + typeof line !== 'number' || + typeof column !== 'number' || + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 0 || + column < 0 || + line < previousLine || + (line === previousLine && column <= previousColumn) + ) { + throw new RangeError('Section offsets must be nonnegative and ordered') + } + previousLine = line + previousColumn = column + rows.length = line + 1 + rows[line] = (rows[line] ?? []).filter( + (segment) => segment.generated < column, + ) + rows[line].push({ generated: column }) + decodeInput(section.map).forEach((segments, index) => { + const targetLine = line + index + const shifted = segments.map((segment) => ({ + ...segment, + generated: segment.generated + (index === 0 ? column : 0), + })) + if (shifted.some((segment) => !Number.isSafeInteger(segment.generated))) + throw new RangeError('Section generated column is out of bounds') + if (index === 0) rows[targetLine].push(...shifted) + else rows[targetLine] = shifted + }) + } + return rows +} + +/** Remap one-based extractor locations using zero-based source-map columns. */ +export function remapMdxError( + error: unknown, + filename: string, + inputSourceMap?: unknown, +): Error { + let lines: Segment[][] = [] + if (inputSourceMap !== undefined && inputSourceMap !== null) { + try { + lines = decodeInput(inputSourceMap) + } catch (cause) { + throw new MdxSourceMapError(filename, cause) + } + } + const message = error instanceof Error ? error.message : String(error) + const remapped = message + .split('\n') + .map((text) => { + if (!text.startsWith(`${filename}:`)) return text + const location = /^(\d+):(\d+)(?=:|\s|$)/.exec( + text.slice(filename.length + 1), + ) + if (!location) return text + const line = Number(location[1]) + const column = Number(location[2]) + if ( + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 1 || + column < 1 + ) { + throw new MdxSourceMapError( + filename, + new RangeError('Error location is out of bounds'), + ) + } + let selected: Segment | undefined + for (const segment of lines[line - 1] ?? []) { + if (segment.generated > column - 1) break + selected = segment + } + const position = selected?.original + const prefix = position + ? `${position.source}:${position.line}:${position.column}` + : `${filename}:${location[0]} (in compiled MDX)` + return prefix + text.slice(filename.length + 1 + location[0].length) + }) + .join('\n') + return new Error(remapped, { cause: error }) +} diff --git a/packages/plugin-utils/src/mdx-selection.ts b/packages/plugin-utils/src/mdx-selection.ts new file mode 100644 index 000000000..06d9829c1 --- /dev/null +++ b/packages/plugin-utils/src/mdx-selection.ts @@ -0,0 +1,46 @@ +import { extname } from 'node:path' + +import { sourceExtensions } from './source-selection' + +export class MdxExtensionError extends Error { + readonly name = 'MdxExtensionError' + constructor(readonly extension: string) { + super( + `mdxExtensions:1:1: invalid extension ${JSON.stringify(extension)}; use leading-dot literal extensions, for example '.mdown'`, + ) + } +} + +export function normalizeMdxExtensions( + extensions: readonly string[] = ['.mdx'], +): readonly string[] { + return [ + ...new Set( + extensions.map((extension) => { + if (!/^\.[a-z\d][a-z\d_-]*$/i.test(extension)) + throw new MdxExtensionError(extension) + return extension.toLowerCase() + }), + ), + ] +} + +export function isMdxSource( + filename: string, + extensions: readonly string[], +): boolean { + return extensions.includes(extname(filename.split('?')[0]).toLowerCase()) +} + +export function mdxSourceFilter(extensions: readonly string[]): RegExp { + return extensions.length + ? new RegExp( + `\\.${extensions.length === 1 ? extensions[0].slice(1) : `(?:${extensions.map((extension) => extension.slice(1)).join('|')})`}$`, + 'i', + ) + : /$^/ +} + +export function selectedSourceFilter(extensions: readonly string[]): RegExp { + return mdxSourceFilter(sourceExtensions(extensions)) +} diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts new file mode 100644 index 000000000..a9c8634ff --- /dev/null +++ b/packages/plugin-utils/src/module-alias.ts @@ -0,0 +1,129 @@ +import type { IgnoredModule } from './import-graph' +import type { ModuleAliasDescriptor, ModuleAliasOptions } from './types' + +export class ModuleAliasError extends Error { + constructor(importer: string, specifier: string) { + super(`${importer}:1:1: Module alias cycle cannot resolve ${specifier}`) + this.name = 'ModuleAliasError' + } +} + +export class ModuleAliasCandidatesError extends Error { + readonly name = 'ModuleAliasCandidatesError' + constructor( + readonly importer: string, + readonly key: string, + readonly candidates: readonly string[], + ) { + super( + `${importer}:1:1: Module alias ${key} cannot resolve candidates ${JSON.stringify(candidates)}`, + ) + } +} + +export class ModuleAliasPackageError extends Error { + readonly name = 'ModuleAliasPackageError' + constructor( + readonly importer: string, + readonly request: string, + ) { + super( + `${importer}:1:1: Module alias package ${request} has no resolving export under the active conditions`, + ) + } +} + +export class ModuleAliasConfigurationError extends Error { + readonly name = 'ModuleAliasConfigurationError' + constructor( + readonly importer: string, + readonly key: string, + ) { + super( + `${importer}:1:1: Module alias ${key} cannot use wildcard names in the shared resolver`, + ) + } +} + +export interface AliasResolution { + readonly ignored?: never + readonly path: string + readonly request: string +} + +/** Resolve every rewriting candidate completely before selecting its request. */ +export function resolveModuleAlias( + specifier: string, + context: { + readonly alias: ModuleAliasOptions + readonly importer: string + readonly resolveRequest: ( + request: string, + aliased: boolean, + ) => string | false | undefined + }, +): AliasResolution | IgnoredModule | false | undefined { + const descriptors: readonly (ModuleAliasDescriptor & { + readonly key: string + })[] = + context.alias === false + ? [] + : Array.isArray(context.alias) + ? context.alias.map((entry) => ({ ...entry, key: entry.name })) + : Object.entries(context.alias).map(([key, alias]) => ({ + key, + name: key.endsWith('$') ? key.slice(0, -1) : key, + onlyModule: key.endsWith('$'), + alias, + })) + let failure: + { readonly key: string; readonly candidates: readonly string[] } | undefined + function visit( + request: string, + visited: ReadonlySet, + aliased: boolean, + ): AliasResolution | IgnoredModule | false | undefined { + if (visited.has(request)) + throw new ModuleAliasError(context.importer, specifier) + const next = new Set(visited).add(request) + for (const { key, name, alias: value, onlyModule: exact } of descriptors) { + if (name.includes('*')) + throw new ModuleAliasConfigurationError(context.importer, key) + if (request !== name && (exact || !request.startsWith(`${name}/`))) + continue + const targets = typeof value === 'object' ? value : [value] + const candidates = targets + .filter( + (target) => + target === false || + (request !== target && !request.startsWith(`${target}/`)), + ) + .map((target) => + target === false ? false : target + request.slice(name.length), + ) + if (!candidates.length) continue + failure ??= { + key, + candidates: candidates.filter((candidate) => candidate !== false), + } + let missing = false + for (const candidate of candidates) { + if (candidate === false) return { ignored: true } + const resolved = visit(candidate, next, true) + if (resolved) return resolved + if (resolved === undefined) missing = true + } + return missing ? undefined : false + } + const path = context.resolveRequest(request, aliased) + return typeof path === 'string' ? { path, request } : path + } + const resolved = visit(specifier, new Set(), false) + if (resolved === undefined && failure) + throw new ModuleAliasCandidatesError( + context.importer, + failure.key, + failure.candidates, + ) + return resolved +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 000000000..28384cbd0 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' +import type { MdxSelection } from './source-selection' + +export interface CollectNumberedFilesOptions { + /** Directories holding the project's own source files */ + roots: string[] + /** Packages whose source the build extracts too (the `include` option) */ + include?: string[] + /** Number MDX only when the caller extracts compiled MDX. Defaults to false. */ + includeMdx?: MdxSelection + readonly exclude?: readonly string[] + cwd?: string + /** + * Only files whose text contains one of these are numbered, so files the + * build never extracts do not take numbers (and lengthen class prefixes) + */ + needles?: string[] + /** The name the plugin extracts a file under, given its absolute path */ + toId?: (path: string) => string +} + +/** Whether the text of the file contains one of the needles */ +function usesAny(file: string, needles: string[]): boolean { + const text = readFileSync(file, 'utf-8') + return needles.some((needle) => text.includes(needle)) +} + +/** Where an included package lives, found the way the bundler finds it. */ +function findPackageDir(cwd: string, name: string): string | undefined { + let dir = resolve(cwd) + for (;;) { + const candidate = join(dir, 'node_modules', name) + if (existsSync(candidate)) return realpathSync(candidate) + const parent = resolve(dir, '..') + if (parent === dir) return undefined + dir = parent + } +} + +/** + * Every file the build can extract, as the names the plugin extracts them + * under: the project's source, then the source of each included package. The + * list is the same on every run for the same files, and the engine numbers it + * in path order, so file numbers (and the class prefixes they make) do not + * depend on which file a bundler worker reaches first. + */ +export function collectNumberedFiles({ + roots, + include = [], + includeMdx = false, + exclude, + cwd = process.cwd(), + needles, + toId = (path) => path.replaceAll('\\', '/'), +}: CollectNumberedFilesOptions): string[] { + const directories = [ + ...roots, + ...include + .map((name) => findPackageDir(cwd, name)) + .filter((dir): dir is string => dir !== undefined), + ] + return [ + ...new Set( + directories.flatMap((dir) => + listSourceFiles(dir, exclude, { includeMdx }) + .filter((file) => !needles || usesAny(file, needles)) + .map(toId), + ), + ), + ].sort() +} + +export interface FileNumbering { + seedFileMap(files: string[]): void +} + +/** + * Number `files` in path order. Files numbered before keep their numbers, so + * running it again as files appear (in development) numbers only the new ones, + * after the existing ones. + */ +export function seedFileNumbers( + engine: FileNumbering, + files: Parameters[0], +): void { + if (files.length > 0) engine.seedFileMap(files) +} + +/** What a file mentions when the build extracts it: the package, StyleX, and the packages Devup UI takes the place of */ +export function extractedNeedles( + libPackage: string, + importAliases: object, +): string[] { + return [libPackage, '@stylexjs/stylex', ...Object.keys(importAliases)] +} diff --git a/packages/plugin-utils/src/packaged-config-inputs.ts b/packages/plugin-utils/src/packaged-config-inputs.ts new file mode 100644 index 000000000..2e66cd0cf --- /dev/null +++ b/packages/plugin-utils/src/packaged-config-inputs.ts @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute, join, relative } from 'node:path' + +import type { ResolutionInputCollector } from './resolution-inputs' +import { resolutionWatchPath } from './resolution-watch-path' + +interface PackagedConfigLookup { + readonly request: string + readonly importer: string + readonly resolved: string | undefined +} + +export function recordPackagedConfigInputs( + lookup: PackagedConfigLookup, + inputs?: ResolutionInputCollector, +): void { + if (!inputs) return + const { request, importer, resolved } = lookup + const require = createRequire(importer) + const name = request + .split('/') + .slice(0, request.startsWith('@') ? 2 : 1) + .join('/') + for (const directory of require.resolve.paths(request) ?? []) { + const candidate = join(directory, name) + const exists = existsSync(candidate) + inputs.probe(candidate, exists) + if (exists && resolved) { + const canonical = resolutionWatchPath(candidate) + const subpath = relative(canonical, resolved) + if (!subpath.startsWith('..') && !isAbsolute(subpath)) break + } + } +} diff --git a/packages/plugin-utils/src/prepared-diagnostics.ts b/packages/plugin-utils/src/prepared-diagnostics.ts new file mode 100644 index 000000000..1a0929b2a --- /dev/null +++ b/packages/plugin-utils/src/prepared-diagnostics.ts @@ -0,0 +1,26 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function preparedDiagnostics( + filename: string, + source: string, + errors: readonly unknown[], +): string { + return errors + .flatMap((error) => { + if (!isRecord(error)) return `${filename}:1:1: ${String(error)}` + const labels = Array.isArray(error.labels) + ? error.labels.filter(isRecord) + : [] + if (labels.length) { + return labels.map((label) => { + const offset = typeof label.start === 'number' ? label.start : 0 + const lines = source.slice(0, offset).split('\n') + return `${filename}:${lines.length}:${(lines.at(-1)?.length ?? 0) + 1}: ${String(error.message)}${typeof label.message === 'string' ? `: ${label.message}` : ''}` + }) + } + return `${filename}:${typeof error.line === 'number' ? error.line : 1}:${typeof error.column === 'number' ? error.column : 1}: ${String(error.message)}` + }) + .join('\n') +} diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts new file mode 100644 index 000000000..237a04db8 --- /dev/null +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -0,0 +1,157 @@ +import { extname } from 'node:path' + +import type { + ModuleResolution, + PreparedSource, + PrepareSource, + ResolvedModule, +} from './import-graph' +import { remapMdxError } from './mdx-errors' +import { readPreparedSource } from './prepared-source' +import { + createResolutionInputs, + readResolutionFile, + type ResolutionInputObserver, +} from './resolution-inputs' +import type { MdxSelection } from './source-selection' + +export interface ModuleResolver { + (specifier: string, importer: string): ModuleResolution | undefined + remapError(error: unknown): Error +} + +class ModulePreparationError extends Error { + constructor(importer: string, filename: string, cause: unknown) { + super( + `${importer}:1:1: module source preparation cannot use \`${filename}\` at build time: ${cause instanceof Error ? cause.message : String(cause)}; prepare Markdown before extraction using a synchronous cache lookup, or move the imported value into JS/TS`, + { cause }, + ) + this.name = 'ModulePreparationError' + } +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) +} + +/** The mutable store belongs to one resolver, not to the WASM singleton. */ +export function createPreparedResolver(options: { + readonly prepareSource?: PrepareSource + readonly includeMdx?: MdxSelection + readonly onResolutionInputs?: ResolutionInputObserver +}) { + const generations = new Map< + string, + { + readonly filename: string + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + >() + const idsByFilename = new Map() + const remappedErrors = new WeakSet() + const markdownExtensions = new Set([ + '.md', + '.mdx', + ...(typeof options.includeMdx === 'object' + ? options.includeMdx.map((extension) => extension.toLowerCase()) + : []), + ]) + return { + read(filename: string, importer: string): PreparedSource { + const previousId = idsByFilename.get(filename) + if (previousId !== undefined) generations.delete(previousId) + idsByFilename.delete(filename) + try { + const prepared = options.prepareSource?.(filename) + if (isThenable(prepared)) + throw new TypeError( + 'Source is not ready: the synchronous WASM resolver received a thenable', + ) + if ( + prepared === undefined && + markdownExtensions.has(extname(filename).toLowerCase()) + ) + throw new TypeError('Markdown source has no prepared JavaScript') + const source = readPreparedSource(prepared) + if (source !== undefined) { + const inputs = createResolutionInputs() + inputs.file(filename) + options.onResolutionInputs?.(inputs.snapshot()) + } + return source + } catch (cause) { + throw new ModulePreparationError(importer, filename, cause) + } + }, + resolved( + id: string, + filename: string, + prepared: PreparedSource, + ): ResolvedModule { + if (prepared === undefined) { + generations.delete(id) + const inputs = createResolutionInputs() + try { + return { path: id, code: readResolutionFile(filename, inputs) } + } finally { + options.onResolutionInputs?.(inputs.snapshot()) + } + } + const source = + typeof prepared === 'string' ? { code: prepared } : prepared + generations.set(id, { filename, ...source }) + idsByFilename.set(filename, id) + return { + path: id, + code: source.code, + ...(source.sourceType === undefined + ? {} + : { sourceType: source.sourceType }), + } + }, + remapError(error: unknown): Error { + if (error instanceof Error && remappedErrors.has(error)) return error + const message = error instanceof Error ? error.message : String(error) + const replacements = [...generations] + .flatMap(([id, source]) => { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + `(^|[\\s"'\x60])${escaped}:(\\d+):(\\d+)(?=:|\\s|$)`, + 'g', + ) + return [...message.matchAll(pattern)].map((match) => ({ + start: match.index + match[1].length, + end: match.index + match[0].length, + text: remapMdxError( + `${source.filename}:${match[2]}:${match[3]}`, + source.filename, + source.map, + ).message.replace('(in compiled MDX)', '(in compiled output)'), + })) + }) + .sort((a, b) => a.start - b.start) + const mapped = replacements.reduce( + (state, replacement) => ({ + text: + state.text + + message.slice(state.end, replacement.start) + + replacement.text, + end: replacement.end, + }), + { text: '', end: 0 }, + ) + const result = new Error(mapped.text + message.slice(mapped.end), { + cause: error, + }) + remappedErrors.add(result) + return result + }, + } +} diff --git a/packages/plugin-utils/src/prepared-source.ts b/packages/plugin-utils/src/prepared-source.ts new file mode 100644 index 000000000..d8f840dae --- /dev/null +++ b/packages/plugin-utils/src/prepared-source.ts @@ -0,0 +1,29 @@ +import type { PreparedSource } from './import-graph' + +export type SourceType = 'compiled-mdx' + +export class PreparedSourceTypeError extends TypeError { + constructor(cause: unknown) { + super( + `source type cannot use the prepared source at build time: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'PreparedSourceTypeError' + } +} + +export function readPreparedSource(prepared: PreparedSource): PreparedSource { + if (typeof prepared !== 'object' || prepared === null) return prepared + try { + const sourceType: unknown = prepared.sourceType + if (sourceType !== undefined && sourceType !== 'compiled-mdx') + throw new TypeError('expected `compiled-mdx`') + return { + code: prepared.code, + map: prepared.map, + ...(sourceType === undefined ? {} : { sourceType }), + } + } catch (cause) { + throw new PreparedSourceTypeError(cause) + } +} diff --git a/packages/plugin-utils/src/resolution-inputs.ts b/packages/plugin-utils/src/resolution-inputs.ts new file mode 100644 index 000000000..b7e283a68 --- /dev/null +++ b/packages/plugin-utils/src/resolution-inputs.ts @@ -0,0 +1,72 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +export interface ResolutionInputs { + readonly fileDependencies: readonly string[] + readonly missingDependencies: readonly string[] +} + +export type ResolutionInputObserver = (inputs: ResolutionInputs) => void + +export function createResolutionInputs(initial?: ResolutionInputs) { + const files = new Set(initial?.fileDependencies) + const missing = new Set(initial?.missingDependencies) + return { + file(path: string) { + files.add(resolve(path)) + }, + missing(path: string) { + missing.add(resolve(path)) + }, + probe(path: string, exists: boolean) { + if (!exists) missing.add(resolve(path)) + }, + snapshot(): ResolutionInputs { + return Object.freeze({ + fileDependencies: Object.freeze([...files].sort()), + missingDependencies: Object.freeze([...missing].sort()), + }) + }, + } +} + +export type ResolutionInputCollector = ReturnType + +export function recordResolutionFailure( + path: string, + error: unknown, + inputs?: ResolutionInputCollector, +): void { + const code: unknown = + error instanceof Error + ? Object.getOwnPropertyDescriptor(error, 'code')?.value + : undefined + if (code === 'ENOENT' || code === 'ENOTDIR') inputs?.missing(path) + else inputs?.file(path) +} + +export function realpathResolution( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + return realpathSync(path) + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} + +export function readResolutionFile( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + const source = readFileSync(path, 'utf-8') + inputs?.file(path) + return source + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} diff --git a/packages/plugin-utils/src/resolution-watch-path.ts b/packages/plugin-utils/src/resolution-watch-path.ts new file mode 100644 index 000000000..221bf9929 --- /dev/null +++ b/packages/plugin-utils/src/resolution-watch-path.ts @@ -0,0 +1,24 @@ +import { realpathSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' + +function canonicalWatchPath(path: string, parent: string): string { + try { + return join(realpathSync(parent), relative(parent, path)) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + ) + return path + const next = dirname(parent) + return next === parent ? path : canonicalWatchPath(path, next) + } +} + +export function resolutionWatchPath( + path: string, + preserveSymlinks = false, +): string { + return preserveSymlinks ? path : canonicalWatchPath(path, path) +} diff --git a/packages/plugin-utils/src/shared.ts b/packages/plugin-utils/src/shared.ts index 9d5710be4..bd6f620c5 100644 --- a/packages/plugin-utils/src/shared.ts +++ b/packages/plugin-utils/src/shared.ts @@ -1,5 +1,56 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + import type { CustomShorthands, ImportAliases } from './types' +export const SOURCE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', +] as const +export const SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx)$/i +export const MDX_FILE_RE = /\.mdx$/i +export const POST_COMPILED_MDX_RE = /\.mdx\.(?:[mc]?[jt]s|[jt]sx)$/i +export const GRAPH_SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx|mdx)$/i + +export function resolveProjectPaths( + root: string, + options: { + readonly devupFile?: string + readonly distDir?: string + readonly cssDir?: string + } = {}, +): { devupFile: string; distDir: string; cssDir: string } { + const distDir = resolve(root, options.distDir ?? 'df') + return { + devupFile: resolve(root, options.devupFile ?? 'devup.json'), + distDir, + cssDir: options.cssDir + ? resolve(root, options.cssDir) + : resolve(distDir, 'devup-ui'), + } +} + +export function resolveSourceDirs( + root: string, + configured?: string | string[], +): string[] { + const dirs = + configured === undefined + ? ['src', 'app'] + : typeof configured === 'string' + ? [configured] + : configured + return [...new Set(dirs.map((dir) => resolve(root, dir)))].filter( + (dir) => configured !== undefined || existsSync(dir), + ) +} + /** * Extract file number from a devup-ui CSS filename. * @@ -51,12 +102,19 @@ export function getFileNumByFilename(filename: string): number | null { * @returns A RegExp for use in bundler exclude/condition rules */ export function createNodeModulesExcludeRegex( - include: string[], + include: readonly string[], extraExcludes?: string, ): RegExp { - const base = `node_modules(?!.*(${['@devup-ui', '@devup-editor', ...include] + const separator = '[\\/\\\\]' + const names = include.map((name) => + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('/', separator), + ) + const allowed = ['@devup-ui', '@devup-editor'] + .map((scope) => `${scope}${separator}[^\\/\\\\]+`) + .concat(names) .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))` + // Only the innermost node_modules owns the package (including pnpm stores). + const base = `(?:^|${separator})node_modules${separator}(?!.*${separator}node_modules${separator})(?!(?:${allowed})(?:${separator}|$))` return new RegExp(extraExcludes ? `(${base})|(${extraExcludes})` : base) } diff --git a/packages/plugin-utils/src/source-selection.ts b/packages/plugin-utils/src/source-selection.ts new file mode 100644 index 000000000..1466006a6 --- /dev/null +++ b/packages/plugin-utils/src/source-selection.ts @@ -0,0 +1,27 @@ +import { extname } from 'node:path' + +import { SOURCE_EXTENSIONS, SOURCE_FILE_RE } from './shared' + +export type MdxSelection = boolean | readonly string[] +export interface SourceSelectionOptions { + readonly includeMdx?: MdxSelection +} + +export function sourceExtensions(selection?: MdxSelection): readonly string[] { + return [ + ...SOURCE_EXTENSIONS, + ...(selection === true ? ['.mdx'] : selection || []).map((extension) => + extension.toLowerCase(), + ), + ] +} + +export function isSelectedSource( + filename: string, + selection?: MdxSelection, +): boolean { + return ( + SOURCE_FILE_RE.test(filename) || + sourceExtensions(selection).includes(extname(filename).toLowerCase()) + ) +} diff --git a/packages/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 000000000..45a21ad49 --- /dev/null +++ b/packages/plugin-utils/src/state-writer.ts @@ -0,0 +1,70 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +let temporaryFiles = 0 + +/** Write `content` so a reader sees the old file or the new one, never half. */ +export async function writeFileAtomically( + path: string, + content: string, +): Promise { + const temporary = `${path}.${process.pid}.${++temporaryFiles}.tmp` + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile(temporary, content, 'utf-8') + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export type WriteOne = ( + path: string, + content: string, + encoding?: 'utf-8', +) => Promise + +export interface StateWriter { + /** + * Write `content` to `path` after every earlier write to it. A write that a + * newer one for the same path overtakes before it starts is dropped, so the + * file never ends up holding an older snapshot than one already handed in. + */ + write(path: string, content: string, encoding?: 'utf-8'): Promise +} + +/** + * Persist engine snapshots from concurrent transforms. Each call hands in the + * state as it was when the call was made, and the last call made decides what + * the file holds, whichever write the operating system finishes last. + */ +export function createStateWriter( + writeOne: WriteOne = (path, content) => writeFileAtomically(path, content), +): StateWriter { + const revisions = new Map() + const tails = new Map>() + return { + write(path, content, encoding) { + const revision = (revisions.get(path) ?? 0) + 1 + revisions.set(path, revision) + const start = async () => { + if (revisions.get(path) !== revision) return + await (encoding + ? writeOne(path, content, encoding) + : writeOne(path, content)) + } + const previous = tails.get(path) + // With nothing in flight the write starts at once + const run = previous + ? previous.catch(() => undefined).then(start) + : start() + tails.set(path, run) + const done = () => { + if (tails.get(path) === run) tails.delete(path) + } + run.then(done, done) + return run + }, + } +} diff --git a/packages/plugin-utils/src/tsconfig.ts b/packages/plugin-utils/src/tsconfig.ts new file mode 100644 index 000000000..9ccf0011d --- /dev/null +++ b/packages/plugin-utils/src/tsconfig.ts @@ -0,0 +1,241 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { ConfigLoadError } from './load-config' +import { recordPackagedConfigInputs } from './packaged-config-inputs' +import { + readResolutionFile, + realpathResolution, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' + +export interface PathAlias { + readonly prefix: string + readonly suffix: string + readonly exact: boolean + readonly targets: string[] +} + +interface AliasConfig { + readonly baseUrl?: string + readonly paths?: Record + readonly pathsOrigin?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseJsonc(source: string): unknown { + let text = '' + let quoted = false + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + const next = source[index + 1] + if (quoted) { + text += char + if (char === '\\') text += source[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + text += char + } else if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1 + text += '\n' + } else if (char === '/' && next === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new SyntaxError('Unterminated JSON comment') + text += ' ' + index = end + 1 + } else text += char + } + let json = '' + quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + json += char + if (char === '\\') json += text[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + json += char + } else if (char !== ',' || !/^\s*[}\]]/.test(text.slice(index + 1))) + json += char + } + return JSON.parse(json) +} + +function resolveParent( + specifier: string, + file: string, + inputs?: ResolutionInputCollector, +): string { + if (specifier.startsWith('.') || isAbsolute(specifier)) { + const candidate = resolve(dirname(file), specifier) + const exists = existsSync(candidate) + inputs?.probe(candidate, exists) + return exists ? candidate : `${candidate}.json` + } + const require = createRequire(file) + let selected: string | undefined + try { + try { + const resolved = require.resolve(specifier) + selected = resolved + if (resolved.endsWith('.json')) return resolved + const manifestPath = require.resolve(`${specifier}/package.json`) + const manifest: unknown = JSON.parse( + readResolutionFile(manifestPath, inputs), + ) + return join( + dirname(manifestPath), + isRecord(manifest) && typeof manifest.tsconfig === 'string' + ? manifest.tsconfig + : 'tsconfig.json', + ) + } catch (cause) { + if ( + !(cause instanceof Error) || + !('code' in cause) || + cause.code !== 'MODULE_NOT_FOUND' + ) + throw cause + try { + const resolved = require.resolve(`${specifier}/tsconfig.json`) + selected = resolved + return resolved + } catch (fallback) { + if ( + !(fallback instanceof Error) || + !('code' in fallback) || + fallback.code !== 'MODULE_NOT_FOUND' + ) + throw fallback + const manifestPath = require.resolve(`${specifier}/package.json`) + selected = manifestPath + const manifest: unknown = JSON.parse( + readResolutionFile(manifestPath, inputs), + ) + const configName = + isRecord(manifest) && typeof manifest.tsconfig === 'string' + ? manifest.tsconfig + : 'tsconfig.json' + return join(dirname(manifestPath), configName) + } + } + } finally { + recordPackagedConfigInputs( + { request: specifier, importer: file, resolved: selected }, + inputs, + ) + } +} + +function loadAliases( + file: string, + stack: readonly string[], + inputs?: ResolutionInputCollector, +): AliasConfig { + try { + const canonical = realpathResolution(file, inputs) + if (stack.includes(canonical) || stack.length >= 128) + throw new Error( + `Tsconfig inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ) + const config = parseJsonc(readResolutionFile(file, inputs)) + if (!isRecord(config)) throw new TypeError('Expected a tsconfig object') + const parents = + config.extends === undefined + ? [] + : typeof config.extends === 'string' + ? [config.extends] + : config.extends + if ( + !Array.isArray(parents) || + !parents.every((parent) => typeof parent === 'string') + ) + throw new TypeError('Expected extends to be a path or array of paths') + let inherited: AliasConfig = {} + for (const parent of parents) + inherited = { + ...inherited, + ...loadAliases( + resolveParent(parent, file, inputs), + [...stack, canonical], + inputs, + ), + } + if (config.compilerOptions === undefined) return inherited + if (!isRecord(config.compilerOptions)) + throw new TypeError('Expected compilerOptions to be an object') + const options = config.compilerOptions + const current: { + baseUrl?: string + paths?: Record + pathsOrigin?: string + } = {} + if (options.baseUrl !== undefined) { + if (typeof options.baseUrl !== 'string') + throw new TypeError('Expected baseUrl to be a path') + current.baseUrl = resolve(dirname(file), options.baseUrl) + } + if (options.paths !== undefined) { + if (!isRecord(options.paths)) + throw new TypeError('Expected paths to be an object') + current.paths = {} + current.pathsOrigin = dirname(file) + for (const [key, targets] of Object.entries(options.paths)) { + if ( + !Array.isArray(targets) || + !targets.every( + (target): target is string => typeof target === 'string', + ) + ) + throw new TypeError(`Expected string targets for paths.${key}`) + current.paths[key] = targets + } + } + return { ...inherited, ...current } + } catch (cause) { + if (cause instanceof ConfigLoadError) throw cause + recordResolutionFailure(file, cause, inputs) + throw new ConfigLoadError(file, cause) + } +} + +export function readPathAliases( + tsconfigPath?: string, + inputs?: ResolutionInputCollector, +): { + aliases: PathAlias[] + baseDir: string + baseUrl?: string +} { + const exists = tsconfigPath ? existsSync(tsconfigPath) : false + if (tsconfigPath) inputs?.probe(tsconfigPath, exists) + if (!tsconfigPath || !exists) return { aliases: [], baseDir: process.cwd() } + const file = resolve(tsconfigPath) + const config = loadAliases(file, [], inputs) + const baseDir = config.baseUrl ?? config.pathsOrigin ?? dirname(file) + const aliases = Object.entries(config.paths ?? {}).map(([alias, targets]) => { + const star = alias.indexOf('*') + return { + prefix: star === -1 ? alias : alias.slice(0, star), + suffix: star === -1 ? '' : alias.slice(star + 1), + exact: star === -1, + targets: targets.map((target) => resolve(baseDir, target)), + } + }) + aliases.sort( + (a, b) => + Number(b.exact) - Number(a.exact) || b.prefix.length - a.prefix.length, + ) + return { + aliases, + baseDir: config.baseUrl ?? dirname(file), + baseUrl: config.baseUrl, + } +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..5dc4cd65f 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -63,6 +63,21 @@ export interface DevupTheme { /** Custom style prop aliases mapped to one or more CSS property names. */ export type CustomShorthands = Record +/** Ordered alias candidates; a reached false explicitly ignores the module. */ +export type ModuleAliases = Readonly< + Record +> + +/** Native ordered entries preserve duplicate names and literal dollar suffixes. */ +export interface ModuleAliasDescriptor { + readonly name: string + readonly alias: string | false | readonly (string | false)[] + readonly onlyModule?: boolean +} + +export type ModuleAliasOptions = + ModuleAliases | readonly ModuleAliasDescriptor[] | false + /** * Devup configuration file structure (devup.json) */ diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 404fc2997..9c1f7d4a4 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -177,6 +177,84 @@ const box = const box = ``` +## Publishing And Consuming Libraries + +With the default `extractCss: true`, a library build extracts its styles. Publish +the transformed JavaScript together with its emitted CSS and expose a stylesheet +that consumers import. The published code no longer calls compile-time Devup UI +placeholders. + +Use `extractCss: false` only for an intentionally uncompiled library. This is the +contract demonstrated by `apps/vite-lib`: **every** consumer needs a Devup UI build +plugin configured with the library's exact package name: + +```ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +The consuming build transforms the library and emits its CSS. Without that +configuration, the uncompiled placeholders throw `Cannot run on the runtime`; +the library must not be treated as standalone browser JavaScript. Both publishing +modes remain free of a styling runtime. + +## Project Roots And Extraction + +Relative `devupFile`, `distDir`, and `cssDir` options resolve against Rsbuild's +`root`, not the shell's working directory. The graph includes existing `src` +and `app` directories, normalized Rspack entries, and `sourceDirs` (a directory +or array of directories). Included packages follow the same effective Rspack +ES-module export conditions as extraction. + +Extraction supports `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and +`.cjs`. Configure an MDX compiler for `.mdx`; Devup UI runs as a post-loader on +its compiled JavaScript, never on raw Markdown. Rsbuild's transform API does not +expose the incoming source map, so extraction errors label compiled MDX locations +with `(in compiled MDX)`. + +Malformed or cyclic configuration and graph/prewarm failures stop the build with +the root and underlying cause. Empty themes overwrite generated declarations. +Optional file-number seeding failures warn once that IDs may depend on module +arrival order. `atomHoist` composes its shared CSS cache group with existing +groups and creates `splitChunks` when absent; `splitChunks: false` is an error +when atom hoisting is requested. + +## MDX Selection And Compiled-Source Guards + +The graph and build-time resolver receive the same native Rspack `resolve.alias` +map-or-false option. Rspack accepts ordered mixed string/false candidates: +the first resolving file wins, and a reached false ignores the module. Empty +arrays fall through to the raw request; a real rewriting all-miss remains +terminal. Rspack's public alias type does not accept Webpack-style descriptor +arrays. Unsupported wildcard names fail with the importer/key instead of being +silently dropped. + +`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such +as `.mdown` are opt-in. Configure the project MDX loader for the same list. +Devup runs on its compiled JavaScript/JSX in a post-loader, retaining the actual +filename. Extraction, graph discovery and numbering share the selected list. + +With extraction enabled, build, dev-server compilations and watch rebuilds use +Rspack's public used-dependency export IDs and actual module targets. A +definitely named compile-time export used by a module outside the supported +JS/TS or selected MDX extensions produces a located module/import error and an +`mdxExtensions` remedy. Static namespace/require member uses remain guarded when +the dependency names the export. Runtime helpers and unused imports are allowed. +For an intended runtime compatibility alias, set `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Rspack additionally exposes some namespace/require destructuring uses +with `ids = []`, and the installed Rspack may omit export IDs for CommonJS +require members, bound results and destructuring altogether. Some renamed +forwarding cannot be traced through its public export data. Those forms are +also opaque and allowed, even though another +bundler with definite export IDs can reject the same source. No provided-export +query is made at `finishModules`. Hidden compile-time exports can still throw +`Cannot run on the runtime` when rendered; add the extension to `mdxExtensions` +and let Devup compile it. The guard does not expand ordinary JS/TS `include` +selection or change `extractCss: false` publishing. + ## Custom Shorthands ```ts diff --git a/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 000000000..8ff7c952a --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,66 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { mdxLanding } from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +it.each(['false', 'later-false', 'first-success', 'empty'])( + 'passes the actual Rspack alias map when %s', + async (kind) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + file('node_modules/provider/package.json', '{"main":"index.js"}') + file('node_modules/provider/index.js', 'export const value="blue"') + const target: string | false | (string | false)[] = + kind === 'false' + ? false + : kind === 'later-false' + ? [join(root, 'missing'), false] + : kind === 'empty' + ? [] + : [blue, false] + wasm.resetBuildState() + try { + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: 'development', + plugins: [DevupUI({ singleCss: true, include: ['provider'] })], + source: { entry: { main: entry } }, + resolve: { + alias: { + provider: target, + '@devup-ui/react': mdxLanding.resolve('@devup-ui/react'), + }, + }, + output: { + target: 'node', + distPath: { root: join(root, 'out') }, + minify: false, + }, + }, + }) + const result = await builder.build() + try { + expect(wasm.getCss(null, false)).toContain( + kind === 'false' || kind === 'later-false' + ? 'color:red' + : 'color:blue', + ) + } finally { + await result.close() + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2a..9f89b1ce0 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -29,6 +29,7 @@ const source = `import { Box } from '@devup-ui/react' const App = () => ` let codeExtractSpy: ReturnType +const closeCallbacks: (() => void)[] = [] beforeAll(() => { spyOn(fs, 'existsSync').mockReturnValue(true) @@ -52,6 +53,7 @@ beforeAll(() => { }) afterAll(() => { + for (const close of closeCallbacks) close() mock.restore() }) @@ -67,8 +69,12 @@ async function extractedCssDirIn(checkout: string) { cssDir: join(checkout, 'df', 'devup-ui'), }) await plugin.setup({ + context: { rootPath: checkout }, + onCloseBuild: mock((close) => closeCallbacks.push(close)), transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts new file mode 100644 index 000000000..11f82c2b4 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts @@ -0,0 +1,292 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { DevupUI } from '../plugin' + +const base = process.env.DEVUP_PLUGIN_TEST_TMP ?? realpathSync(tmpdir()) +const roots: string[] = [] +const closes: (() => void)[] = [] +afterEach(() => { + for (const close of closes.splice(0)) close() + mock.restore() + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) + wasm.resetBuildState() + wasm.setDebug(true) +}) + +function project() { + mkdirSync(base, { recursive: true }) + const root = realpathSync(mkdtempSync(join(base, 'rsbuild-'))) + roots.push(root) + return root +} +function file(root: string, name: string, contents: string) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path +} +const box = (color: string) => + `import { Box } from '@devup-ui/react'; export default ` + +async function setup( + root: string, + options: Parameters[0] = {}, +) { + const transform = mock() + const modify = mock() + const configure = mock() + const before = mock() + await DevupUI(options).setup({ + context: { rootPath: root }, + transform, + modifyRspackConfig: modify, + modifyRsbuildConfig: configure, + onBeforeBuild: before, + onCloseBuild: mock((close) => closes.push(close)), + }) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + const apply = (normalized: object) => { + modify.mock.calls[0][0](config, { + environment: { name: 'web', config: { output: { target: 'web' } } }, + }) + config.plugins?.at(-1)?.apply({ + options: normalized, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + } + return { transform, configure, before, apply } +} + +it('writes only beneath the Rsbuild root and clears declarations when the theme is removed', async () => { + const root = project() + file( + root, + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: 'red' } } } }), + ) + await setup(root, { extractCss: false, distDir: 'generated' }) + expect(readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8')).toContain( + 'primary', + ) + file(root, 'devup.json', '{}') + await setup(root, { extractCss: false, distDir: 'generated' }) + expect( + readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8'), + ).not.toContain('primary') + expect( + readFileSync(join(root, 'generated/devup-ui/devup-ui.css'), 'utf-8'), + ).not.toContain('--primary') +}) + +it.each(['{', JSON.stringify({ extends: ['./base.json'] })])( + 'fails initial setup for malformed or cyclic real config: %s', + async (contents) => { + const root = project() + file(root, 'devup.json', contents) + file(root, 'base.json', JSON.stringify({ extends: ['./devup.json'] })) + await expect(setup(root)).rejects.toThrow(join(root, 'devup.json')) + }, +) + +it('shares one conditional graph across canonical mapping, reach and reachable prewarm', async () => { + const root = project() + file( + root, + 'src/first.tsx', + `import Page from '../app/second'; import Widget from '../custom/widget'; import value from 'fixture'; export default [Page, Widget, value]`, + ) + file(root, 'app/second.tsx', box('tan')) + file(root, 'custom/widget.tsx', box('blue')) + file(root, 'custom/dead.tsx', 'this is invalid and unreachable') + file( + root, + 'node_modules/fixture/package.json', + JSON.stringify({ + name: 'fixture', + exports: { + browser: './browser.mjs', + node: './node.cjs', + default: './fallback.js', + }, + }), + ) + const browser = file( + root, + 'node_modules/fixture/browser.mjs', + `import { css } from '@devup-ui/react'; export default css({ color: 'green' })`, + ) + file(root, 'node_modules/fixture/node.cjs', `throw new Error('wrong branch')`) + const graph = spyOn(utils, 'buildStaticImportGraph') + const canonical = spyOn(utils, 'buildCanonicalMap') + const reach = spyOn(utils, 'computeFileReach') + const reachable = spyOn(utils, 'computeReachableFiles') + const extract = spyOn(wasm, 'codeExtract') + const api = await setup(root, { + atomHoist: 2, + sourceDirs: 'custom', + include: ['fixture'], + }) + api.apply({ + entry: { + first: { import: ['./src/first.tsx'] }, + second: { import: ['./app/second.tsx'] }, + }, + resolve: { + conditionNames: ['browser'], + byDependency: { esm: { conditionNames: ['import', '...'] } }, + }, + }) + const built = graph.mock.results[0]?.value + expect(graph).toHaveBeenCalledTimes(1) + expect(canonical.mock.calls[0]?.[0].graph).toBe(built) + expect(reach.mock.calls[0]?.[0].graph).toBe(built) + expect(reachable.mock.calls[0]?.[0].graph).toBe(built) + expect(extract.mock.calls.map((call) => call[0])).toContain( + browser.replaceAll('\\', '/'), + ) + expect(extract.mock.calls.map((call) => call[0])).not.toContain( + join(root, 'custom/dead.tsx').replaceAll('\\', '/'), + ) + expect(wasm.getCss(null, false)).toContain('green') +}) + +it.each(['mts', 'cts', 'cjs', 'mjs'])( + 'transforms real modern .%s files', + async (extension) => { + const root = project() + const path = file( + root, + `src/style.${extension}`, + `import { css } from '@devup-ui/react'; export const style = css({ color: 'red' })`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + const result = await api.transform.mock.calls[1][1]({ + code: readFileSync(path, 'utf-8'), + resourcePath: path, + addDependency: mock(), + addMissingDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain('css(') + const sheet = result.code.match(/devup-ui(?:-\d+)?\.css/)?.[0] + expect( + wasm.getCss(utils.getFileNumByFilename(sheet ?? ''), true), + ).toContain('red') + }, +) + +it('extracts compiled MDX in the post-loader without prewarming raw markdown', async () => { + const root = project() + const path = file( + root, + 'app/page.mdx', + `# Markdown\n\nimport { Box } from '@devup-ui/react'\n\n`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + expect(wasm.getCss(null, false)).not.toContain('red') + expect(api.transform.mock.calls[2][0]).toMatchObject({ order: 'post' }) + const result = await api.transform.mock.calls[2][1]({ + code: box('red'), + resourcePath: path, + addDependency: mock(), + addMissingDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain(' { + const root = project() + const api = await setup(root) + spyOn(wasm, 'codeExtract').mockImplementation(() => { + throw new Error(`${join(root, 'page.mdx')}:3:4: broken`) + }) + await expect( + api.transform.mock.calls[2][1]({ + code: '', + resourcePath: join(root, 'page.mdx'), + addDependency: mock(), + }), + ).rejects.toThrow('in compiled MDX') + await expect( + api.transform.mock.calls[1][1]({ + code: '', + resourcePath: join(root, 'page.tsx'), + addDependency: mock(), + }), + ).rejects.toThrow('broken') +}) + +it('fails prewarm with its real source filename and root', async () => { + const root = project() + const path = file( + root, + 'src/style.ts', + `import { css } from '@devup-ui/react'; export const style = css({ color: Math.random() })`, + ) + const api = await setup(root) + expect(() => api.apply({ entry: { main: { import: [path] } } })).toThrow( + `prewarm failed at ${path}`, + ) +}) + +it('warns once for optional seed failure while preserving requested graph guarantees', async () => { + const root = project() + const api = await setup(root) + spyOn(utils, 'collectNumberedFiles').mockImplementation(() => { + throw new Error('seed failed') + }) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + api.apply({ entry: {} }) + api.apply({ entry: {} }) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ phase: 'seed', root }) +}) + +it('fails rather than guessing when normalized entries are dynamic', async () => { + const root = project() + const api = await setup(root) + expect(() => api.apply({ entry: () => ({}) })).toThrow( + 'dynamic Rspack entries', + ) +}) + +it('preserves user cache groups when shared splitting is composed', async () => { + const root = project() + const api = await setup(root, { atomHoist: 2 }) + const config: { tools?: { rspack?: (config: object) => void } } = {} + api.configure.mock.calls[0][0](config) + const vendor = { name: 'vendor' } + const rspack = { optimization: { splitChunks: { cacheGroups: { vendor } } } } + config.tools?.rspack?.(rspack) + expect(rspack.optimization.splitChunks.cacheGroups).toMatchObject({ + vendor, + devupUiShared: { enforce: true }, + }) +}) diff --git a/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..fec4f0d70 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,123 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, +} from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const cjsIds: unknown[] = [] + try { + const builder = await createRsbuild({ + cwd: fixture.root, + rsbuildConfig: { + mode: 'development', + plugins: [ + DevupUI({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + source: { entry: { main: fixture.filename } }, + resolve: { alias: fixture.alias }, + output: { + target: 'node', + distPath: { root: join(fixture.root, 'out') }, + minify: false, + }, + tools: { + rspack: { + module: { rules: [fixture.rule] }, + plugins: [ + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'PublicCjsIds', + (compilation) => { + compilation.hooks.finishModules.tap( + 'PublicCjsIds', + (modules) => { + for (const module of modules) { + if (module.resource !== fixture.filename) continue + for (const dependency of module.dependencies) + if (dependency.type.startsWith('cjs')) + cjsIds.push(dependency.ids) + } + }, + ) + }, + ) + }, + }, + ], + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + }, + }, + }, + }) + const result = await builder.build() + try { + return { css: wasm.getCss(null, false), cjsIds } + } finally { + await result.close() + } + } finally { + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.css).toContain('red') + expect(custom.css).toBe(expected.css) +}) + +it.each(guardCases)( + 'uses Rspack definitely-used IDs to guard %s', + async (name, source, errors) => { + const action = bundle(source) + if (errors && name !== 'namespace destructure') + await expect(action).rejects.toThrow() + else expect((await action).css).not.toContain('{') + }, +) + +it.each(cjsGuardCases)( + 'allows require uses with opaque public Rspack IDs when %s', + async (name, source) => { + const result = await bundle(source) + + expect(result.cjsIds).toEqual( + name === 'require shadowed' || name === 'dynamic opaque' + ? [] + : [undefined], + ) + expect(result.css).not.toContain('{') + }, +) + +it('allows opaque destructuring while definite member IDs remain guarded', async () => { + // Rspack gives these destructuring uses the same empty export-ID list. + for (const name of ['css', 'getTheme']) { + const source = `import * as DU from "@devup-ui/react"\n\nexport const {${name}: value} = DU\n\n# Plain` + expect((await bundle(source)).css).not.toContain('{') + } + await expect( + bundle( + 'import * as DU from "@devup-ui/react"\n\nexport const value = DU.css({color:"red"})\n\n# Plain', + ), + ).rejects.toThrow('Rspack build failed') +}) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..170333b16 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -20,6 +20,10 @@ import { DevupUI } from '../plugin' type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] +const closeCallbacks: (() => void)[] = [] +afterEach(() => { + for (const close of closeCallbacks.splice(0)) close() +}) function createCodeExtractResult( overrides: Partial = {}, @@ -42,6 +46,17 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock((callback) => { + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + callback(config, { environment: { name: 'web' } }) + config.plugins?.[0]?.apply({ + options: {}, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + }), + onBeforeBuild: mock(), + onCloseBuild: mock((close) => closeCallbacks.push(close)), + context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -64,8 +79,12 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let realpathSpy: ReturnType beforeAll(() => { + realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((path) => + resolve(String(path)), + ) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) @@ -80,6 +99,7 @@ beforeAll(() => { }) afterAll(() => { + realpathSpy.mockRestore() existsSyncSpy.mockRestore() writeFileSyncSpy.mockRestore() mkdirSpy.mockRestore() @@ -151,7 +171,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -171,7 +191,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -189,11 +209,9 @@ describe('DevupUIRsbuildPlugin', () => { }) it('should error when write data files', async () => { - const originalConsoleError = console.error - console.error = mock() readFileSpy.mockRejectedValueOnce('error') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -201,14 +219,14 @@ describe('DevupUIRsbuildPlugin', () => { expect(plugin.setup).toBeDefined() const transform = mock() const modifyRsbuildConfig = mock() - await plugin.setup( - createSetupContext({ - transform, - modifyRsbuildConfig, - }), - ) - expect(console.error).toHaveBeenCalledWith('error') - console.error = originalConsoleError + await expect( + plugin.setup( + createSetupContext({ + transform, + modifyRsbuildConfig, + }), + ), + ).rejects.toThrow('error') }) it('should not register css transform', async () => { @@ -256,20 +274,22 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) + const getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('file css') expect( transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, + code: '/* placeholder */', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-1.css'), + environment: { name: 'web' }, }), - ).toBe('') + ).toBe('file css') + // A file's stylesheet imports the shared base + expect(getCssSpy).toHaveBeenCalledWith(1, true) + getCssSpy.mockRestore() }) it('should transform code', async () => { const plugin = DevupUI() @@ -286,16 +306,11 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') codeExtractSpy.mockReturnValue( createCodeExtractResult({ code: '
', @@ -344,7 +359,7 @@ const App = () => `, expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -367,13 +382,6 @@ const App = () => `, map: undefined, }) - if (options.updatedBaseStyle) { - expect(writeFileSpy).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), @@ -408,8 +416,8 @@ const App = () => `, getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -420,7 +428,7 @@ const App = () => `, const plugin = DevupUI({ singleCss: options.singleCss }) await plugin.setup(createSetupContext()) if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -431,7 +439,7 @@ const App = () => `, 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -485,6 +493,48 @@ const App = () => `, expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + describe('deterministic file numbering', () => { + it.each([ + ['relative', false], + ['posix', true], + ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const closeBuild = mock() + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId('C:\\p\\a.tsx')).toBe( + atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', + ) + return ['/p/a.tsx'] + }) + await DevupUI({ + include: ['@acme/ui'], + sourceDirs: ['src'], + atomHoist: atomMode ? 2 : undefined, + }).setup(createSetupContext({ onCloseBuild: closeBuild })) + expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + closeBuild.mock.calls[0][0]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + await DevupUI().setup(createSetupContext()) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) + + it('sets the prefix every time, even without one', async () => { + setPrefixSpy.mockClear() + await DevupUI().setup(createSetupContext()) + expect(setPrefixSpy).toHaveBeenCalledWith(null) + }) + }) describe('atomHoist pre-pass', () => { let buildCanonicalMapSpy: ReturnType let computeFileReachSpy: ReturnType @@ -578,14 +628,19 @@ const App = () => `, expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom planning when the graph fails', async () => { spies() buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await DevupUI({ atomHoist: 2 }).setup( - createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), - ) + await expect( + DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ + transform: mock(), + modifyRsbuildConfig: mock(), + }), + ), + ).rejects.toThrow('boom') expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -607,12 +662,14 @@ const App = () => `, const servedChunk = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, }) expect(servedChunk).toBe('CSS_FOR_3') expect(getCssSpy).toHaveBeenCalledWith(3, false) const servedBase = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + environment: { name: 'web' }, }) expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) @@ -675,7 +732,12 @@ const App = () => `, // splitChunks missing/false -> no cacheGroup added, no throw const rspackCfg2 = {} as { optimization?: { splitChunks?: unknown } } inject(rspackCfg2) - expect(rspackCfg2.optimization?.splitChunks).toBeUndefined() + expect(rspackCfg2.optimization?.splitChunks).toMatchObject({ + cacheGroups: { devupUiShared: { type: 'css/mini-extract' } }, + }) + expect(() => inject({ optimization: { splitChunks: false } })).toThrow( + 'splitChunks: false', + ) }) it('composes the cacheGroup with existing tools.rspack (function then array)', async () => { @@ -710,4 +772,179 @@ const App = () => `, expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) }) }) + + describe('stylesheets built too early', () => { + let getCssSpy: ReturnType + let readFileSyncSpy: ReturnType + let computeReachableFilesSpy: ReturnType + + afterEach(() => { + getCssSpy.mockRestore() + readFileSyncSpy.mockRestore() + computeReachableFilesSpy.mockRestore() + existsSyncSpy.mockReturnValue(false) + }) + + async function setup(options: Parameters[0] = {}) { + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('before') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('source') + computeReachableFilesSpy = spyOn( + pluginUtils, + 'computeReachableFiles', + ).mockReturnValue([resolve('src', 'App.tsx')]) + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const transform = mock() + const onBeforeBuild = mock() + const modifyRspackConfig = mock() + await DevupUI(options).setup( + createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), + ) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config, { + environment: { name: 'web' }, + }) + const taps: Record unknown> = {} + const tap = + (hook: string) => + (name: unknown, fn: (...args: unknown[]) => unknown) => { + taps[name === 'DevupUICompiledSourceGuard' ? `${hook}:guard` : hook] = + fn + } + const compiler = { + options: {}, + watchMode: false, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, + hooks: { + run: { tap: tap('run') }, + thisCompilation: { tap: tap('start') }, + }, + } + config.plugins![0]!.apply(compiler) + const compilation = { + assets: { 'index.js': {} }, + fileDependencies: new Set(), + missingDependencies: new Set(), + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const serve = (resourcePath: string) => + transform.mock.calls[0][1]({ + resourcePath, + environment: { name: 'web' }, + }) + return { onBeforeBuild, compiler, compilation, taps, serve } + } + + it('extracts the files the entries reach before building', async () => { + const { onBeforeBuild } = await setup({ atomHoist: undefined }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ + environments: { + web: { + entry: { + a: './src/a.tsx', + b: ['./src/b.tsx'], + c: { import: './src/c.tsx' }, + d: { import: ['./src/d.tsx'] }, + }, + }, + }, + }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [], + }), + ) + expect(codeExtractSpy).toHaveBeenCalledWith( + resolve('src', 'App.tsx'), + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + false, + false, + true, + expect.anything(), + ) + + // an extraction error is reported by the transform of that file + codeExtractSpy.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + onBeforeBuild.mock.calls[0][0]({ environments: {} }), + ).toThrow('prewarm failed') + }) + + it('extracts under posix names in atom mode', async () => { + const { onBeforeBuild } = await setup({ atomHoist: 2 }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ environments: {} }) + expect(codeExtractSpy.mock.calls[0]![0]).toBe( + resolve('src', 'App.tsx').replaceAll('\\', '/'), + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', async () => { + const { compiler, compilation, taps, serve } = await setup() + writeFileSyncSpy.mockClear() + taps.start!(compilation) + // the shared base is written first, as the CSS loaders read it from disk + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'before', + 'utf-8', + ) + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui-1.css'), + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + taps.run!() + expect(taps.needAdditionalPass!()).toBe(true) + + // the dev server rebuilds through the files the transforms write + compiler.watchMode = true + taps.start!(compilation) + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBe(false) + }) + + it('keeps the pass when every stylesheet is current', async () => { + const { compilation, taps, serve } = await setup() + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('before') + writeFileSyncSpy.mockClear() + taps.start!(compilation) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 000000000..bd2e93b33 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { DevupUI } from '../../dist/index.mjs' + +const require = createRequire(import.meta.url) +const { createRsbuild } = await import( + pathToFileURL(require.resolve('@rsbuild/core')).href +) +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const cold = process.argv[2] === 'cold' +const fixture = cold + ? undefined + : await realpath(await mkdtemp(join(tmpdir(), 'rsbuild-resolution-watch-'))) +const root = cold ? process.argv[3] : join(fixture, 'apps/app') +const palette = resolve(root, '../../packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +let server +let result +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (stats) => { + clearTimeout(timer) + next = undefined + resolveBuild(stats) + } + if (pending.length) next(pending.shift()) + }) +const state = (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const compilation = stats.stats + ? stats.stats[0].compilation + : stats.compilation + const module = [...compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const files = Object.keys(compilation.assets).filter((name) => + name.endsWith('.css'), + ) + assert.ok(files.length) + return { + manifestWatched: compilation.fileDependencies.has(manifest), + code, + selected, + files, + } +} +try { + if (!cold) { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + } + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: cold ? 'production' : 'development', + plugins: [DevupUI()], + source: { entry: { main: entry } }, + resolve: { + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + server: { host: '127.0.0.1', port: 0 }, + output: { + injectStyles: false, + minify: false, + distPath: { root: join(root, cold ? 'cold' : 'out') }, + }, + }, + }) + if (cold) { + result = await builder.build() + const files = await import('node:fs/promises').then(({ readdir }) => + readdir(join(root, 'cold'), { recursive: true }), + ) + const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) + ).join('\n') + assert.match(css, /background:\s*(?:blue|#00f)(?:;|\s*\})/) + assert.doesNotMatch(css, /background:\s*(?:red|#f00)(?:;|\s*\})/) + console.info(JSON.stringify({ cold: css })) + } else { + builder.onAfterDevCompile(({ stats }) => + next ? next(stats) : pending.push(stats), + ) + const initial = nextBuild() + server = (await builder.startDevServer()).server + const before = state(await initial) + assert.ok(before.manifestWatched, `Manifest not registered: ${manifest}`) + const { port } = server.httpServer.address() + const css = async (snapshot) => + ( + await Promise.all( + snapshot.files.map(async (file) => { + const response = await fetch(`http://127.0.0.1:${port}/${file}`, { + signal: AbortSignal.timeout(10000), + }) + assert.equal(response.status, 200) + return response.text() + }), + ) + ).join('\n') + const beforeCss = await css(before) + assert.match( + beforeCss, + new RegExp( + `\\.${before.selected}\\s*\\{[^}]*background:\\s*red(?:;|\\s*\\})`, + ), + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = state(await rebuilt) + for (let attempt = 0; attempt < 3; attempt += 1) { + if (after.selected !== before.selected) break + after = state(await nextBuild()) + } + const afterCss = await css(after) + assert.notEqual(after.selected, before.selected) + assert.match( + afterCss, + new RegExp( + `\\.${after.selected}\\s*\\{[^}]*background:\\s*(?:blue|#00f)(?:;|\\s*\\})`, + ), + ) + await server.close() + server = undefined + const built = spawnSync('node', [import.meta.filename, 'cold', root], { + encoding: 'utf-8', + }) + assert.equal(built.status, 0, built.stderr) + console.info( + JSON.stringify({ + before: beforeCss, + after: afterCss, + selected: after.selected, + cold: built.stdout, + }), + ) + } +} finally { + await server?.close() + await result?.close() + if (fixture) await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..82ba4a1cd --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('serves the new selected CSS rule when only linked workspace exports change', () => { + // Given the real Rsbuild dev-server fixture and two existing palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When only exports is edited and public compilation callbacks complete. + const result = Bun.spawnSync(['node', runner], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then HTTP-served CSS selects blue and the separate cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') +}, 45000) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 990a4cd2a..ca2270076 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,20 +1,40 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, + computeReachableFiles, createCompatTypes, + createDependencyGuard, createModuleResolver, + type CreateModuleResolverOptions, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, + isMdxSource, + isSelectedSource, loadDevupConfig, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -25,12 +45,16 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, setPrefix, } from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildPlugin, Rspack } from '@rsbuild/core' + +const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { package: string @@ -43,6 +67,8 @@ export interface DevupUIRsbuildPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for it * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -65,42 +91,35 @@ export interface DevupUIRsbuildPluginOptions { importAliases?: ImportAliases } -let globalCss = '' - async function writeDataFiles( options: Omit< DevupUIRsbuildPluginOptions, 'extractCss' | 'debug' | 'include' >, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - await Promise.all([ - !existsSync(options.cssDir) - ? mkdir(options.cssDir, { recursive: true }) - : Promise.resolve(), - !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) - : Promise.resolve(), - ]) + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') + if (!existsSync(options.cssDir)) + await mkdir(options.cssDir, { recursive: true }) + if (!options.singleCss) + await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) +} + +/** + * Write `css` to the stylesheet file at `path` unless it holds it already. The + * CSS loaders read the shared base from disk when a file's stylesheet imports + * it, so it must hold every style extracted so far. + */ +function writeChanged(path: string, css: string) { + if (!existsSync(path) || readFileSync(path, 'utf-8') !== css) + writeFileSync(path, css, 'utf-8') } export const DevupUI = ({ @@ -108,41 +127,69 @@ export const DevupUI = ({ package: libPackage = '@devup-ui/react', extractCss = true, distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, devupFile = 'devup.json', debug = false, singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions: configuredMdxExtensions, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) + const excludeModules = createNodeModulesExcludeRegex(include) + let seedWarningEmitted = false + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { - name: 'devup-ui-rsbuild-plugin', + name: PLUGIN_NAME, async setup(api) { - setDebug(debug) - if (prefix) { - setPrefix(prefix) - } - - 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({ - package: libPackage, + const root = api.context.rootPath + const { cssDir, - devupFile, + distDir: outputDir, + devupFile: configFile, + } = resolveProjectPaths(root, { distDir, - singleCss, + devupFile, + cssDir: configuredCssDir, }) + // A build starts from its own options, not from what an earlier build + // in this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + api.onCloseBuild?.(endBuild) + setDebug(debug) + setPrefix(prefix ?? null) + + try { + if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true }) + await writeFile(join(outputDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(outputDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile: configFile, + distDir: outputDir, + singleCss, + }) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } if (!extractCss) return // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any @@ -153,60 +200,315 @@ export const DevupUI = ({ // POSIX-normalized to match. const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - setModuleResolver( - createModuleResolver({ - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - if (atomMode) { - try { - const root = process.cwd() - const srcDir = resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + const toId = (path: string) => + atomMode ? path.replaceAll('\\', '/') : path + const sourceDirs = [ + ...new Set([ + ...resolveSourceDirs(root, configuredSourceDirs), + ...resolveSourceDirs(root), + ]), + ] + const plans = new Map< + string, + { + graph: StaticImportGraph + roots: string[] + entries: string[] + resolver: ReturnType + resolverOptions: CreateModuleResolverOptions + preserveSymlinks: boolean + inputFiles: Set + missingInputs: Set + } + >() + const prewarm = (plan: NonNullable>) => { + plan.resolver = createModuleResolver(plan.resolverOptions) + setModuleResolver(plan.resolver) + for (const file of computeReachableFiles({ + srcDir: plan.roots, + tsconfigPath: resolve(root, 'tsconfig.json'), + entries: plan.entries, + graph: plan.graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { + let extractCssDir = relative(dirname(file), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + codeExtract( + toId(file), + readFileSync(file, 'utf-8'), + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${root}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, ) } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). } } + api.onBeforeBuild(() => { + for (const plan of plans.values()) prewarm(plan) + }) + + const servedCss = new Map>() + const checkCompiled = createDependencyGuard({ + package: libPackage, + mdxExtensions, + importAliases, + }) + const stylesheet = (resourcePath: string) => + // A file's stylesheet imports the shared base, except in atom mode, + // where the entry code imports the base itself so that hoisted atoms + // load once (the injected splitChunks cacheGroup, see + // modifyRsbuildConfig, emits the base once) + getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) api.transform( { test: cssDir, }, - ({ resourcePath }) => { - // Non-atom: keep the existing single-string behavior (no regression). - if (!atomMode) return globalCss - // Atom mode: serve the route-specific chunk and have it @import the - // shared base (devup-ui.css) so hoisted atoms load ONCE and are not - // inlined per chunk. The base file itself imports nothing. - // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css); the injected splitChunks - // cacheGroup (see modifyRsbuildConfig) emits the base once. - return getCss(getFileNumByFilename(basename(resourcePath)), false) + ({ resourcePath, environment }) => { + const css = stylesheet(resourcePath) + servedCss.get(environment.name)?.set(resourcePath, css) + return css }, ) + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. The dev server rebuilds it + // through the stylesheet files the transforms write instead. + api.modifyRspackConfig((config, { environment }) => { + const initialize = (normalized: Rspack.Compiler['options']) => { + const baseConditions = normalized.resolve?.conditionNames ?? [ + 'webpack', + normalized.mode === 'development' ? 'development' : 'production', + environment.config?.output?.target === 'node' ? 'node' : 'browser', + ] + const conditions = ( + normalized.resolve?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = normalized.entry + if (typeof entry === 'function') + throw new Error( + `[devup-ui] graph setup failed at ${root}: dynamic Rspack entries are not available for prewarm`, + ) + const rawEntries = + typeof entry === 'string' + ? [entry] + : Array.isArray(entry) + ? entry + : Object.values(entry ?? {}).flatMap((value) => + typeof value === 'string' + ? [value] + : Array.isArray(value) + ? value + : (value.import ?? []), + ) + const entries = rawEntries + .filter((file) => isSelectedSource(file, mdxExtensions)) + .map((file) => resolve(root, file)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((file) => dirname(file)), + ]), + ] + const tsconfigPath = resolve(root, 'tsconfig.json') + const inputFiles = new Set() + const missingInputs = new Set() + const preserveSymlinks = normalized.resolve?.symlinks === false + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add(resolutionWatchPath(path, preserveSymlinks)) + for (const path of inputs.missingDependencies) + missingInputs.add(resolutionWatchPath(path, preserveSymlinks)) + } + try { + const resolverOptions = { + cwd: root, + includeMdx: mdxExtensions, + conditions, + alias: normalized.resolve?.alias, + toId, + onResolutionInputs, + } + const resolver = createModuleResolver(resolverOptions) + setModuleResolver(resolver) + const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: mdxExtensions, + cwd: root, + include, + conditions, + alias: normalized.resolve?.alias, + exclude: [basename(outputDir), basename(cssDir)], + onResolutionInputs, + }) + const plan = { + graph, + roots, + entries, + resolver, + resolverOptions, + preserveSymlinks, + inputFiles, + missingInputs, + } + plans.set(environment.name, plan) + if (atomMode) { + const canonicalMap = buildCanonicalMap({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + const reach = computeFileReach({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length ? entries : undefined, + }) + const hoist = planAtomHoist(canonicalMap, reach, atomHoist) + if (hoist) { + importFileRoutes(hoist.reachByBucket) + setAtomHoist(hoist.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: mdxExtensions, + include, + cwd: root, + needles: extractedNeedles(libPackage, importAliases), + toId, + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root, cause }, + ) + } + } + prewarm(plan) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } + config.plugins ??= [] + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + initialize(compiler.options) + let passes = 0 + compiler.hooks.run.tap(PLUGIN_NAME, () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const plan = plans.get(environment.name) + for (const path of plan?.inputFiles ?? []) + compilation.fileDependencies.add(path) + for (const path of plan?.missingInputs ?? []) + compilation.missingDependencies.add(path) + const served = new Map() + servedCss.set(environment.name, served) + const basePath = join(cssDir, 'devup-ui.css') + const base = stylesheet(basePath) + writeChanged(basePath, base) + let stale: string[] = [] + compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + if (compiler.watchMode) return + const changed = new Set( + [...served] + .filter(([path, css]) => stylesheet(path) !== css) + .map(([path]) => path), + ) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin + if (stylesheet(basePath) !== base) changed.add(basePath) + stale = [...changed] + }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + compilation.errors.push( + ...checkCompiled(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = + actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + }), + ) + }, + ) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: + compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap(PLUGIN_NAME, () => { + if (stale.length === 0 || passes > 0) return false + passes += 1 + for (const path of stale) + writeFileSync(path, stylesheet(path), 'utf-8') + return true + }) + }) + }, + }) + }) + api.modifyRsbuildConfig((config) => { const theme = getDefaultTheme() if (theme) { @@ -231,16 +533,20 @@ export const DevupUI = ({ } }) => { rspackConfig.optimization ??= {} - const sc = rspackConfig.optimization.splitChunks - if (sc && typeof sc === 'object') { - sc.cacheGroups ??= {} - sc.cacheGroups['devupUiShared'] = { - type: 'css/mini-extract', - name: 'devup-ui-shared', - test: /[\\/]devup-ui\.css$/, - chunks: 'all', - enforce: true, - } + const splitChunks = rspackConfig.optimization.splitChunks + if (splitChunks === false) + throw new Error( + `[devup-ui] atomHoist requires splitChunks at ${root}; splitChunks: false disables shared CSS`, + ) + const sc = splitChunks ?? {} + rspackConfig.optimization.splitChunks = sc + sc.cacheGroups ??= {} + sc.cacheGroups['devupUiShared'] = { + type: 'css/mini-extract', + name: 'devup-ui-shared', + test: /[\\/]devup-ui\.css$/, + chunks: 'all', + enforce: true, } } config.tools.rspack = Array.isArray(prev) @@ -252,81 +558,115 @@ export const DevupUI = ({ return config }) - api.transform( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - async ({ code, resourcePath, addDependency }) => { - if (createNodeModulesExcludeRegex(include).test(resourcePath)) - return code - // The stylesheet import is emitted relative to the importing file, as - // in the next/webpack/vite loaders. An absolute cssDir would bake this - // checkout's path into the emitted module, so byte-identical sources - // in two checkouts (git worktrees, a CI matrix, sibling clones) would - // produce different output and any content-addressed or relocated - // build cache would serve the wrong checkout's stylesheet. - // - // Atom mode additionally mirrors vite: the entry CODE imports the - // shared base (import_main_css_in_code=true) so rspack emits - // devup-ui.css once and links it from every entry (hoisted atoms - // shared, not inlined), and the extraction filename is - // POSIX-normalized to match the absolute-keyed canonical map / - // FILE_ROUTES. - let extractCssDir = relative( - dirname(resourcePath), - cssDir, - ).replaceAll('\\', '/') - if (!extractCssDir.startsWith('./')) - extractCssDir = `./${extractCssDir}` - const extractName = atomMode - ? resourcePath.replaceAll('\\', '/') - : resourcePath - const { - code: retCode, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( - extractName, - code, - libPackage, - extractCssDir, - singleCss, - atomMode, - !atomMode, - importAliases, + const extract: Parameters[1] = async ({ + code, + resourcePath, + addDependency, + addMissingDependency, + environment, + }) => { + if (excludeModules.test(resourcePath)) return code + const plan = plans.get(environment?.name) + if (plan) + setModuleResolver( + createModuleResolver({ + ...plan.resolverOptions, + onResolutionInputs: (inputs) => { + for (const path of inputs.fileDependencies) + addDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + for (const path of inputs.missingDependencies) + addMissingDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + }, + }), ) - for (const dependency of dependencies) addDependency(dependency) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile( - join(cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ), + // The stylesheet import is emitted relative to the importing file, as + // in the next/webpack/vite loaders. An absolute cssDir would bake this + // checkout's path into the emitted module, so byte-identical sources + // in two checkouts (git worktrees, a CI matrix, sibling clones) would + // produce different output and any content-addressed or relocated + // build cache would serve the wrong checkout's stylesheet. + // + // Atom mode additionally mirrors vite: the entry CODE imports the + // shared base (import_main_css_in_code=true) so rspack emits + // devup-ui.css once and links it from every entry (hoisted atoms + // shared, not inlined), and the extraction filename is + // POSIX-normalized to match the absolute-keyed canonical map / + // FILE_ROUTES. + let extractCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + const extractName = atomMode + ? resourcePath.replaceAll('\\', '/') + : resourcePath + const { + code: retCode, + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + extractName, + code, + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ...(isMdxSource(resourcePath, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) + } catch (error) { + if (isMdxSource(resourcePath, mdxExtensions)) + throw remapMdxError(error, resourcePath) + throw error } + })() + for (const dependency of dependencies) addDependency(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style + promises.push( + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), + ) + } - if (cssFile) { - if (globalCss.length < css.length) globalCss = css - promises.push( - writeFile( - join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, - 'utf-8', - ), - ) - } - await Promise.all(promises) - return { - code: retCode, - map, - } - }, + if (cssFile) { + promises.push( + stateWriter.write( + join(cssDir, basename(cssFile)), + `/* ${resourcePath} ${Date.now()} */`, + 'utf-8', + ), + ) + } + await Promise.all(promises) + return { + code: retCode, + map, + } + } + api.transform({ test: SOURCE_FILE_RE }, extract) + // Rsbuild's order: post installs an enforce: post Rspack loader. Its + // transform context does not expose the incoming map, so located errors + // are explicitly labelled as compiled MDX rather than claiming raw lines. + api.transform( + { test: mdxSourceFilter(mdxExtensions), order: 'post' }, + extract, ) }, } diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a943c028c..d662c59fd 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -177,6 +177,91 @@ const box = const box = ``` +## Publishing Component Libraries + +Choose one of these build-time contracts. Neither mode permits Devup UI's +compile-time component placeholders to run in a consuming application. + +1. Precompiled library: keep `extractCss: true` (the default), publish the + transformed JavaScript and emitted CSS, and expose a stylesheet that consumers + import. Consumers do not need to transform the already compiled library. +2. Uncompiled library: use `DevupUI({ extractCss: false })` intentionally, as in + `apps/vite-lib`. Every consuming application must install a Devup UI build + plugin and include the library's exact package name so its modules are + transformed during the application's build. + +```ts +// The consuming application's vite.config.ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +An uncompiled library is not standalone browser JavaScript. Using it without +the consumer transformation runs placeholders and throws +`Cannot run on the runtime`. Do not publish it as precompiled output. + +### Aggregated Library CSS + +Library builds and applications using `build.cssCodeSplit: false` prepare the +actual resolved build inputs and their static/dynamic dependencies before any +generated stylesheet is loaded. MDX still runs after the configured compiler. +Unreachable source files contribute no CSS, and complete styles pass through +Vite's normal CSS processing and optimizer in their original import positions. +Ordinary split application builds and development behavior are unchanged. + +If a public bundler hook cannot expose a complete transformed graph, the build +fails with the responsible source/stylesheet and a supported alternative instead +of emitting incomplete CSS. Remaining computed imports must use literal imports +or `import.meta.glob`; plugins must expose additional inputs before the first +generated stylesheet loads. A source transform that awaits generated CSS can +form a circular wait, so unsettled public loads fail after 30 seconds; an +exceptionally slow healthy transform can also reach that limit. + +Some split libraries with plugin-emitted entries produce a standalone generated +stylesheet that cannot retain optimizer output under the shared-sheet contract. +These builds fail with the final asset name. Use `build.cssCodeSplit: false` for +that library, or publish separately imported, precompiled CSS. The real-build +matrix is qualified with Vite 8.3.1 / Rolldown 1.2.10; no private adapter or +bundler version pin is required. + +## MDX Selection And Compiled-Source Guards + +`mdxExtensions` defaults to `['.mdx']`. Add literal extensions such as +`['.mdx', '.md', '.mdown']` only when your project compiler handles them as MDX. +The same list controls post-compiler extraction, graph discovery and numbering. +Compiled output is JavaScript with JSX under its real filename, never renamed +to a JavaScript extension. Configure the MDX compiler separately; Devup runs +after it. Ordinary JavaScript/TypeScript extensions keep their existing handling. + +`DevupUI()` returns the main `devup-ui` plugin, the unchanged +`devup-ui:restore-forwarded-css` plugin, and three independent companions: +`devup-ui:mdx`, `devup-ui:aggregate-css-guard`, and +`devup-ui:compiled-source-guard`. The main plugin remains `enforce: 'pre'`; +the MDX companion is `enforce: 'post'` and only transforms source. + +During builds with extraction enabled, the compiled-source guard inspects final +code through Vite's public parser and resolved module graph. An extension outside +the JavaScript/TypeScript and MDX lists fails with its module, used import and +`mdxExtensions` remedy when a compile-time export is definitely referenced by +name, static namespace/member access or destructuring. Runtime helpers such as +`getTheme`, `setTheme`, `initTheme`, `useTheme` and `ThemeScript`, and unused +imports, are allowed. An active compatibility alias can be disabled with +`importAliases[package] = false` when runtime use is intended. + +Whole namespace/require-result objects and dynamic imports are opaque and are +not rejected; compile-time exports reached only that way may still throw +`Cannot run on the runtime` when rendered. Let Devup transform the extension +instead. Ordinary JS/TS excluded by `include`, and intentional +`extractCss: false` publishing, are not broadened by this guard. + +The complete forwarding graph is finalized by build hooks, not available as an +equivalent completed graph in the dev server. This guard is build-only, including +build-watch compilations; unselected extensions in dev may therefore retain +placeholders and throw when rendered. Add the extension to `mdxExtensions` and +configure its compiler for both dev and build. Vite's native alias format has +no `false` target. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target diff --git a/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts new file mode 100644 index 000000000..b172089f5 --- /dev/null +++ b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../src/plugin' + +it('rejects a real public source-load/generated-CSS circular wait with a located alternative', async () => { + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-load-cycle-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const sheet = `${root}/df/devup-ui/devup-ui.css` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI(), + { + name: 'source-awaiting-generated-css', + enforce: 'post', + async transform(_source, id) { + if (id === entry) await this.load({ id: sheet }) + }, + }, + ], + build: { write: false, lib: { entry, formats: ['es'] } }, + }) + await expect(pending).rejects.toThrow( + `${entry}:1:1: [devup-ui] aggregate CSS asset`, + ) + await expect(pending).rejects.toThrow('preparation cycle') + await expect(pending).rejects.toThrow('separate imported asset') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 40000) diff --git a/packages/vite-plugin/src/__tests__/aggregate-build.test.ts b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts new file mode 100644 index 000000000..1ac53cebe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + for (const configuration of ['library', 'split-library', 'aggregate-app']) { + it(`completes ${configuration} CSS when singleCss=${singleCss} and ${delayed} transforms last`, async () => { + // Given: real inputs, shared sheets, JS/CSS cycles, late compiler edges. + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-aggregate-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"aggregate-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; export const lazy=()=>import('./dynamic.js'); globalThis.fixtureLazy=lazy; import 'virtual:aggregate'; export {default as Page} from './page.mdx'; import './blue.css';", + 'other.js': + "export {second} from './second.js'; export {first} from './first.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; import './cycle-a.js'; export {staticStyle} from './style.css.ts'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'style.css.ts': + "import {css} from '@devup-ui/react'; import {color} from './constants.ts'; export const staticStyle=css({background:color});", + 'constants.ts': "export const color='purple';", + 'dynamic.js': + "import {css} from '@devup-ui/react'; export const dynamic=css({background:'orange'});", + 'injected.js': + "import {css} from '@devup-ui/react'; export const injected=css({background:'yellow'});", + 'plugin-entry.js': + "import {css} from '@devup-ui/react'; export const emitted=css({background:'pink'});", + 'cycle-a.js': "import './cycle-b.js'; export const a='a';", + 'cycle-b.js': "import './cycle-a.js'; export const b='b';", + 'page.mdx': + 'import {Box} from \'@devup-ui/react\'\n\n# Compiled\n\n', + 'unreachable.js': + "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'magenta'}});", + 'red.css': '@import "./local.css"; @layer b{.x{color:red}}', + 'local.css': + '@import "./cycle.css"; .asset{background:url(./image.svg?no-inline)}', + 'cycle.css': '@import "./local.css"; .local{display:block}', + 'blue.css': '@layer b{.x{color:blue}}', + 'image.svg': + '', + } + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(root, name), source) + } + const transformed = new Set() + const snapshots: string[] = [] + const plugins = DevupUI({ singleCss }) + const originalLoad = plugins[0].load + plugins[0].load = async function (id) { + const result = await originalLoad.call(this, id) + if (/devup-ui(?:-\d+)?\.css$/.test(id)) { + snapshots.push(String(result)) + expect(transformed.has(`${root}/second.js`)).toBe(true) + expect(transformed.has(`${root}/injected.js`)).toBe(true) + expect(transformed.has(`${root}/page.mdx`)).toBe(true) + } + return result + } + try { + // When: the actual optimizer consumes the ordinary generated imports. + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { + alias: { + 'react/jsx-runtime': landing.resolve('react/jsx-runtime'), + }, + }, + plugins: [ + { + name: 'aggregate-delay-and-mdx', + enforce: 'pre', + async transform(source, id) { + if (id.endsWith(`/${delayed}.js`)) { + await new Promise((done) => setTimeout(done, 60)) + } + if (id.endsWith('.mdx')) { + return { + code: String( + await mdx.compile({ value: source, path: id }), + ), + map: null, + } + } + }, + }, + plugins, + { + name: 'aggregate-injected-closure', + enforce: 'post', + buildStart() { + if (configuration !== 'split-library') { + this.emitFile({ + type: 'chunk', + id: join(root, 'plugin-entry.js'), + name: 'plugin-entry', + }) + } + }, + resolveId(id) { + if (id === 'virtual:aggregate') return '\0aggregate-companion' + }, + async load(id) { + if (id === '\0aggregate-companion') { + await new Promise((done) => setTimeout(done, 90)) + return 'export const companion=1;' + } + }, + transform(source, id) { + transformed.add(id) + if (id === '\0aggregate-companion') { + return `${source}\nimport ${JSON.stringify(join(root, 'injected.js'))};` + } + }, + }, + ], + build: { + write: false, + minify: false, + cssMinify: 'lightningcss', + cssCodeSplit: configuration === 'split-library', + lib: + configuration === 'aggregate-app' + ? false + : { + entry: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + formats: ['es'], + }, + rolldownOptions: { + input: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + external: ['react/jsx-runtime'], + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + // Then: complete atoms/base sheet, blue cascade, native CSS processing. + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) + throw new Error('Build returned a watcher') + return bundle.output + }, + ) + const assets = output.filter((item) => item.type === 'asset') + const css = assets + .filter((item) => item.fileName.endsWith('.css')) + .map((item) => String(item.source)) + .join('\n') + expect(snapshots.length).toBeGreaterThan(0) + expect(snapshots.join('\n')).toContain('body{color:blue}') + expect(css).toMatch(/body[^}]*color:#00f/) + for (const color of ['purple', 'orange', '#ff0', '#0ff']) { + expect(css).toContain(color) + } + if (configuration !== 'split-library') expect(css).toContain('pink') + if (configuration !== 'split-library') + expect(css).not.toContain('green') + expect(css).not.toContain('magenta') + expect(css).toMatch(/(?:\.x|body,\.x)\{color:#00f\}/) + const byName = new Map(output.map((item) => [item.fileName, item])) + const entryCss: string[] = [] + const visited = new Set() + function visit(file: string) { + if (visited.has(file)) return + visited.add(file) + const item = byName.get(file) + if (item?.type !== 'chunk') return + for (const dependency of item.imports) visit(dependency) + const metadata = + 'viteMetadata' in item ? item.viteMetadata : undefined + if ( + metadata && + typeof metadata === 'object' && + 'importedCss' in metadata && + metadata.importedCss instanceof Set + ) { + for (const assetName of metadata.importedCss) { + const asset = byName.get(assetName) + if (asset?.type === 'asset') entryCss.push(String(asset.source)) + } + } + } + const entry = output.find( + (item) => + item.type === 'chunk' && item.isEntry && item.name === 'entry', + ) + if (!entry) throw new Error('Entry output missing') + visit(entry.fileName) + if (configuration === 'split-library') { + const winners = [ + ...entryCss.join('\n').matchAll(/([^{}]+)\{color:([^;}]+)/g), + ].filter((match) => match[1].split(',').includes('.x')) + expect(winners.at(-1)?.[2]).toBe('#00f') + } + expect(css).toContain('.local{display:block}') + expect(css).not.toContain('@import') + const image = assets.find((item) => + /custom\/image-[\w-]+\.svg$/.test(item.fileName), + ) + if (!image) throw new Error('Hashed URL asset missing') + expect(css).toContain(basename(image.fileName)) + expect(css).not.toContain('?no-inline') + expect( + assets + .filter((item) => item.fileName.endsWith('.css')) + .every((item) => /^custom\/.+-[\w-]+\.css$/.test(item.fileName)), + ).toBe(true) + expect(transformed.has(`${root}/unreachable.js`)).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 15000) + } + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-css.test.ts b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts new file mode 100644 index 000000000..edbe29bbe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from 'bun:test' + +import { createAggregateCssPreparation } from '../aggregate-css' +import { DevupUI } from '../plugin' +import { fixture, module } from './aggregate-fixture' + +it('shares one complete preparation when concurrent sheets use multiple cyclic entries', async () => { + // Given: two roots, a static cycle, dynamic and extensionless virtual edges. + const f = fixture({ first: 'entry', second: 'other' }) + f.modules.set( + 'entry', + module( + "import 'cycle'; export const lazy=()=>import('dynamic')", + ['cycle'], + ['dynamic'], + ), + ) + f.modules.set( + 'cycle', + module("import 'entry'; import 'sheet.css'", ['entry', 'sheet.css']), + ) + f.modules.set('other', module("import 'virtual'", ['virtual'])) + f.modules.set('dynamic', module()) + f.modules.set('virtual', module("import 'injected'", ['injected'])) + f.modules.set('injected', module()) + // When: every first snapshot requests the same environment barrier. + const first = f.preparation.prepare(f.environment, f.context, 'aggregate.css') + const second = f.preparation.prepare(f.environment, f.context, 'numbered.css') + await Promise.all([first, second]) + // Then: exactly the reachable non-CSS closure is ready, without a cycle wait. + expect(first).toBe(second) + expect([...f.loaded].sort()).toEqual([ + 'cycle', + 'dynamic', + 'entry', + 'injected', + 'other', + 'virtual', + ]) +}) + +it('isolates and resets barriers when builds reuse environment objects', async () => { + const f = fixture(['entry']) + const secondEnvironment = {} + f.modules.set('other', module()) + f.preparation.start(secondEnvironment, ['other'], { cssCodeSplit: false }) + const first = f.preparation.prepare(f.environment, f.context, 'first.css') + const second = f.preparation.prepare( + secondEnvironment, + f.context, + 'second.css', + ) + await Promise.all([first, second]) + expect(first).not.toBe(second) + expect([...f.loaded].sort()).toEqual(['entry', 'other']) + f.preparation.start(f.environment, 'other', { cssCodeSplit: false }) + const rebuilt = f.preparation.prepare(f.environment, f.context, 'rebuilt.css') + expect(rebuilt).not.toBe(first) + await rebuilt +}) + +it('leaves ordinary application builds untouched when aggregation is disabled', () => { + const f = fixture() + f.preparation.start(f.environment, 'entry', { cssCodeSplit: true }) + expect( + f.preparation.prepare(f.environment, f.context, 'sheet.css'), + ).toBeUndefined() + expect(f.loaded.size).toBe(0) +}) + +it('uses public external resolution when cached module information lacks isExternal', async () => { + const f = fixture() + f.modules.set( + 'entry', + module("import 'external'; import 'rollup-external'; import 'virtual';", [ + 'external', + 'rollup-external', + 'virtual', + ]), + ) + f.cached.set('external', { ...module(), code: null }) + f.cached.set('rollup-external', { ...module(), isExternal: true }) + f.cached.set('virtual', { ...module(), code: null }) + f.modules.set('virtual', module()) + f.context.resolve = async (id) => + id === 'virtual' ? null : { id, external: id === 'external' } + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'virtual']) +}) + +it('honors public canonical IDs when resolution redirects a dependency', async () => { + const f = fixture() + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.modules.set('canonical', module()) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id, + external: false, + }) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'canonical']) +}) + +it('accepts literal and empty-template dynamic imports after transformation', async () => { + const f = fixture() + f.modules.set( + 'entry', + module( + "export const a=()=>import('literal'); export const b=()=>import(`template`)", + [], + ['literal', 'template'], + ), + ) + f.modules.set('literal', module()) + f.modules.set('template', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'literal', 'template']) +}) + +it('keeps aggregation out of dev and disabled-extraction build hooks', () => { + const [plugin] = DevupUI({ extractCss: false }) + Reflect.apply(plugin.buildStart, {}, [{ input: 'entry' }]) + expect( + plugin.config.call(undefined, { build: { lib: { entry: 'entry' } } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('records fallback build inputs without preloading in buildStart', () => { + const [plugin] = DevupUI() + const f = fixture() + Reflect.apply(plugin.buildStart, f.context, [{ input: 'entry' }]) + expect(f.loaded.size).toBe(0) + expect( + plugin.config.call(undefined, { build: { cssCodeSplit: false } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('fails located when a source transform awaits the generated CSS barrier it blocks', async () => { + const expirations: (() => void)[] = [] + const f = fixture() + const preparation = createAggregateCssPreparation((expire) => { + expirations.push(expire) + return () => {} + }) + preparation.start(f.environment, 'entry', { cssCodeSplit: false }) + const entered = Promise.withResolvers() + f.context.load = async () => { + entered.resolve() + await preparation.prepare(f.environment, f.context, 'aggregate.css') + return module() + } + const pending = preparation.prepare(f.environment, f.context, 'aggregate.css') + await entered.promise + for (const expire of expirations) expire() + await expect(pending).rejects.toThrow( + 'entry:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) + await expect(pending).rejects.toThrow('preparation cycle') +}) + +it('prepares actual plugin-defined entry modules but never cached orphan modules', async () => { + const f = fixture() + f.cached.set('plugin-entry', { ...module(), isEntry: true }) + f.cached.set('orphan', module()) + f.modules.set('plugin-entry', module()) + f.modules.set('orphan', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'plugin-entry']) + expect(() => + f.preparation.observe(f.environment, 'plugin-entry'), + ).not.toThrow() + expect(() => f.preparation.observe(f.environment, 'late')).toThrow( + 'late:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts new file mode 100644 index 000000000..c73d76473 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts @@ -0,0 +1,240 @@ +import { expect, it } from 'bun:test' + +import { AggregateCssError } from '../aggregate-css' +import { fixture, module } from './aggregate-fixture' + +const failures: readonly { + readonly name: string + readonly setup: (f: ReturnType) => void + readonly file: string + readonly reason: string +}[] = [ + { + name: 'empty inputs', + setup(f) { + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + }, + file: 'aggregate.css', + reason: 'input list is empty', + }, + { + name: 'unresolved entry', + setup(f) { + f.context.resolve = async () => null + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'external entry', + setup(f) { + f.context.resolve = async (id) => ({ id, external: 'absolute' }) + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'unresolved dependency', + setup(f) { + f.modules.set('entry', module("import 'missing'", ['missing'])) + f.context.resolve = async (id) => + id === 'entry' ? { id, external: false } : null + }, + file: 'entry', + reason: 'resolution refused missing', + }, + { + name: 'cyclic canonical resolution', + setup(f) { + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id === 'canonical' ? 'alias' : id, + external: false, + }) + }, + file: 'entry', + reason: 'ID cycle', + }, + { + name: 'null load', + setup(f) { + f.modules.delete('entry') + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete code', + setup(f) { + f.modules.set('entry', { ...module(), code: null }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete static edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'importedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete dynamic edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'dynamicallyImportedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'unresolved import graph', + setup(f) { + f.modules.set('entry', module("export * from 'missing'")) + }, + file: 'entry', + reason: 'unresolved import graph', + }, + { + name: 'computed import', + setup(f) { + f.modules.set( + 'entry', + module("const target='late';\nexport const lazy=()=>import(target)"), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'computed template import', + setup(f) { + f.modules.set( + 'entry', + module('export const lazy=(name)=>import(`./${name}.js`)'), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'missing import source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression' }], + }) + }, + file: 'entry', + reason: 'incomplete dynamic import', + }, + { + name: 'unknown dynamic source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression', source: { type: 'Identifier' } }], + }) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'null parser', + setup(f) { + f.context.parse = () => null + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'incomplete parser', + setup(f) { + f.context.parse = () => ({}) + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'invalid parser root', + setup(f) { + f.context.parse = () => ({ type: 'Literal' }) + }, + file: 'entry', + reason: 'incomplete module information', + }, + ...[new Error('refusal'), 'non-error refusal'].flatMap((cause) => [ + { + name: `load rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.load = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public load refused', + }, + { + name: `resolve rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.resolve = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public resolution refused', + }, + { + name: `parse rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.parse = () => { + throw cause + } + }, + file: 'entry', + reason: 'public parser refused', + }, + ]), +] + +for (const failure of failures) { + it(`fails closed with a located asset/config alternative when ${failure.name}`, async () => { + // Given: a public seam refusing a complete transformed closure. + const f = fixture() + failure.setup(f) + // When: a generated snapshot attempts preparation. + try { + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + throw new Error('Expected aggregate preparation failure') + } catch (error) { + // Then: no silent partial success; responsible file and alternatives survive. + expect(error).toBeInstanceOf(AggregateCssError) + if (!(error instanceof AggregateCssError)) throw error + expect(error.file).toBe(failure.file) + expect(error.asset).toBe('aggregate.css') + expect(error.message).toContain(failure.reason) + expect(error.message).toContain('build.cssCodeSplit=true') + expect(error.message).toContain('separate imported asset') + if (failure.name === 'computed import') { + expect(error.line).toBe(2) + expect(error.column).toBe(23) + } + } + }) +} + +it('locates an unsafe standalone asset even when a plugin supplied all entry roots', () => { + const f = fixture() + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + f.preparation.checkAsset(f.environment, { fileName: 'image.svg' }) + expect(() => + f.preparation.checkAsset(f.environment, { + name: 'devup-ui.css', + fileName: 'custom/devup-ui-hash.css', + }), + ).toThrow('custom/devup-ui-hash.css:1:1: [devup-ui] aggregate CSS asset') +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-fixture.ts b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts new file mode 100644 index 000000000..69cc7a937 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts @@ -0,0 +1,47 @@ +import { parseAst } from 'vite' + +import { + createAggregateCssPreparation, + type CssClosureContext, +} from '../aggregate-css' + +type Module = NonNullable> + +export function module( + code = '', + importedIds: readonly string[] = [], + dynamicallyImportedIds: readonly string[] = [], +): Module { + return { code, importedIds, dynamicallyImportedIds } +} + +export function fixture( + entries: + string | readonly string[] | Record | undefined = 'entry', +) { + const environment = {} + const preparation = createAggregateCssPreparation() + const modules = new Map([['entry', module()]]) + const cached = new Map() + const loaded = new Set() + const context: CssClosureContext = { + getModuleIds() { + return cached.keys() + }, + getModuleInfo(id) { + return cached.get(id) ?? null + }, + async resolve(id) { + return { id, external: false } + }, + async load({ id }) { + loaded.add(id) + return modules.get(id) ?? null + }, + parse(code) { + return parseAst(code) + }, + } + preparation.start(environment, entries, { lib: { entry: 'entry' } }) + return { environment, preparation, context, modules, cached, loaded } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts new file mode 100644 index 000000000..73ceadb71 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + it(`rejects unsafe native standalone CSS when singleCss=${singleCss} and ${delayed} is delayed`, async () => { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-split-asset-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"split-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; import './blue.css';", + 'other.js': + "export {first} from './first.js'; export {second} from './second.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'emitted.js': + "import {css} from '@devup-ui/react'; export const third=css({background:'pink'});", + 'red.css': '@layer b{.x{color:red}}', + 'blue.css': '@layer b{.x{color:blue}}', + } + for (const [name, source] of Object.entries(files)) + writeFileSync(join(root, name), source) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ singleCss }), + { + name: 'actual-plugin-entry', + buildStart() { + this.emitFile({ type: 'chunk', id: `${root}/emitted.js` }) + }, + async transform(_code, id) { + if (id.endsWith(`/${delayed}.js`)) + await new Promise((done) => setTimeout(done, 60)) + }, + }, + ], + build: { + write: false, + cssCodeSplit: true, + cssMinify: 'lightningcss', + lib: { + entry: { entry: `${root}/entry.js`, other: `${root}/other.js` }, + formats: ['es'], + }, + rolldownOptions: { + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + await expect(pending).rejects.toThrow( + /aggregate CSS asset custom\/devup-ui-[\w-]+\.css/, + ) + await expect(pending).rejects.toThrow( + 'build.cssCodeSplit=false for this library', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts new file mode 100644 index 000000000..195ed0882 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts @@ -0,0 +1,138 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { buildStaticImportGraph } from '@devup-ui/plugin-utils' +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +function unknownFixture() { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-unknown-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const unknown = `${root}/unknown.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'}); export {lazy} from './unknown.js';", + ) + writeFileSync( + unknown, + 'export const lazy=(target)=>import(/* @vite-ignore */ target);', + ) + return { root, entry, unknown } +} + +it('rejects an unknowable dynamic closure with the responsible source and asset alternative', async () => { + const { root, entry, unknown } = unknownFixture() + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { write: false, lib: { entry, formats: ['es'] } }, + }), + ).rejects.toThrow(`${unknown}:1:29: [devup-ui] aggregate CSS asset`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('builds the same module when the documented nonaggregating application alternative is used', async () => { + const { root, entry } = unknownFixture() + try { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + write: false, + lib: false, + cssCodeSplit: true, + rolldownOptions: { input: entry }, + }, + }) + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) throw new Error('Build returned a watcher') + return bundle.output + }, + ) + expect(output.some((item) => item.fileName.endsWith('.css'))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('rejects a real late plugin-emitted module absent from the static source graph', async () => { + resetBuildState() + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + const root = realpathSync + .native(mkdtempSync(join(parent, 'devup-late-'))) + .replaceAll('\\', '/') + mkdirSync(`${root}/src`) + const entry = `${root}/src/entry.js` + const late = `${root}/late.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + writeFileSync( + late, + "import {css} from '@devup-ui/react'; export const late=css({color:'blue'});", + ) + const graph = buildStaticImportGraph(`${root}/src`, undefined, { cwd: root }) + expect( + graph.fileSet.has(late.replaceAll('/', '\\')) || graph.fileSet.has(late), + ).toBe(false) + let emitted = false + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ sourceDirs: ['src'] }), + { + name: 'late-module-after-css-barrier', + transform(_code, id) { + if (!emitted && /devup-ui.*\.css(?:$|\?)/.test(id)) { + emitted = true + this.emitFile({ type: 'chunk', id: late, name: 'late' }) + } + }, + }, + ], + build: { + write: false, + cssCodeSplit: false, + lib: { entry, formats: ['es'] }, + }, + }), + ).rejects.toThrow(`${late}:1:1: [devup-ui] aggregate CSS asset`) + expect(emitted).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/vite-plugin/src/__tests__/core-regression.test.ts b/packages/vite-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 000000000..8210cffc5 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,256 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { ConfigLoadError } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +let root: string +let server: ViteDevServer | undefined +const temp = process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir() + +beforeEach(async () => { + wasm.resetBuildState() + root = await realpath(await mkdtemp(join(temp, 'vite-'))) + await writeFile(join(root, 'tsconfig.json'), '{}') +}) + +afterEach(async () => { + await server?.close() + server = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'extracts modern .%s modules under the actual Vite root', + async (extension) => { + await mkdir(join(root, 'src')) + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const color = css({bg:'red'});", + ) + server = await createServer({ + root, + tsconfig: join(root, 'tsconfig.json'), + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + plugins: [DevupUI({ singleCss: true })], + }) + + const result = await server.transformRequest(`/src/main.${extension}`) + + expect(result?.code).not.toContain('css({') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') + }, +) + +it('clears declarations when a real theme file becomes empty', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false, watch: null }, + plugins: [plugin], + }) + await server.watcher.close() + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + await plugin.watchChange(join(root, 'devup.json')) + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it('preserves the prior theme on a malformed watched config', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { preserved: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false, watch: null }, + plugins: [plugin], + }) + await server.watcher.close() + const prior = await readFile(join(root, 'df/theme.d.ts'), 'utf-8') + const priorCss = wasm.getCss(null, false) + expect(server.watcher.getWatched()).toEqual({}) + await writeFile(join(root, 'devup.json'), '{') + const diagnostic = spyOn(console, 'error').mockImplementation(() => {}) + try { + console.error('[unrelated] config diagnostic from another server') + await plugin.watchChange(join(root, 'devup.json')) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toBe(prior) + const targeted = diagnostic.mock.calls.find( + ([message]) => + message === + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + expect(targeted?.[0]).toBe( + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + const error = targeted?.[1] + expect(error).toBeInstanceOf(ConfigLoadError) + if (!(error instanceof ConfigLoadError)) + throw new TypeError('Expected config load diagnostic') + expect(error.file).toBe(join(root, 'devup.json')) + expect(error.cause).toBeInstanceOf(SyntaxError) + expect(wasm.getCss(null, false)).toBe(priorCss) + expect(priorCss).toContain('--preserved') + } finally { + diagnostic.mockRestore() + } +}) + +it('rejects a malformed setup config rather than clearing it silently', async () => { + await writeFile(join(root, 'devup.json'), '{') + + await expect( + createServer({ root, configFile: false, plugins: [DevupUI()] }), + ).rejects.toThrow(join(root, 'devup.json')) +}) + +it.each(['client', 'ssr'])( + 'covers all roots and the included closure for environment %s', + async (consumer) => { + for (const dir of ['src', 'app', 'custom', 'node_modules/foo.bar/nested']) + await mkdir(join(root, dir), { recursive: true }) + await writeFile( + join(root, 'node_modules/foo.bar/package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + import: './import.mjs', + }, + }), + ) + await writeFile( + join(root, 'node_modules/foo.bar/browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/import.mjs'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/node.mjs'), + "export const color = 'blue'", + ) + await writeFile( + join(root, 'src/style.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/main.ts'), "import '../src/style.mts'") + await writeFile(join(root, 'custom/main.cts'), "import '../src/style.mts'") + server = await createServer({ + root, + configFile: false, + tsconfig: join(root, 'tsconfig.json'), + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + resolve: { conditions: ['browser', 'development|production'] }, + ssr: { resolve: { conditions: ['node', 'development|production'] } }, + build: { + ssr: consumer === 'ssr', + rollupOptions: { + input: { app: 'app/main.ts', custom: 'custom/main.cts' }, + }, + }, + plugins: [ + DevupUI({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + const result = + consumer === 'ssr' + ? await server.environments.ssr.transformRequest('/src/style.mts') + : await server.transformRequest('/src/style.mts') + + expect(result?.code).not.toContain('css({') + expect(wasm.getCss(null, false)).toContain( + consumer === 'ssr' ? 'blue' : 'red', + ) + expect(wasm.getCss(null, false)).not.toContain( + consumer === 'ssr' ? 'red' : 'blue', + ) + }, +) + +it('extracts MDX only after the installed MDX compiler has compiled raw markdown', async () => { + const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const loader = landing.resolve('@mdx-js/loader') + const mdx = await import( + pathToFileURL(createRequire(loader).resolve('@mdx-js/mdx')).href + ) + await writeFile( + join(root, 'page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Title\n\n', + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + resolve: { + alias: { 'react/jsx-runtime': landing.resolve('react/jsx-runtime') }, + }, + server: { middlewareMode: true, hmr: false }, + plugins: [ + DevupUI({ singleCss: true }), + { + name: 'actual-mdx-compiler', + enforce: 'pre', + async transform(source, id) { + if (!id.endsWith('.mdx')) return + const output = await mdx.compile({ value: source, path: id }) + return { code: String(output), map: null } + }, + }, + ], + }) + + const result = await server.transformRequest('/page.mdx') + + expect(result?.code).not.toContain('bg: "red"') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') +}) diff --git a/packages/vite-plugin/src/__tests__/guard-facts.test.ts b/packages/vite-plugin/src/__tests__/guard-facts.test.ts new file mode 100644 index 000000000..597acae06 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/guard-facts.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from 'bun:test' +import { parseAst } from 'vite' + +import { compiledFacts } from '../../../plugin-utils/src/guard-facts' + +it.each([ + [ + 'member object', + 'import {css} from "library"; export const n=css({color:"red"}).length', + ], + [ + 'parameter default', + 'import {css} from "library"; export function f({x=css({color:"red"})}={}){return x}', + ], + [ + 'array default', + 'import {css} from "library"; const [x=css({color:"red"})]=[]', + ], + [ + 'pattern default', + 'import {css} from "library"; const {x=css({color:"red"})}={}', + ], + [ + 'computed destructure', + 'import * as DU from "library"; const {["css"]:style}=DU', + ], + ['direct require', 'export const n=require("library").css({color:"red"})'], + [ + 'assigned require', + 'const DU=require("library"); export const n=DU.css({color:"red"})', + ], + [ + 'require destructure', + 'const {css:style}=require("library"); export const n=style({color:"red"})', + ], +] as const)('records definite css when %s executes', (_name, source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect( + facts.references.map(({ request, ids }) => ({ request, ids })), + ).toContainEqual({ request: 'library', ids: ['css'] }) +}) + +it.each([ + 'const DU=require("library"); export const value=DU', + 'export const value=require("library")', + 'export const value=import("library")', + 'export function f(require){return require("library").css()}', + 'const require=other; export const value=require("library").css()', + 'export const value=require(name).css()', + 'import * as DU from "library"; export function f({css}={}){return css()}', +] as const)( + 'keeps opaque or shadowed source unclassified when %s', + (source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect(facts.references).toEqual([]) + }, +) + +it('resolves static namespace references while preserving lexical shadowing and opaque uses', () => { + const ast = parseAst(`import * as DU from 'library'; + import {Box as B, getTheme} from 'library'; + export function shadow(DU, {Box:B}) { return [DU.css(), B]; } + function hoisted() { if (false) { var B; } return B; } + const C = class DU { method() { return DU.css(); } static { const B = 1; } }; + const escaped = DU; + const {css: style} = DU; + let picked; ({keyframes: picked} = DU); + const read = [DU.getTheme, DU['css'], B, getTheme]; + let name; const computed = DU[name]; + export default read;`) + const facts = compiledFacts(ast) + expect(facts.references.map((reference) => reference.ids)).toEqual([ + ['css'], + ['keyframes'], + ['getTheme'], + ['css'], + ['Box'], + ['getTheme'], + ]) +}) + +it('retains actual forwarding names and local-export precedence for a bundler to resolve', () => { + const facts = compiledFacts( + parseAst(`import styled from 'alias'; + import {Box as B} from 'library'; + export {styled as Named, B as Widget}; + export {css as cls} from 'library'; + export * from 'star'; + export * as Namespace from 'library'; + export const local = 1, [first, ...rest] = []; + export class LocalClass {} + export default styled;`), + ) + expect([...facts.exports]).toEqual([ + ['Named', { request: 'alias', ids: ['default'] }], + ['Widget', { request: 'library', ids: ['Box'] }], + ['cls', { request: 'library', ids: ['css'] }], + ['Namespace', { request: 'library', ids: [] }], + ['local', undefined], + ['first', undefined], + ['rest', undefined], + ['LocalClass', undefined], + ['default', { request: 'alias', ids: ['default'] }], + ]) + expect(facts.stars).toEqual(['star']) +}) + +it('visits parameter defaults, catches, loop scopes and function expressions without guessing binding names', () => { + const ast = parseAst(`import {css} from 'library'; + const local = function self({color: c = 1}, ...args) { return self; }; + try { throw 1; } catch ({message: css}) { css(); } + for (let css of []) { css(); } + for (let css in {}) { css(); } + for (let css = 0; css < 1; css++) {} + switch (1) { case 1: { let css = 0; } } + export const result = css({color:'red'});`) + expect( + compiledFacts(ast).references.map((reference) => reference.ids), + ).toEqual([['css']]) +}) + +it('ignores data and empty public AST values rather than treating strings as imports', () => { + expect(compiledFacts(undefined).references).toEqual([]) + expect( + compiledFacts( + parseAst(`export default "import {Box} from '@devup-ui/react'"`), + ).references, + ).toEqual([]) +}) diff --git a/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..3a50e49c2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { getCss, resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build, type Plugin } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const compiler: { + compile( + input: { readonly value: string; readonly path: string }, + options: { readonly format: 'mdx' }, + ): Promise<{ readonly value: string }> +} = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) +const devup = landing.resolve('@devup-ui/react') + +async function bundle( + source: string, + options: { + extension?: string + selected?: boolean + compilerFirst?: boolean + } = {}, +) { + resetBuildState() + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-vite-')) + const extension = options.extension ?? '.mdown' + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'ordinary.js'), + 'export function Box(){return "div"}', + ) + const mdx: Plugin = { + name: 'project-mdx', + async transform(code, id) { + if (id === filename.replaceAll('\\', '/')) + return { + code: ( + await compiler.compile({ value: code, path: id }, { format: 'mdx' }) + ).value, + map: null, + } + }, + } + const plugins = DevupUI({ + singleCss: true, + mdxExtensions: options.selected ? ['.mdx', '.mdown'] : undefined, + }) + try { + await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { alias: { '@devup-ui/react': devup } }, + plugins: options.compilerFirst ? [mdx, plugins] : [plugins, mdx], + build: { + write: false, + minify: false, + rolldownOptions: { + input: filename, + external: ['react', 'react/jsx-runtime'], + }, + }, + }) + return getCss(null, false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each([false, true])( + 'extracts custom MDX with equal CSS when compilerFirst=%s', + async (compilerFirst) => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, { + extension: '.mdx', + selected: true, + compilerFirst, + }) + expect(expected).toContain('red') + expect(await bundle(source, { selected: true, compilerFirst })).toBe( + expected, + ) + }, +) + +it.each([ + ['used', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'member', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'forwarded Widget', + 'import {Widget} from "./barrel.js"\n\n', + true, + ], + ['ordinary Box', 'import {Box} from "./ordinary.js"\n\n', false], + [ + 'member executable', + 'import {css} from "@devup-ui/react"\n\nexport const n=css({color:"red"}).length', + true, + ], + [ + 'parameter executable', + 'import {css} from "@devup-ui/react"\n\nexport function f({x=css({color:"red"})}={}){return x}', + true, + ], + [ + 'pattern executable', + 'import {css} from "@devup-ui/react"\n\nexport const {x=css({color:"red"})}={}', + true, + ], + [ + 'computed destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {["css"]:style}=DU', + true, + ], + [ + 'require member', + 'export const style=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const style=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:style}=require("@devup-ui/react")\n\nexport const n=style({color:"red"})', + true, + ], + [ + 'require runtime', + 'export const theme=require("@devup-ui/react").getTheme', + false, + ], + [ + 'require opaque', + 'export const namespace=require("@devup-ui/react")', + false, + ], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const namespace=import("@devup-ui/react")', false], +])( + 'guards only definitely used compiled exports when %s', + async (_name, source, errors) => { + const action = bundle(source) + if (errors) await expect(action).rejects.toThrow('mdxExtensions') + else expect(await action).not.toContain('{') + }, +) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 4e14b5fd8..0ba21d75f 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,7 +14,14 @@ import { spyOn, } from 'bun:test' -import { DevupUI } from '../plugin' +import { DevupUI as createDevupUI } from '../plugin' + +const pluginInstances = new Set[0]>() +function DevupUI(options?: Parameters[0]) { + const plugin = createDevupUI(options) + pluginInstances.add(plugin[0]) + return plugin +} type CodeExtractResult = ReturnType interface ConfigHookMeta { @@ -48,10 +55,18 @@ interface HotUpdateEnvironment { } interface ViteTestPlugin { + environment?: { + name: string + config: { + consumer: 'client' | 'server' + build?: { write?: boolean } + } + } name: string sharedDuringBuild: true enforce: 'pre' apply: () => boolean + closeBundle: () => void config: ( this: { meta?: ConfigHookMeta } | void, userConfig?: ViteConfig, @@ -59,7 +74,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string - plugins?: object[] + plugins?: readonly object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -80,6 +95,12 @@ interface ViteTestPlugin { load: (id: string) => string | undefined transform: ( this: { + getCombinedSourcemap?: () => { + version: number + sources: string[] + names: string[] + mappings: string + } environment?: { name: string config: { @@ -146,7 +167,10 @@ function createPlugins( } function createPlugin(options?: Parameters[0]): ViteTestPlugin { - return createPlugins(options)[0] + const plugin = createPlugins(options)[0] as unknown as ViteTestPlugin & { + transform: { handler: ViteTestPlugin['transform'] } + } + return { ...plugin, transform: plugin.transform.handler } } const ROLLUP_META: ConfigHookMeta = { @@ -186,6 +210,8 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let loadConfigSpy: ReturnType +let graphSpy: ReturnType beforeEach(() => { existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) @@ -206,9 +232,25 @@ beforeEach(() => { registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) setPrefixSpy = spyOn(wasm, 'setPrefix').mockReturnValue(undefined) + loadConfigSpy = spyOn(pluginUtils, 'loadDevupConfig').mockImplementation( + async (file: string) => { + if (!fs.existsSync(file)) return {} + return JSON.parse(await fsPromises.readFile(file, 'utf-8')) + }, + ) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const plugin of pluginInstances) plugin.closeBundle() + pluginInstances.clear() existsSyncSpy.mockRestore() mkdirSpy.mockRestore() readFileSpy.mockRestore() @@ -221,16 +263,51 @@ afterEach(() => { registerThemeSpy.mockRestore() setDebugSpy.mockRestore() setPrefixSpy.mockRestore() + loadConfigSpy.mockRestore() + graphSpy.mockRestore() }) describe('devupUIVitePlugin', () => { console.error = mock() + it('remaps compiled MDX extraction failures using the compiler map', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const plugin = DevupUI()[2] as unknown as Pick + + await expect( + plugin.transform.call( + { + getCombinedSourcemap: () => ({ + version: 3, + sources: ['authored.mdx'], + names: [], + mappings: 'AAGE', + }), + }, + 'compiled', + 'page.mdx', + ), + ).rejects.toThrow('authored.mdx:4:3') + }) + + it('propagates non-MDX extraction failures without remapping', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('source failure') + }) + + await expect(createPlugin().transform('code', 'page.ts')).rejects.toThrow( + 'source failure', + ) + }) it('should apply default options', () => { const plugin = createPlugin({}) expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), + buildStart: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -241,6 +318,7 @@ describe('devupUIVitePlugin', () => { apply: expect.any(Function), generateBundle: expect.any(Function), configResolved: expect.any(Function), + configureServer: expect.any(Function), resolveId: expect.any(Function), }) expect(plugin.apply()).toBe(true) @@ -451,20 +529,19 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + const collectNumberedFiles = pluginUtils.collectNumberedFiles + const seedFileMap = wasm.seedFileMap + let collectSpy: ReturnType + let seedFileMapSpy: ReturnType beforeEach(() => { - listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue('{}') + collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + seedFileMapSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) }) afterEach(() => { - listSourceFilesSpy.mockRestore() - importFileMapSpy.mockRestore() - exportFileMapSpy.mockRestore() + collectSpy.mockRestore() + seedFileMapSpy.mockRestore() }) function onlyDirs(...dirs: string[]) { @@ -472,90 +549,181 @@ describe('devupUIVitePlugin', () => { existsSyncSpy.mockImplementation((path: string) => wanted.has(path)) } - it('numbers files by sorted path, not by transform arrival order', async () => { - onlyDirs('src') - // returned out of order on purpose: arrival order must not leak through - listSourceFilesSpy.mockReturnValue([ - '/p/src/z.tsx', - '/p/src/a.tsx', - '/p/src/m.tsx', - ]) + it('numbers the files the scan finds, in the order it returns them', async () => { + onlyDirs('src', 'app') + collectSpy.mockReturnValue(['/p/app/page.tsx', '/p/src/b.tsx']) - await createPlugin({}).configResolved({ root: '/p' }) + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + includeMdx: ['.mdx'], + include: ['@acme/ui'], + cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - it('normalizes windows separators to match vite module ids', async () => { + // A framework plugin resolves the config once per environment. Seeding + // keeps the numbers files hold, so a second pass numbers only new files. + it('seeds again on a second configResolved', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + collectSpy.mockReturnValue(['/p/src/a.tsx']) + const plugin = createPlugin({}) - await createPlugin({}).configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) - expect(importFileMapSpy).toHaveBeenCalledWith({ 'C:/p/src/a.tsx': 0 }) + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) }) - // A framework plugin resolves the config once per environment. Re-seeding - // drops the numbers already given to files outside src/ and app/, and since - // the sheet does not reset with the map, the next such file reuses a live - // number and its atoms overwrite the previous owner's. - it('leaves an already-populated map alone on a second configResolved', async () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) - + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) await createPlugin({}).configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) + + it('keeps building when the scan fails', async () => { + onlyDirs('src') + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) - expect(importFileMapSpy).not.toHaveBeenCalled() + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const plugin = createPlugin({}) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledTimes(1) + expect(warning.mock.calls[0]?.[1]).toMatchObject({ + phase: 'seed', + root: '/p', + cause: expect.any(Error), + }) + } finally { + warning.mockRestore() + } }) - it('scans app/ for App Router projects and dedupes across roots', async () => { - onlyDirs('src', 'app') - listSourceFilesSpy.mockImplementation((dir: string) => - dir === resolve('/p', 'app') - ? ['/p/app/page.tsx', '/p/shared.tsx'] - : ['/p/src/b.tsx', '/p/shared.tsx'], - ) + describe('source collection before seeding', () => { + let listSourceFilesSpy: ReturnType + let readFileSyncSpy: ReturnType - await createPlugin({}).configResolved({ root: '/p' }) + beforeEach(() => { + collectSpy.mockImplementation(collectNumberedFiles) + listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( + "import { Box } from '@devup-ui/react'", + ) + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, + afterEach(() => { + listSourceFilesSpy.mockRestore() + readFileSyncSpy.mockRestore() }) - }) - it.each([ - ['no conventional source dir exists', () => onlyDirs()], - [ - 'the source dir is empty', - () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue([]) - }, - ], - ])('leaves numbering alone when %s', async (_name, setup) => { - setup() - await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() - }) + it('numbers files by sorted path, not by transform arrival order', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([ + '/p/src/z.tsx', + '/p/src/a.tsx', + '/p/src/m.tsx', + ]) - it('keeps building when the scan fails', async () => { - onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { - throw new Error('scan boom') + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/src/a.tsx', + '/p/src/m.tsx', + '/p/src/z.tsx', + ]) }) - await createPlugin({}).configResolved({ root: '/p' }) + it('normalizes windows separators to match vite module ids', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith(['C:/p/src/a.tsx']) + }) + + it('leaves an already-populated map alone on a second configResolved', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) + const previousMap = wasm.exportFileMap() + const existingMap = { + '/p/src/a.tsx': 0, + '/monorepo/packages/ui/X.tsx': 1, + } + seedFileMapSpy.mockImplementation(seedFileMap) + const plugin = createPlugin({}) + wasm.importFileMap(existingMap) + try { + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) + expect(JSON.parse(wasm.exportFileMap())).toEqual(existingMap) + } finally { + wasm.importFileMap(JSON.parse(previousMap)) + } + }) + + it('scans app/ for App Router projects and dedupes across roots', async () => { + onlyDirs('src', 'app') + listSourceFilesSpy.mockImplementation((dir: string) => + dir === resolve('/p', 'app') + ? ['/p/app/page.tsx', '/p/shared.tsx'] + : ['/p/src/b.tsx', '/p/shared.tsx'], + ) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'src'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'app'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/shared.tsx', + '/p/src/b.tsx', + ]) + }) + + it.each([ + ['no conventional source dir exists', () => onlyDirs()], + [ + 'the source dir is empty', + () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([]) + }, + ], + ])('leaves numbering alone when %s', async (_name, setup) => { + setup() + + await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) }) }) @@ -706,6 +874,23 @@ describe('devupUIVitePlugin', () => { ) }) + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue( + undefined, + ) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } + }) it.each([ { label: 'per-file css', @@ -894,6 +1079,146 @@ describe('devupUIVitePlugin', () => { expect(serverBundle['base.css'].fileName).toBe('base.css') }) + + it('does not forward server css that the client already emits', async () => { + const [plugin, restore] = createPlugins({}) + const base = asset('base.css', 'devup-ui.css') + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', [ + 'base.css', + 'file.css', + 'server-only.css', + ]) + const serverBundle: Bundle = { + 'base.css': base, + 'file.css': file, + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'entry.js': entry, + } + const clientBase = asset('base.css', 'devup-ui.css') + const clientFile = asset('file.css', 'devup-ui-3.css') + const clientBundle: Bundle = { + 'base.css': clientBase, + 'file.css': clientFile, + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + + expect(base.source).toEqual('base sheet') + expect(file.source).toEqual('file sheet') + expect(clientBase.source).toEqual('base sheet') + expect(clientFile.source).toEqual('file sheet') + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + expect(forwarded.emitted).toEqual([ + 'base.css.devup-forwarded', + 'file.css.devup-forwarded', + 'server-only.css', + ]) + for (const name of forwarded.emitted) { + clientBundle[name] = asset(name, name) + } + restore.generateBundle.handler({}, clientBundle) + + expect(base.fileName).toBe('base.css') + expect(file.fileName).toBe('file.css') + expect(clientBundle['base.css']).toBe(clientBase) + expect(clientBundle['file.css']).toBe(clientFile) + expect(Object.keys(clientBundle).sort()).toEqual([ + 'base.css', + 'file.css', + 'server-only.css', + ]) + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + }) + + it('ignores no-write analysis bundles when tracking server css', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['file.css']) + const serverBundle: Bundle = { + 'file.css': file, + 'entry.js': entry, + } + await plugin.configResolved(rscConfig({})) + await plugin.generateBundle.call( + { + environment: { + name: 'rsc', + config: { consumer: 'server', build: { write: false } }, + }, + }, + {}, + serverBundle, + ) + const clientBundle: Bundle = { + 'file.css': asset('file.css', 'devup-ui-3.css'), + } + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual(new Set(['file.css'])) + expect(file.fileName).toBe('file.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle)).toEqual(['file.css']) + }) + + it('keeps server forwarding for a different output file name', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('devup-ui-3.server.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['devup-ui-3.server.css']) + const serverBundle: Bundle = { + 'devup-ui-3.server.css': file, + 'entry.js': entry, + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + for (const name of forwarded.emitted) clientBundle[name] = file + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['devup-ui-3.server.css']), + ) + expect(forwarded.emitted).toEqual(['devup-ui-3.server.css']) + expect(file.fileName).toBe('devup-ui-3.server.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle).sort()).toEqual([ + 'devup-ui-3.client.css', + 'devup-ui-3.server.css', + ]) + }) + }) + + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } }) it('resolves a stable id during build', async () => { @@ -951,8 +1276,8 @@ describe('devupUIVitePlugin', () => { getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -963,7 +1288,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({ singleCss: options.singleCss }) await plugin.configResolved() if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -974,7 +1299,7 @@ describe('devupUIVitePlugin', () => { 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -994,7 +1319,7 @@ describe('devupUIVitePlugin', () => { } }) - it('should reset data files when load error', async () => { + it('propagates config load errors without clearing the registered theme', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockReturnValue(true) @@ -1002,10 +1327,10 @@ describe('devupUIVitePlugin', () => { throw new Error('error') }) const plugin = createPlugin({}) - await plugin.configResolved() - expect(registerThemeSpy).toHaveBeenCalledWith({}) + await expect(plugin.configResolved()).rejects.toThrow('error') + expect(registerThemeSpy).not.toHaveBeenCalled() expect(writeFileSpy).toHaveBeenCalledWith( - join('df', '.gitignore'), + resolve('df', '.gitignore'), '*', 'utf-8', ) @@ -1019,7 +1344,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({}) await plugin.watchChange('devup.json') expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1048,7 +1373,7 @@ describe('devupUIVitePlugin', () => { }) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1080,7 +1405,7 @@ describe('devupUIVitePlugin', () => { expect(result).toBeUndefined() expect(writeFileSpy).not.toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), expect.any(String), 'utf-8', ) @@ -1111,7 +1436,7 @@ describe('devupUIVitePlugin', () => { ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1201,7 +1526,10 @@ describe('devupUIVitePlugin', () => { }) const plugin = createPlugin({}) await plugin.watchChange('devup.json') - expect(console.error).toHaveBeenCalledWith(expect.any(Error)) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(resolve('devup.json')), + expect.any(Error), + ) }) it('should load', () => { @@ -1326,7 +1654,7 @@ describe('devupUIVitePlugin', () => { { const plugin = createPlugin({ - cssDir: '', + cssDir: '.', }) expect(plugin.resolveId('devup-ui.css')).toEqual(expect.any(String)) } @@ -1573,7 +1901,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', dir) }), + expect.objectContaining({ srcDir: [resolve('/p', dir)] }), ) }) @@ -1587,7 +1915,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', 'src') }), + expect.objectContaining({ srcDir: [] }), ) }) @@ -1639,11 +1967,13 @@ describe('devupUIVitePlugin atom hoisting', () => { expect(setAtomHoistSpy).toHaveBeenCalledWith(2) }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom setup with root and cause', async () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + await expect( + runConfigResolved({ atomHoist: 2 }, { root: '/p' }), + ).rejects.toThrow(/atom graph setup failed.*boom/) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) diff --git a/packages/vite-plugin/src/__tests__/resolution-cold.mjs b/packages/vite-plugin/src/__tests__/resolution-cold.mjs new file mode 100644 index 000000000..82be99235 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-cold.mjs @@ -0,0 +1,35 @@ +import { readdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const { build } = await import(pathToFileURL(require.resolve('vite')).href) +const { DevupUI } = await import('../../dist/index.mjs') +const root = process.argv[2] +await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + outDir: 'cold', + lib: { + entry: join(root, 'src/main.js'), + fileName: 'main', + formats: ['es'], + }, + minify: false, + }, +}) +const files = await readdir(join(root, 'cold')) +const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) +).join('\n') +if (!/background:\s*blue/.test(css) || /background:\s*red/.test(css)) + throw new Error(`Invalid cold CSS: ${css}`) +console.info(css) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts new file mode 100644 index 000000000..5eac84ac2 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('retains client missing-input watches when SSR transforms the same importer later', async () => { + // Given different condition branches for one importer in two real environments. + resetBuildState() + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-input-environments-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const candidate = join(palette, 'browser/entry.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(join(palette, 'browser'), { recursive: true }) + await mkdir(join(palette, 'node'), { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile( + join(palette, 'package.json'), + '{"name":"palette","exports":{"browser":"./browser/entry","node":"./node/entry.js"}}', + ) + await writeFile( + join(palette, 'browser/entry.js'), + "export const color='red'", + ) + await writeFile( + join(palette, 'node/entry.js'), + "export const color='green'", + ) + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'environment-watch-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await server.environments.ssr.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When a client-only missing candidate is created after SSR's distinct observations. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No client candidate add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then client cache invalidation survived the later SSR extraction. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..63b82be89 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,201 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('refreshes the selected CSS class when linked workspace exports alone change', async () => { + resetBuildState() + // Given a monorepo palette outside node_modules and two already existing entries. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-resolution-watch-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const manifest = join(palette, 'package.json') + let server: ViteDevServer | undefined + let ready: Promise | undefined + const observed = new Set() + let changed: (() => void) | undefined + const update = new Promise((done) => { + changed = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + const configuration = () => ({ + root, + configFile: false as const, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'resolution-watch-observation', + configureServer(current: ViteDevServer) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + }, + hotUpdate({ file }: { readonly file: string }) { + observed.add(file) + if (file === manifest.replaceAll('\\', '/')) changed?.() + }, + }, + ], + }) + server = await createServer(configuration()) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the only mutation switches the package exports target. + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + await Promise.race([ + update, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No manifest hotUpdate')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the new class selects blue; retained earlier atoms do not define its effective style. + expect(observed).toContain(manifest.replaceAll('\\', '/')) + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect(selected).toBeDefined() + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + await server.close() + server = undefined + const cold = Bun.spawnSync( + ['node', join(import.meta.dir, 'resolution-cold.mjs'), root], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ + exitCode: cold.exitCode, + error: cold.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(cold.stdout.toString()).toMatch(/background:\s*blue/) + expect(cold.stdout.toString()).not.toMatch(/background:\s*red/) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) + +it('refreshes an importer when a missing earlier paths candidate outside the root is created', async () => { + resetBuildState() + // Given an existing fallback and a missing candidate outside the default root watch. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-missing-input-')), + ) + const root = join(fixture, 'apps/app') + const outside = join(fixture, 'packages/alternate') + const candidate = join(outside, 'color.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile( + join(root, 'tsconfig.json'), + '{"compilerOptions":{"allowJs":true,"paths":{"palette":["../../packages/alternate/color.ts","src/red.ts"]}}}', + ) + await writeFile(join(root, 'src/red.ts'), "export const color='red'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + resolve: { tsconfigPaths: true }, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'missing-input-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the sole mutation creates the previously missing higher-priority candidate. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('Missing outside-root add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the cached importer is invalidated by public watch transport and selects blue. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/aggregate-css.ts b/packages/vite-plugin/src/aggregate-css.ts new file mode 100644 index 000000000..311b8da7a --- /dev/null +++ b/packages/vite-plugin/src/aggregate-css.ts @@ -0,0 +1,252 @@ +import type { BuildOptions } from 'vite' + +import { inspectImports } from './aggregate-imports' + +interface ClosureModule { + readonly code: string | null + readonly importedIds: readonly string[] + readonly dynamicallyImportedIds: readonly string[] + readonly isExternal?: boolean + readonly isEntry?: boolean +} + +export interface CssClosureContext { + getModuleIds(): IterableIterator + getModuleInfo(id: string): ClosureModule | null + load(options: { + id: string + resolveDependencies: boolean + }): Promise + resolve( + id: string, + importer?: string, + ): Promise<{ + readonly id: string + readonly external: boolean | string + } | null> + parse(code: string): unknown +} + +interface Preparation { + readonly entries: readonly string[] + promise?: Promise + completed?: { readonly ids: ReadonlySet; readonly asset: string } +} + +export class AggregateCssError extends Error { + readonly file: string + readonly line: number + readonly column: number + + constructor( + location: + | string + | { + readonly file: string + readonly line: number + readonly column: number + }, + readonly asset: string, + reason: string, + ) { + const { file, line, column } = + typeof location === 'string' + ? { file: location, line: 1, column: 1 } + : location + super( + `${file}:${line}:${column}: [devup-ui] aggregate CSS asset ${asset} cannot be completed at build time: ${reason}; use an application build with build.lib=false and build.cssCodeSplit=true, or precompile the responsible module and publish its CSS as a separate imported asset`, + ) + this.file = file + this.line = line + this.column = column + this.name = 'AggregateCssError' + } +} + +// Vite processes these requests as stylesheets, not JS graph vertices. Awaiting +// their loads would wait on this very barrier (including CSS @import cycles). +const CSS_REQUEST = + /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i + +export function createAggregateCssPreparation( + deadline: (expire: () => void) => () => void = (expire) => { + const timer = setTimeout(expire, 30_000) + return () => clearTimeout(timer) + }, +) { + const environments = new WeakMap() + return { + start( + environment: object, + input: string | readonly string[] | Record | undefined, + build: Pick, + ) { + environments.delete(environment) + if (!build.lib && build.cssCodeSplit !== false) return + const entries = + typeof input === 'string' ? [input] : input ? Object.values(input) : [] + environments.set(environment, { entries }) + }, + prepare(environment: object, context: CssClosureContext, asset: string) { + const state = environments.get(environment) + if (!state) return + state.promise ??= (async () => { + const visited = new Set() + const entries = new Set(state.entries) + for (const id of context.getModuleIds()) { + if (context.getModuleInfo(id)?.isEntry) entries.add(id) + } + async function resolveModule(id: string, importer?: string) { + try { + return await context.resolve(id, importer) + } catch (cause) { + throw new AggregateCssError( + importer ?? id, + asset, + `public resolution refused ${id}: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + } + async function walk(id: string, importer: string): Promise { + if (visited.has(id)) return + visited.add(id) + const known = context.getModuleInfo(id) + if (known?.isExternal) return + if (!known || known.code === null) { + const resolved = await resolveModule(id, importer) + if (!resolved && !known) + throw new AggregateCssError( + importer, + asset, + `public resolution refused ${id}`, + ) + if (resolved?.external) return + if (resolved && resolved.id !== id) { + if (visited.has(resolved.id)) + throw new AggregateCssError( + importer, + asset, + `public resolution formed an ID cycle at ${id}`, + ) + return walk(resolved.id, importer) + } + } + if (CSS_REQUEST.test(id)) return + let code = '' + function fail(reason: string, position?: number): never { + const lines = code.slice(0, position).split('\n') + const location = + position === undefined + ? id + : { + file: id, + line: lines.length, + column: (lines.at(-1)?.length ?? 0) + 1, + } + throw new AggregateCssError(location, asset, reason) + } + let info: ClosureModule | null + try { + info = await new Promise( + (resolve, reject) => { + const cancel = deadline(() => + reject( + new AggregateCssError( + id, + asset, + 'public load did not settle within 30 seconds; a source transform awaiting generated CSS creates a preparation cycle', + ), + ), + ) + Promise.resolve() + .then(() => context.load({ id, resolveDependencies: false })) + .then(resolve, reject) + .finally(cancel) + }, + ) + } catch (cause) { + fail( + `public load refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if ( + !info || + typeof info.code !== 'string' || + !Array.isArray(info.importedIds) || + !Array.isArray(info.dynamicallyImportedIds) + ) { + fail( + 'public load returned null or incomplete transformed module information', + ) + } + const dependencies = [ + ...info.importedIds, + ...info.dynamicallyImportedIds, + ] + code = info.code + let hasImports: boolean + try { + const ast = context.parse(code) + if ( + !ast || + typeof ast !== 'object' || + !('type' in ast) || + ast.type !== 'Program' + ) + fail('public parser returned incomplete module information') + hasImports = inspectImports(ast, fail) + } catch (cause) { + if (cause instanceof AggregateCssError) throw cause + fail( + `public parser refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if (hasImports && dependencies.length === 0) + fail('public load returned an unresolved import graph') + for (const dependency of dependencies) await walk(dependency, id) + } + if (entries.size === 0) + throw new AggregateCssError( + asset, + asset, + 'the public build input list is empty', + ) + for (const entry of entries) { + const resolved = await resolveModule(entry) + if (!resolved || resolved.external) + throw new AggregateCssError( + entry, + asset, + 'the public build entry could not be resolved to an internal module', + ) + await walk(resolved.id, entry) + } + state.completed = { ids: visited, asset } + })() + return state.promise + }, + observe(environment: object, id: string) { + const completed = environments.get(environment)?.completed + if (completed && !completed.ids.has(id)) { + throw new AggregateCssError( + id, + completed.asset, + 'a plugin introduced a module after the aggregate closure completed; expose its entry/imports before the first generated CSS load', + ) + } + }, + checkAsset( + environment: object, + asset: { readonly name?: string; readonly fileName: string }, + ) { + const state = environments.get(environment) + if (state && /^devup-ui(-\d+)?\.css$/.test(asset.name ?? '')) { + throw new AggregateCssError( + state.entries[0] ?? asset.fileName, + asset.fileName, + 'the native split library emitted a standalone generated stylesheet that the shared-sheet output contract rewrites after optimization; use build.cssCodeSplit=false for this library', + ) + } + }, + } +} diff --git a/packages/vite-plugin/src/aggregate-imports.ts b/packages/vite-plugin/src/aggregate-imports.ts new file mode 100644 index 000000000..562eccfb3 --- /dev/null +++ b/packages/vite-plugin/src/aggregate-imports.ts @@ -0,0 +1,47 @@ +export function inspectImports( + ast: unknown, + fail: (reason: string, position?: number) => never, +): boolean { + if (!ast || typeof ast !== 'object') return false + if (Array.isArray(ast)) + return ast.map((node) => inspectImports(node, fail)).some(Boolean) + let imports = false + if ('type' in ast && ast.type === 'ImportExpression') { + const source = 'source' in ast ? ast.source : undefined + if (!source || typeof source !== 'object' || !('type' in source)) { + fail('public parser returned an incomplete dynamic import') + } + if ( + !( + source.type === 'Literal' && + 'value' in source && + typeof source.value === 'string' + ) && + !( + source.type === 'TemplateLiteral' && + 'expressions' in source && + Array.isArray(source.expressions) && + source.expressions.length === 0 + ) + ) { + fail( + 'computed dynamic import has no knowable resolved closure; replace it with literal imports or import.meta.glob', + 'start' in ast && typeof ast.start === 'number' ? ast.start : undefined, + ) + } + imports = true + } + if ( + 'type' in ast && + (ast.type === 'ImportDeclaration' || + ast.type === 'ExportAllDeclaration' || + ast.type === 'ExportNamedDeclaration') && + 'source' in ast && + ast.source + ) + imports = true + for (const value of Object.values(ast)) { + if (inspectImports(value, fail)) imports = true + } + return imports +} diff --git a/packages/vite-plugin/src/compiled-guard.ts b/packages/vite-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..d2cdfc4c4 --- /dev/null +++ b/packages/vite-plugin/src/compiled-guard.ts @@ -0,0 +1,140 @@ +import { + compiledFacts, + createCompileTimeClassifier, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import type { Plugin } from 'vite' + +export function createCompiledGuard(options: { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases + readonly extractCss: boolean +}): Plugin { + type Module = { + readonly facts: GuardFacts + readonly code: string + readonly targets: ReadonlyMap + } + const environments = new Map>() + const classify = createCompileTimeClassifier(options.package) + const targetKey = (ref: { + readonly request: string + readonly kind?: 'require-call' + }) => JSON.stringify([ref.request, ref.kind]) + function environmentModules(environment: object) { + let modules = environments.get(environment) + if (!modules) { + modules = new Map() + environments.set(environment, modules) + } + return modules + } + const plugin: Plugin = { + name: 'devup-ui:compiled-source-guard', + sharedDuringBuild: true, + apply: 'build', + buildStart() { + environmentModules(this.environment ?? plugin).clear() + }, + async moduleParsed(info) { + if (!options.extractCss || !info.code) return + const facts = compiledFacts(this.parse(info.code)) + const requests = new Map< + string, + { readonly request: string; readonly kind?: 'require-call' } + >( + [ + ...facts.references, + ...[...facts.exports.values()].flatMap((ref) => (ref ? [ref] : [])), + ...facts.stars.map((request) => ({ request })), + ].map((ref) => [targetKey(ref), ref]), + ) + const targets = new Map() + for (const [key, ref] of requests) { + const target = await this.resolve(ref.request, info.id, { + kind: ref.kind ?? 'import-statement', + }) + if (target && info.importedIds.includes(target.id)) + targets.set(key, target.id) + } + environmentModules(this.environment ?? plugin).set(info.id, { + facts, + code: info.code, + targets, + }) + }, + buildEnd(error) { + if (error || !options.extractCss) return + const modules = environmentModules(this.environment ?? plugin) + function origins( + id: string, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + const key = JSON.stringify([id, ids]) + if (seen.has(key)) return new Map() + if (classify(id, ids)) return new Map([[key, true]]) + const module = modules.get(id) + if (!module || !ids.length) return new Map() + const next = new Set([...seen, key]) + const direct = module.facts.exports.get(ids[0]) + if (module.facts.exports.has(ids[0])) { + const target = direct && module.targets.get(targetKey(direct)) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + return new Map( + (ids[0] === 'default' ? [] : module.facts.stars).flatMap( + (request) => { + const target = module.targets.get(targetKey({ request })) + return target ? [...origins(target, ids, next)] : [] + }, + ), + ) + } + for (const [id, module] of modules) { + if ( + id.startsWith('\0') || + isSelectedSource(id.split('?')[0], options.mdxExtensions) + ) + continue + for (const ref of module.facts.references) { + const target = module.targets.get(targetKey(ref)) + const targets = target + ? origins(target, ref.ids) + : new Map() + if ( + !isCompileTimeAlias( + { ...ref, line: 1, column: 1 }, + options.importAliases, + ) && + (targets.size !== 1 || ![...targets.values()][0]) + ) + continue + const prefix = module.code.slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + this.error({ + id, + pos: ref.pos, + message: new UntransformedSourceError( + id, + reference, + isCompileTimeAlias(reference, options.importAliases), + ).message, + }) + } + } + }, + } + return plugin +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 91e585045..df579c8fa 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,31 +3,45 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, - listSourceFiles, + isMdxSource, + isSelectedSource, loadDevupConfig, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, getCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -37,11 +51,14 @@ import type { EnvironmentModuleNode, ModuleNode, Plugin, - PluginOption, ResolvedConfig, UserConfig, } from 'vite' +import { createAggregateCssPreparation } from './aggregate-css' +import { createCompiledGuard } from './compiled-guard' +import { createMissingInputWatch } from './resolution-watch' + /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... * @@ -51,59 +68,6 @@ import type { */ const DEVUP_CSS_FILE_RE = /^devup-ui(-\d+)?\.css$/ -const SOURCE_DIR_CANDIDATES = ['src', 'app'] - -/** - * Source roots to scan, or an empty list when none of the conventional layouts - * are present. Scanning the project root instead would sweep in config files - * and other never-transformed modules, so callers skip the scan entirely rather - * than guess. - */ -function resolveSourceDirs(root: string): string[] { - return SOURCE_DIR_CANDIDATES.map((dir) => resolve(root, dir)).filter((dir) => - existsSync(dir), - ) -} - -/** - * Assigns each source file its devup file number up front, ordered by path. - * - * The engine otherwise hands numbers out on first sight, and the bundler - * transforms in parallel, so two identical builds produce different per-file - * class prefixes and therefore different CSS *and* JS asset hashes. Seeding - * from a sorted scan makes the numbering a pure function of the file paths. - * Every source file now holds a slot, where before only the ones that emitted - * styles consumed a number. Prefix length is a step function of the highest - * number handed out (1 char up to 26, 2 up to 1025, 3 beyond), so this is free - * until a project passes 1026 files under the scanned roots, at which point - * prefixes that used to be 2 chars become 3. - * - * Vite reports module ids as absolute POSIX-style paths even on Windows, so the - * scanned paths are normalized to match the keys `codeExtract` will look up. - * - * `importFileMap` REPLACES the engine's map, and a framework plugin resolves the - * config once per environment, so seeding unconditionally would wipe the numbers - * already handed to files outside `sourceDirs`: a monorepo sibling, or anything - * reached through `include`. The style sheet does not reset with the map, so the - * next such file reuses a live number and its atoms overwrite the previous - * owner's. Seeding only into an empty map keeps numbering deterministic on the - * first pass and stable for every later one. - */ -function seedFileMap(sourceDirs: string[]): void { - if (Object.keys(JSON.parse(exportFileMap())).length > 0) return - const sorted = [ - ...new Set( - sourceDirs - .flatMap((dir) => listSourceFiles(dir)) - .map((file) => file.replaceAll('\\', '/')), - ), - ].sort() - if (sorted.length === 0) return - const fileMap: Record = {} - for (const [index, file] of sorted.entries()) fileMap[file] = index - importFileMap(fileMap) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -274,6 +238,8 @@ export interface DevupUIPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -293,26 +259,15 @@ export interface DevupUIPluginOptions { async function writeDataFiles( options: Omit, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') // Sequential: writing into cssDir concurrently with its own mkdir loses the // race on a cold start (no `df/`) and fails the build with ENOENT. if (!existsSync(options.cssDir)) { @@ -327,133 +282,346 @@ export function DevupUI({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, extractCss = true, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, -}: Partial = {}): PluginOption { + mdxExtensions: configuredMdxExtensions, +}: Partial = {}) { + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) + // A build starts from its own options: whatever an earlier build in this + // process left in the engine (prefix, hoisting, routes, buckets, numbers, + // styles) is gone unless another build is still running. + const endBuild = beginBuild({ resetBuildState }) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) + const excludeModules = createNodeModulesExcludeRegex(include) + const pathOptions = { devupFile, distDir, cssDir: configuredCssDir } + let projectRoot = process.cwd() + let cssDir = configuredCssDir ?? join(distDir, 'devup-ui') + let pathsResolved = false + function resolveFallbackPaths() { + if (!pathsResolved) { + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + } + } const cssMap = new Map() + const aggregateCss = createAggregateCssPreparation() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling // plugin once @vitejs/plugin-rsc has forwarded. let restoreForwardedCss: ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false + let isProduction = false + let seedWarningEmitted = false + const setupWatchFiles = new Set() + const missingWatchContexts = new Map void>() + const missingInputWatch = createMissingInputWatch() + let fallbackConditions: readonly string[] = [ + 'import', + 'module', + 'require', + 'node', + ] + const observeSetup: ResolutionInputObserver = (inputs) => { + for (const path of [ + ...inputs.fileDependencies, + ...inputs.missingDependencies, + ]) + setupWatchFiles.add(path) + } + function moduleResolver( + conditions: readonly string[], + onResolutionInputs: ResolutionInputObserver = observeSetup, + ) { + return createModuleResolver({ + cwd: projectRoot, + includeMdx: mdxExtensions, + conditions, + onResolutionInputs, + toId: (path) => path.replaceAll('\\', '/'), + }) + } // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) - return writeFile(join(cssDir, fileName), css, 'utf-8') + return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } - const plugin: Plugin = { + const sourceTransform = { + async transform(code, id) { + if (!extractCss) return + resolveFallbackPaths() + const fileName = id.split('?')[0] + if (excludeModules.test(fileName)) return + const environment = this.environment ?? plugin + const preserveSymlinks = + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks + missingInputWatch.start(fileName, environment) + aggregateCss.observe(this.environment ?? plugin, id) + const environmentConditions = this.environment?.config.resolve.conditions + setModuleResolver( + moduleResolver( + (environmentConditions + ? ['import', ...environmentConditions] + : fallbackConditions + ).map((condition) => + condition === 'development|production' + ? isProduction + ? 'production' + : 'development' + : condition, + ), + (inputs) => { + for (const path of inputs.fileDependencies) + this.addWatchFile( + resolutionWatchPath(path, preserveSymlinks).replaceAll( + '\\', + '/', + ), + ) + for (const path of inputs.missingDependencies) { + const watched = resolutionWatchPath( + path, + preserveSymlinks, + ).replaceAll('\\', '/') + setupWatchFiles.add(path) + missingInputWatch.observe(fileName, watched, environment) + missingWatchContexts.get(this.environment ?? plugin)?.(watched) + } + }, + ), + ) + let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') + if (!rel.startsWith('./')) rel = `./${rel}` + const { + code: extractedCode, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + fileName, + code, + libPackage, + rel, + singleCss, + true, + false, + importAliases, + ...(isMdxSource(fileName, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), + ) + } catch (error) { + if (isMdxSource(fileName, mdxExtensions)) + throw remapMdxError(error, fileName, this.getCombinedSourcemap()) + throw error + } + })() + for (const dependency of dependencies) this.addWatchFile(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) + promises.push(writeCssFile('devup-ui.css', getCss(null, false))) + if (cssFile) { + const fileNum = getFileNumByFilename(cssFile) + const prevCss = cssMap.get(fileNum) + if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) + if (css) promises.push(writeCssFile(basename(cssFile), css)) + } + await Promise.all(promises) + return { code: extractedCode, map } + }, + } satisfies Pick + const plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, + configureServer(server) { + missingInputWatch.attach(server) + }, async configResolved(config) { resolvedConfig = config - isServe = config?.command === 'serve' - const projectRoot = config?.root ?? process.cwd() - // Vite ids are POSIX absolute paths - setModuleResolver( - createModuleResolver({ - cwd: projectRoot, - toId: (path) => path.replaceAll('\\', '/'), - }), - ) - const sourceDirs = resolveSourceDirs(projectRoot) try { - seedFileMap(sourceDirs) - } catch { - // Best-effort; on failure numbering falls back to arrival order. - } - 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({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with - // single-importer collapse: both are keyed by the canonical bucket. Vite - // passes the ABSOLUTE module id to codeExtract, so the graph maps use - // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - if (atomMode) { + isServe = config?.command === 'serve' + isProduction = config?.isProduction ?? false + projectRoot = config?.root ?? process.cwd() + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + const conditions = [ + 'import', + ...(config?.build?.ssr + ? (config.ssr?.resolve?.conditions ?? [ + 'module', + 'node', + 'development|production', + ]) + : (config?.resolve?.conditions ?? [ + 'module', + 'browser', + 'development|production', + ])), + ].map((condition) => + condition === 'development|production' + ? config?.isProduction + ? 'production' + : 'development' + : condition, + ) + // Vite ids are POSIX absolute paths + fallbackConditions = conditions + setModuleResolver(moduleResolver(conditions)) + const sourceDirs = resolveSourceDirs(projectRoot, configuredSourceDirs) + const input = + config?.build?.rolldownOptions?.input ?? + config?.build?.rollupOptions?.input ?? + (config?.build?.lib && config.build.lib.entry) + const rawEntries = + typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : input && typeof input === 'object' + ? Object.values(input) + : [] + const entries = rawEntries + .filter((entry): entry is string => typeof entry === 'string') + .filter((entry) => isSelectedSource(entry, mdxExtensions)) + .map((entry) => resolve(projectRoot, entry)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((entry) => dirname(entry)), + ]), + ] + 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({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any + // transform so atoms receive global (shared) class names. Composes with + // single-importer collapse: both are keyed by the canonical bucket. Vite + // passes the ABSOLUTE module id to codeExtract, so the graph maps use + // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const root = projectRoot + // App Router projects keep their sources in `app/`, so a hardcoded + // `src/` made the whole pre-pass a silent no-op for them. + const srcDir = roots + const tsconfigPath = resolve(root, 'tsconfig.json') + const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: mdxExtensions, + cwd: root, + include, + conditions, + onResolutionInputs: observeSetup, + }) + + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length > 0 ? entries : undefined, + }) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } catch (cause) { + throw new Error( + `[devup-ui] atom graph setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } try { - const root = projectRoot - // App Router projects keep their sources in `app/`, so a hardcoded - // `src/` made the whole pre-pass a silent no-op for them. - const srcDir = sourceDirs[0] ?? resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - // C: prefer the bundler's real JS entries; fall back to the heuristic - // (files with no importer) when input is html-only / unavailable. - const input = config.build?.rollupOptions?.input - const rawEntries = - typeof input === 'string' - ? [input] - : Array.isArray(input) - ? input - : input && typeof input === 'object' - ? Object.values(input) - : [] - const entries = rawEntries - .filter((e): e is string => typeof e === 'string') - .filter((e) => /\.(tsx|ts|jsx|js|mjs)$/i.test(e)) - .map((e) => resolve(root, e)) - - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - entries: entries.length > 0 ? entries : undefined, - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: mdxExtensions, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: projectRoot, cause }, ) } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). } + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { @@ -476,7 +644,11 @@ export function DevupUI({ noExternal: [...include, /@devup-ui/, /@devup-editor/], }, } - if (extractCss) { + if ( + extractCss && + !userConfig?.build?.lib && + userConfig?.build?.cssCodeSplit !== false + ) { ret.build = createCssChunkBuildOptions(this?.meta, userConfig) } return ret @@ -484,7 +656,32 @@ export function DevupUI({ apply() { return true }, - async watchChange(id) { + closeBundle(this: void) { + missingInputWatch.close() + missingWatchContexts.clear() + endBuild() + }, + buildStart(options) { + missingWatchContexts.set(this.environment ?? plugin, (path) => + this.addWatchFile(path), + ) + for (const path of setupWatchFiles) + this.addWatchFile( + resolutionWatchPath( + path, + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks, + ).replaceAll('\\', '/'), + ) + if (isServe || !extractCss) return + aggregateCss.start( + this.environment ?? plugin, + options.input, + this.environment?.config.build ?? resolvedConfig?.build ?? {}, + ) + }, + async watchChange(this: void, id) { + resolveFallbackPaths() if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { await writeDataFiles({ @@ -495,13 +692,14 @@ export function DevupUI({ singleCss, }) } catch (error) { - console.error(error) + console.error(`[devup-ui] theme update failed at ${devupFile}`, error) } } }, // Runs once per environment. Vite 6+ ignores `handleHotUpdate` on a plugin // that defines this hook, so the devup.json reload lives here as well. async hotUpdate({ file, modules, timestamp }) { + resolveFallbackPaths() const { environment } = this if (environment.config.consumer === 'server') { // A module runner cannot apply CSS, so Vite answers a sheet change @@ -540,6 +738,7 @@ export function DevupUI({ }, // Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists. async handleHotUpdate({ file, server, modules, timestamp }) { + resolveFallbackPaths() if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) { return } @@ -565,6 +764,7 @@ export function DevupUI({ return [] }, resolveId(id, importer) { + resolveFallbackPaths() const fileName = basename(id).split('?')[0] if ( DEVUP_CSS_FILE_RE.test(fileName) && @@ -588,63 +788,26 @@ export function DevupUI({ load(id) { const fileName = basename(id).split('?')[0] if (DEVUP_CSS_FILE_RE.test(fileName)) { - const fileNum = getFileNumByFilename(fileName) - const css = getCss(fileNum, false) - cssMap.set(fileNum, css) - return css + const snapshot = () => { + const fileNum = getFileNumByFilename(fileName) + const css = getCss(fileNum, false) + cssMap.set(fileNum, css) + return css + } + const preparation = aggregateCss.prepare( + this.environment ?? plugin, + this, + id, + ) + return preparation ? preparation.then(snapshot) : snapshot() } }, enforce: 'pre', - async transform(code, id) { - if (!extractCss) return - - const fileName = id.split('?')[0] - if (!/\.(tsx|ts|js|mjs|jsx)$/i.test(fileName)) return - if (createNodeModulesExcludeRegex(include).test(fileName)) { - return - } - - let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') - if (!rel.startsWith('./')) rel = `./${rel}` - - const { - code: extractedCode, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - // import main css in code - } = codeExtract( - fileName, - code, - libPackage, - rel, - singleCss, - true, - false, - importAliases, - ) - for (const dependency of dependencies) this.addWatchFile(dependency) - const promises: Promise[] = [] - - if (updatedBaseStyle) { - // update base style - promises.push(writeCssFile('devup-ui.css', getCss(null, false))) - } - - if (cssFile) { - const fileNum = getFileNumByFilename(cssFile) - const prevCss = cssMap.get(fileNum) - if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) - // `css` is only set when this transform added styles to the sheet. - if (css) promises.push(writeCssFile(basename(cssFile), css)) - } - await Promise.all(promises) - return { - code: extractedCode, - map, - } + transform: { + async handler(code, id) { + if (!SOURCE_FILE_RE.test(id.split('?')[0])) return + return sourceTransform.transform.call(this, code, id) + }, }, async generateBundle(_options, bundle) { if (!extractCss) return @@ -681,7 +844,7 @@ export function DevupUI({ } } }, - } + } satisfies Plugin const restorePlugin: Plugin = { name: 'devup-ui:restore-forwarded-css', sharedDuringBuild: true, @@ -694,5 +857,42 @@ export function DevupUI({ }, }, } - return [plugin, restorePlugin] + const mdxPlugin = { + name: 'devup-ui:mdx', + enforce: 'post', + sharedDuringBuild: true, + async transform(code, id) { + if (!isMdxSource(id, mdxExtensions)) return + return sourceTransform.transform.call(this, code, id) + }, + } satisfies Plugin + const aggregateGuard = { + name: 'devup-ui:aggregate-css-guard', + sharedDuringBuild: true, + apply: 'build', + generateBundle: { + order: 'post', + handler(_options, bundle) { + for (const asset of Object.values(bundle)) { + if (asset.type === 'asset') { + aggregateCss.checkAsset(this.environment ?? plugin, asset) + } + } + }, + }, + } satisfies Plugin + const compiledGuard = createCompiledGuard({ + package: libPackage, + mdxExtensions, + importAliases, + extractCss, + }) + const plugins: [ + typeof plugin, + typeof restorePlugin, + typeof mdxPlugin, + typeof aggregateGuard, + typeof compiledGuard, + ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard, compiledGuard] + return plugins } diff --git a/packages/vite-plugin/src/resolution-watch.ts b/packages/vite-plugin/src/resolution-watch.ts new file mode 100644 index 000000000..f72ae825a --- /dev/null +++ b/packages/vite-plugin/src/resolution-watch.ts @@ -0,0 +1,52 @@ +import { dirname } from 'node:path' + +import type { ViteDevServer } from 'vite' + +export function createMissingInputWatch() { + const missingByEnvironment = new Map>>() + let server: ViteDevServer | undefined + const changed = (file: string) => { + const path = file.replaceAll('\\', '/') + let affected = false + for (const environment of Object.values(server?.environments ?? {})) { + for (const [importer, inputs] of missingByEnvironment.get(environment) ?? + []) { + if (!inputs.has(path)) continue + for (const module of environment.moduleGraph.getModulesByFile( + importer, + ) ?? []) { + environment.moduleGraph.invalidateModule(module) + affected = true + } + } + } + if (affected) server?.ws.send({ type: 'full-reload' }) + } + return { + attach(current: ViteDevServer) { + server = current + current.watcher + .on('add', changed) + .on('change', changed) + .on('unlink', changed) + }, + start(importer: string, environment: object) { + const importers = + missingByEnvironment.get(environment) ?? new Map>() + importers.set(importer, new Set()) + missingByEnvironment.set(environment, importers) + }, + observe(importer: string, path: string, environment: object) { + missingByEnvironment.get(environment)?.get(importer)?.add(path) + server?.watcher.add(dirname(path)) + }, + close() { + server?.watcher + .off('add', changed) + .off('change', changed) + .off('unlink', changed) + server = undefined + missingByEnvironment.clear() + }, + } +} diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 343ead564..598d992fd 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -177,6 +177,57 @@ const box = const box = ``` +## Consuming Component Libraries + +A precompiled Devup UI library ships transformed JavaScript and emitted CSS. +Import its published stylesheet; its compile-time components have already been +removed, so the consumer does not need to transform that library again. + +A library intentionally published without extraction (for example with Vite's +`DevupUI({ extractCss: false })`, as in `apps/vite-lib`) requires a Devup UI build +plugin in **every** consuming application and its exact package name in `include`: + +```ts +new DevupUIWebpackPlugin({ include: ['@acme/components'] }) +``` + +The consumer compiles the library and emits its CSS at build time. Uncompiled +output is not standalone browser JavaScript: without this consumer setup, the +component placeholders throw `Cannot run on the runtime`. No styling runtime is +added by either supported publishing mode. + +## MDX Selection And Compiled-Source Guards + +Native `resolve.alias` maps or ordered descriptors are passed unchanged to the +shared graph, build-time resolver and loader; resolver cache identity includes +their order and values. Duplicate descriptors and literal-dollar names retain +native semantics. Empty candidate arrays perform no rewrite and fall through; +an actual rewriting all-miss is terminal. The installed enhanced-resolve factory +rejects mixed string/false arrays before its alias handler, so Webpack reports +its own construction error for those arrays. Wildcard names the shared resolver +cannot represent are located configuration errors, never silently dropped. + +`mdxExtensions` defaults to `['.mdx']`. Configure a project MDX loader for every +literal extension you opt into, for example `['.mdx', '.mdown']`; `.md` is not +selected by default. Devup's post-loader extracts compiler-produced JavaScript +with JSX under the actual filename. Graph discovery and numbering use the same +selection as extraction. + +With extraction enabled, the guard runs after loaders in build, development +compilations and watch rebuilds. Public parser facts, used dependency export IDs +and actual module targets identify definitely used compile-time exports in +modules outside the supported JS/TS and selected MDX extensions. Named bindings, +static namespace/require-result members and destructuring with known export IDs +produce a located module/import error and an `mdxExtensions` remedy. Runtime +helpers and unused imports are allowed; an intended runtime compatibility alias +can be disabled through `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Compile-time exports reached only through these opaque forms may throw +`Cannot run on the runtime` when rendered; let Devup compile the extension +instead. The guard does not expand extraction to ordinary JS/TS excluded by +`include` or change intentional uncompiled-library publishing. + ## Custom Shorthands ```ts diff --git a/packages/webpack-plugin/src/__tests__/alias-native.test.ts b/packages/webpack-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 000000000..2fa5b4821 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { DevupUIWebpackPlugin } from '../plugin' +import { mdxLanding } from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +it.each([false, true])( + 'passes native descriptors through graph, setup and loader when watch=%s', + async (watch) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + try { + for (const target of [false, blue] as const) { + wasm.resetBuildState() + const compiler = bundled.webpack({ + context: root, + mode: 'development', + entry, + target: 'node', + output: { path: join(root, 'out'), library: { type: 'commonjs2' } }, + resolve: { + alias: [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + { + name: '@devup-ui/react', + alias: mdxLanding.resolve('@devup-ui/react'), + }, + ], + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [new DevupUIWebpackPlugin({ singleCss: true, watch })], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + expect( + stats.toJson({ all: false, errors: true }).errors ?? [], + ).toEqual([]) + const result: { exports: { cls?: string } } = { exports: {} } + new Function( + 'module', + 'exports', + readFileSync(join(root, 'out/main.js'), 'utf8'), + )(result, result.exports) + expect(wasm.getCss(null, false)).toContain( + `.${result.exports.cls}{color:${target === false ? 'red' : 'blue'}}`, + ) + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + } + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/webpack-plugin/src/__tests__/core-regression.test.ts b/packages/webpack-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 000000000..a831d0c13 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,261 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import * as pluginUtils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import type { Compiler, Configuration } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = landing( + 'next/dist/compiled/webpack/webpack', +) +function webpack(config: Configuration) { + return bundled.webpack({ + ...config, + optimization: { ...config.optimization, minimize: false }, + }) +} + +let root: string +let compiler: Compiler | undefined + +beforeEach(async () => { + wasm.resetBuildState() + root = await mkdtemp( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'webpack-'), + ) + await mkdir(join(root, 'src')) +}) + +afterEach(async () => { + if (compiler) + await new Promise((done, reject) => + compiler?.close((error) => (error ? reject(error) : done())), + ) + compiler = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'prewarms modern .%s files relative to compiler.context', + async (extension) => { + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'});", + ) + const plugin = new DevupUIWebpackPlugin({ singleCss: true }) + + compiler = webpack({ + context: root, + mode: 'production', + entry: `./src/main.${extension}`, + plugins: [plugin], + }) + + expect(plugin.options.distDir).toBe(join(root, 'df')) + expect(wasm.getCss(null, false)).toContain('red') + const rules = compiler.options.module.rules + expect(rules).toContainEqual( + expect.objectContaining({ + test: pluginUtils.SOURCE_FILE_RE, + enforce: 'pre', + }), + ) + expect(rules).toContainEqual( + expect.objectContaining({ enforce: 'post', test: /\.mdx$/i }), + ) + }, +) + +it('overwrites empty declarations using the actual compiler context', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const plugin = new DevupUIWebpackPlugin() + compiler = webpack({ context: root, plugins: [plugin] }) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + plugin.writeDataFiles() + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it.each(['web', 'node'])( + 'reuses one graph covering all roots and the included closure for target %s', + async (target) => { + await mkdir(join(root, 'app')) + await mkdir(join(root, 'custom')) + const library = join(root, 'node_modules', 'foo.bar') + await mkdir(join(library, 'nested'), { recursive: true }) + await writeFile( + join(library, 'package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + default: './fallback.js', + }, + }), + ) + await writeFile( + join(library, 'browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(library, 'nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile(join(library, 'node.mjs'), "export const color = 'blue'") + await writeFile( + join(library, 'fallback.js'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'src/main.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/page.tsx'), "import '../src/main.mts'") + await writeFile(join(root, 'custom/entry.cts'), "import '../src/main.mts'") + const originalGraph = pluginUtils.buildStaticImportGraph + const captured: { graph?: pluginUtils.StaticImportGraph } = {} + const graph = spyOn( + pluginUtils, + 'buildStaticImportGraph', + ).mockImplementation((...args) => { + captured.graph = originalGraph(...args) + return captured.graph + }) + const canonical = spyOn(pluginUtils, 'buildCanonicalMap') + const reach = spyOn(pluginUtils, 'computeFileReach') + const prewarm = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + target, + entry: { app: './app/page.tsx', custom: './custom/entry.cts' }, + plugins: [ + new DevupUIWebpackPlugin({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + expect(graph).toHaveBeenCalledTimes(1) + const shared = captured.graph + expect(shared?.files).toContain( + join(library, target === 'web' ? 'nested/colors.mts' : 'node.mjs'), + ) + expect(shared?.files).not.toContain( + join(library, target === 'web' ? 'node.mjs' : 'nested/colors.mts'), + ) + expect(canonical.mock.calls[0]?.[0].graph).toBe(shared) + expect(reach.mock.calls[0]?.[0].graph).toBe(shared) + expect(prewarm.mock.calls[0]?.[0].graph).toBe(shared) + expect(wasm.getCss(null, false)).toContain( + target === 'web' ? 'red' : 'blue', + ) + } finally { + graph.mockRestore() + canonical.mockRestore() + reach.mockRestore() + prewarm.mockRestore() + } + }, +) + +it('rejects malformed config with its root and original cause', async () => { + await writeFile(join(root, 'devup.json'), '{') + + expect(() => + webpack({ context: root, plugins: [new DevupUIWebpackPlugin()] }), + ).toThrow(join(root, 'devup.json')) +}) + +it('does not parse raw MDX during prewarm', async () => { + await writeFile( + join(root, 'src/page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + ) + const extract = spyOn(wasm, 'codeExtract') + try { + compiler = webpack({ + context: root, + entry: './src/page.mdx', + plugins: [new DevupUIWebpackPlugin()], + }) + + expect(extract).not.toHaveBeenCalled() + } finally { + extract.mockRestore() + } +}) + +it.each([ + ['giant Next pitch', `next-app-loader?page=${'segment/'.repeat(5000)}!`], + ['virtual scheme', 'virtual:next-entry'], + ['virtual ID', '\0next-entry'], + ['empty resource', 'loader?pitch=true!?resourceQueryOnly'], +])('skips virtual or pitch-only entry %s', async (_name, entry) => { + const reachable = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin()], + }) + expect(reachable.mock.calls[0]?.[0].entries).toEqual([]) + } finally { + reachable.mockRestore() + } +}) + +it.each([ + './src/main.ts', + './src/main.ts?resource=true', + 'first-loader?value=one!second-loader?value=two!./src/main.ts?resource=true', +])('prewarms the real resource of entry %s', async (entry) => { + await writeFile( + join(root, 'src/main.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'})", + ) + await writeFile( + join(root, 'src/unreachable.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'blue'})", + ) + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin({ singleCss: true })], + }) + expect(wasm.getCss(null, false)).toContain('red') + expect(wasm.getCss(null, false)).not.toContain('blue') +}) + +it('propagates invalid real entry resource I/O instead of skipping it', () => { + expect(() => + webpack({ + context: root, + entry: 'loader!./src/\0.ts?resource=true', + plugins: [new DevupUIWebpackPlugin()], + }), + ).toThrow() +}) diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 5e5f80fee..cff741693 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -12,6 +12,7 @@ import { } from 'bun:test' import devupUICssLoader from '../css-loader' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType type CssLoaderThis = ThisParameterType @@ -34,12 +35,15 @@ function createCodeExtractResult( function createCssLoaderContext( resourcePath: string, callback: ReturnType, + compilation?: object, ): CssLoaderThis { return { callback, + cacheable: mock(), addContextDependency: mock(), resourcePath, getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + _compilation: compilation, } as unknown as CssLoaderThis } @@ -108,4 +112,22 @@ describe('devupUICssLoader', () => { expect(getCssSpy).toBeCalledTimes(1) }) + + it('records the stylesheet it serves in its compilation, uncached', () => { + const callback = mock() + const compilation = {} + getCssSpy.mockReturnValue('base css') + const context = createCssLoaderContext( + '/df/devup-ui/devup-ui.css', + callback, + compilation, + ) + devupUICssLoader.bind(context)(Buffer.from(''), '') + + expect(context.cacheable).toHaveBeenCalledWith(false) + expect(servedCss(compilation)).toEqual( + new Map([['/df/devup-ui/devup-ui.css', 'base css']]), + ) + expect(servedCss(compilation)).toBe(servedCss(compilation)) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 8697cb090..6c8efe49a 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -1,7 +1,12 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as fsPromises from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createModuleResolver } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, @@ -21,7 +26,7 @@ type LoaderThis = ThisParameterType type LoaderCallback = ReturnType interface TestLoaderContext extends Pick< LoaderThis, - 'async' | 'resourcePath' | 'addDependency' + 'async' | 'resourcePath' | 'addDependency' | 'addMissingDependency' > { getOptions: () => Partial _compiler?: { __DEVUP_CACHE: string } @@ -53,6 +58,7 @@ function createLoaderContext( async: mock().mockReturnValue(callback), resourcePath, addDependency: mock(), + addMissingDependency: mock(), ...overrides, } as unknown as LoaderThis } @@ -125,6 +131,224 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it('refreshes same-root configuration and isolates watches when successive loaders evaluate imports', async () => { + // Given unchanged loader options and separately owned dependency subscriptions. + const rootDir = mkdtempSync(join(tmpdir(), 'loader-inputs-')) + writeFileSync(join(rootDir, 'red.ts'), "export const color='red'") + writeFileSync(join(rootDir, 'blue.ts'), "export const color='blue'") + const selected: (string | undefined)[] = [] + const observed = [mock(), mock(), mock()] + const missing = [mock(), mock(), mock()] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('color', 'main.ts')?.code) + }, + ) + try { + // When a missing config is created and its inherited target changes at the same root. + for (const index of [0, 1, 2]) { + if (index === 1) + writeFileSync( + join(rootDir, 'tsconfig.json'), + '{"extends":"./base.json"}', + ) + if (index > 0) + writeFileSync( + join(rootDir, 'base.json'), + JSON.stringify({ + compilerOptions: { + paths: { color: [index === 1 ? 'red.ts' : 'blue.ts'] }, + }, + }), + ) + await new Promise((done) => { + const context = createLoaderContext( + { rootDir, cssDir: join(rootDir, 'df') }, + () => done(), + join(rootDir, 'main.ts'), + { + addDependency: observed[index], + addMissingDependency: missing[index], + }, + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + // Then each invocation sees current config and watches only through its own context. + expect(selected).toEqual([ + undefined, + "export const color='red'", + "export const color='blue'", + ]) + expect(missing[0]?.mock.calls).toContainEqual([ + join(rootDir, 'tsconfig.json'), + ]) + expect(observed[1]?.mock.calls).toContainEqual([join(rootDir, 'red.ts')]) + expect(observed[2]?.mock.calls).toContainEqual([join(rootDir, 'blue.ts')]) + expect(observed[0]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + expect(observed[1]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + } finally { + register.mockRestore() + rmSync(rootDir, { recursive: true, force: true }) + } + }) + it('selects a fresh native alias resolver when ordered entries change at the same root', async () => { + const root = mkdtempSync(join(tmpdir(), 'loader-alias-')) + const target = join(root, 'chosen.js') + writeFileSync(target, 'export const value="blue"') + const selected: unknown[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('provider', 'src/main.ts')) + }, + ) + try { + for (const alias of [ + [{ name: 'provider', alias: false }], + [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + ], + [{ name: 'provider', alias: false }], + ] as const) { + await new Promise((done) => { + const context = createLoaderContext( + { rootDir: root, alias, cssDir: join(root, 'df') }, + () => done(), + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + expect(selected).toEqual([ + { ignored: true }, + { path: 'chosen.js', code: 'export const value="blue"' }, + { ignored: true }, + ]) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('keys resolver reuse by the explicit project root and active conditions', async () => { + const root = mkdtempSync( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'loader-'), + ) + for (const project of ['first', 'second']) { + const library = join(root, project, 'node_modules', 'conditional') + mkdirSync(library, { recursive: true }) + writeFileSync( + join(library, 'package.json'), + JSON.stringify({ + exports: { browser: './browser.js', node: './node.js' }, + }), + ) + writeFileSync(join(library, 'browser.js'), project + '-browser') + writeFileSync(join(library, 'node.js'), project + '-node') + } + const selected: (string | undefined)[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('conditional', 'src/main.ts')?.code) + }, + ) + try { + for (const [project, condition] of [ + ['first', 'browser'], + ['first', 'node'], + ['second', 'node'], + ]) { + const rootDir = join(root, project) + await new Promise((done) => { + const context = createLoaderContext( + { + rootDir, + conditions: [condition], + cssDir: join(rootDir, 'df/devup-ui'), + }, + () => done(), + join(rootDir, 'src/main.ts'), + ) + devupUILoader.bind(context)(Buffer.from('code')) + }) + } + expect(selected).toEqual(['first-browser', 'first-node', 'second-node']) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('extracts actual compiled MDX while retaining the original resource filename', async () => { + const landing = createRequire( + nodePath.resolve( + import.meta.dir, + '../../../../apps/landing/package.json', + ), + ) + const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href + ) + const rootDir = process.cwd() + const filename = nodePath.resolve(rootDir, 'src/page.mdx') + const compiled = await mdx.compile({ + value: + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + path: filename, + }) + codeExtractSpy.mockRestore() + wasm.resetBuildState() + const callback = mock() + const context = createLoaderContext( + { + package: '@devup-ui/react', + cssDir: nodePath.resolve('df/devup-ui'), + rootDir, + singleCss: true, + }, + callback, + filename, + ) + + await new Promise((done) => { + callback.mockImplementation(() => done()) + devupUILoader.bind(context)(Buffer.from(String(compiled))) + }) + + expect(callback.mock.calls[0][0]).toBeNull() + expect(callback.mock.calls[0][1]).not.toContain('bg: "red"') + expect(callback.mock.calls[0][1]).toContain('devup-ui.css') + }) + + it.each([ + undefined, + { version: 3, sources: ['authored.mdx'], names: [], mappings: 'AAGE' }, + ])( + 'locates MDX errors with the supplied compiler map %j', + async (inputMap) => { + const callback = mock() + const rootDir = nodePath.resolve('/project') + const filename = nodePath.join(rootDir, 'page.mdx') + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const context = createLoaderContext( + { rootDir, cssDir: nodePath.join(rootDir, 'df/devup-ui') }, + callback, + filename, + ) + + devupUILoader.bind(context)(Buffer.from('compiled'), inputMap) + + expect(callback.mock.calls[0][0].message).toContain( + inputMap ? 'authored.mdx:4:3' : 'page.mdx:1:1 (in compiled MDX)', + ) + }, + ) it('resolves imports to cwd-relative ids and depends on the modules read', async () => { const setModuleResolverSpy = spyOn( wasm, diff --git a/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts new file mode 100644 index 000000000..898869ac6 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts @@ -0,0 +1,147 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export const mdxLanding = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) + +export const guardCases = [ + ['used Box', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused Box', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime helper', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'namespace css', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'namespace destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque namespace', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'active alias', + 'import styled from "@emotion/styled"\n\nexport const component = styled("div")\n\n# Plain', + true, + ], + [ + 'runtime alias', + 'import {ThemeProvider} from "styled-components"\n\n', + false, + ], + [ + 'unmapped alias', + 'import {CacheProvider} from "@emotion/react"\n\n', + false, + ], + [ + 'ordinary alias Box', + 'import {Box} from "ordinary-provider"\n\n', + false, + ], + ['actual alias Box', 'import {Box} from "opaque-provider"\n\n', true], + [ + 'shadowed namespace', + 'import * as DU from "@devup-ui/react"\n\nexport function use(DU){return DU.css()}\n\n# Plain', + false, + ], +] as const + +export const cjsGuardCases = [ + [ + 'require member', + 'export const value=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const value=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:value}=require("@devup-ui/react")', + true, + ], + [ + 'require runtime', + 'export const value=require("@devup-ui/react").getTheme', + false, + ], + ['require opaque', 'export const value=require("@devup-ui/react")', false], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const value=import("@devup-ui/react")', false], +] as const + +export function mdxBuildFixture(source: string, extension = '.mdown') { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-mdx-build-')), + ) + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'ordinary.js'), + 'export const Box = "div"; export const CacheProvider = "div"; export const ThemeProvider = "div"; export default () => "div"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + mkdirSync(join(root, 'node_modules/skipped'), { recursive: true }) + writeFileSync( + join(root, 'node_modules/skipped/package.json'), + '{"name":"skipped","main":"index.js"}', + ) + writeFileSync( + join(root, 'node_modules/skipped/index.js'), + 'import {Box} from "@devup-ui/react"; export const component = Box', + ) + const devup = mdxLanding.resolve('@devup-ui/react') + const alias = { + '@devup-ui/react': devup, + 'opaque-provider': devup, + 'ordinary-provider': join(root, 'ordinary.js'), + '@emotion/styled': join(root, 'ordinary.js'), + '@emotion/react': join(root, 'ordinary.js'), + 'styled-components': join(root, 'ordinary.js'), + } + const rule = { + test: /\.(?:mdown|mdx)$/i, + type: 'javascript/auto', + use: [ + { + loader: mdxLanding.resolve('@mdx-js/loader'), + options: { format: 'mdx', development: false }, + }, + ], + } + return { + root, + filename, + alias, + rule, + cleanup: () => rmSync(root, { recursive: true, force: true }), + } +} diff --git a/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..425e6c984 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,83 @@ +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, + mdxLanding, +} from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const compiler = bundled.webpack({ + context: fixture.root, + mode: 'development', + entry: fixture.filename, + output: { path: `${fixture.root}/out` }, + resolve: { alias: fixture.alias }, + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + experiments: { css: true }, + optimization: { minimize: false }, + module: { rules: [fixture.rule, { test: /\.css$/, type: 'css' }] }, + plugins: [ + new DevupUIWebpackPlugin({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + return { + errors: stats.toJson({ all: false, errors: true }).errors ?? [], + css: wasm.getCss(null, false), + } + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const standard = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.errors).toEqual([]) + expect(custom.css).toContain('red') + expect(custom.css).toBe(standard.css) +}) + +it.each([...guardCases, ...cjsGuardCases])( + 'uses final dependencies to guard %s', + async (_name, source, errors) => { + const result = await bundle(source) + expect(result.errors.length > 0).toBe(errors) + if (errors) expect(result.errors[0]?.message).toContain('mdxExtensions') + }, +) + +it('follows an actual forwarded Widget export to Box', async () => { + const result = await bundle( + 'import {Widget} from "./barrel.js"\n\n', + ) + expect(result.errors[0]?.message).toContain('mdxExtensions') +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead30..e450adb5d 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -17,19 +17,29 @@ import { import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType interface MockCompiler { options: { module: { rules: unknown[] } plugins: unknown[] + entry?: unknown + context?: string + } + watchMode?: boolean + webpack: { + DefinePlugin: ReturnType + Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } } - webpack: { DefinePlugin: ReturnType } hooks: { watchRun: { tapPromise: ReturnType } beforeRun: { tapPromise: ReturnType } done: { tapPromise: ReturnType } afterCompile: { tap: ReturnType } + run: { tap: ReturnType } + thisCompilation: { tap: ReturnType } + shutdown: { tap: ReturnType } } } @@ -76,6 +86,8 @@ let mkdirSpy: ReturnType let readFileSpy: ReturnType let statSpy: ReturnType let writeFileSpy: ReturnType +let graphSpy: ReturnType +const closeBuilds = new Set<() => void>() beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( @@ -102,9 +114,19 @@ beforeEach(() => { readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') statSpy = spyOn(fsPromises, 'stat').mockResolvedValue(createStats(0)) writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const closeBuild of closeBuilds) closeBuild() + closeBuilds.clear() codeExtractSpy.mockRestore() getCssSpy.mockRestore() getDefaultThemeSpy.mockRestore() @@ -124,6 +146,7 @@ afterEach(() => { readFileSpy.mockRestore() statSpy.mockRestore() writeFileSpy.mockRestore() + graphSpy.mockRestore() }) function createCompiler(): MockCompiler { @@ -136,6 +159,7 @@ function createCompiler(): MockCompiler { }, webpack: { DefinePlugin: mock(), + Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 }, }, hooks: { watchRun: { @@ -150,6 +174,17 @@ function createCompiler(): MockCompiler { afterCompile: { tap: mock(), }, + run: { + tap: mock(), + }, + thisCompilation: { + tap: mock(), + }, + shutdown: { + tap: mock((_name: string, closeBuild: () => void) => { + closeBuilds.add(closeBuild) + }), + }, }, } } @@ -157,11 +192,43 @@ function createCompiler(): MockCompiler { describe('devupUIWebpackPlugin', () => { console.error = mock() + describe('deterministic file numbering', () => { + it('numbers the files the scan finds and ends the build at shutdown', () => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx', 'src/b.tsx'] + }) + const compiler = createCompiler() + new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + asCompiler(compiler), + ) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [], + include: ['@acme/ui'], + }) + compiler.hooks.shutdown.tap.mock.calls[0][1]() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + resetSpy.mockRestore() + } + }) + }) it('should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), + cssDir: join('df', 'devup-ui'), devupFile: 'devup.json', distDir: 'df', watch: false, @@ -217,25 +284,20 @@ describe('devupUIWebpackPlugin', () => { 'CustomShadows', 'DevupTheme', ) - if (_options.getThemeInterface) - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join(options.distDir, 'theme.d.ts'), - _options.getThemeInterface, - { - encoding: 'utf-8', - }, - ) - else expect(writeFileSyncSpy).toHaveBeenCalledTimes(options.watch ? 1 : 0) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(options.distDir, 'theme.d.ts'), + _options.getThemeInterface, + { + encoding: 'utf-8', + }, + ) if (options.watch) expect(writeFileSyncSpy).toHaveBeenCalledWith( join(options.cssDir, 'devup-ui.css'), _options.getCss, ) - else - expect(writeFileSyncSpy).toHaveBeenCalledTimes( - _options.getThemeInterface ? 1 : 0, - ) + else expect(writeFileSyncSpy).toHaveBeenCalledTimes(1) }) }) @@ -270,10 +332,10 @@ describe('devupUIWebpackPlugin', () => { const compiler = createCompiler() await plugin.apply(asCompiler(compiler)) - expect(compiler.options.module.rules.length).toBe(2) + expect(compiler.options.module.rules.length).toBe(3) expect(compiler.options.module.rules[0].exclude).toEqual( - options.include.output, + pluginUtils.createNodeModulesExcludeRegex(options.include.input), ) }) @@ -295,7 +357,8 @@ describe('devupUIWebpackPlugin', () => { watch: true, }) const compiler = createCompiler() - readFileSyncSpy.mockImplementation(() => { + readFileSyncSpy.mockImplementation((file: string) => { + if (file.endsWith('tsconfig.json')) return '{}' throw new Error('error') }) statSpy.mockReturnValue(createStats(1)) @@ -395,16 +458,30 @@ describe('devupUIWebpackPlugin', () => { expect(statSpy).not.toHaveBeenCalled() } } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + const resolutionTap = compiler.hooks.afterCompile.tap.mock.calls.find( + ([name]) => name === 'DevupUIResolutionInputs', + ) + expect(resolutionTap).toBeDefined() + const inputFiles = new Set() + const missingInputs = new Set() + resolutionTap?.[1]({ + fileDependencies: inputFiles, + missingDependencies: missingInputs, + }) + expect(missingInputs).toContain(resolve('tsconfig.json')) + const themeTaps = compiler.hooks.afterCompile.tap.mock.calls.filter( + ([name]) => name === 'DevupUIWebpackPlugin', + ) if (options.existsDevupFile) { expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() const add = mock() - compiler.hooks.afterCompile.tap.mock.calls[0][1]({ + themeTaps[0]?.[1]({ fileDependencies: { add, }, }) expect(add).toHaveBeenCalledWith(resolve(plugin.options.devupFile)) - } else expect(compiler.hooks.afterCompile.tap).not.toHaveBeenCalled() + } else expect(themeTaps).toHaveLength(0) if (options.existsCssDir) { expect(mkdirSpy).not.toHaveBeenCalledWith(plugin.options.cssDir, { recursive: true, @@ -459,7 +536,7 @@ describe('devupUIWebpackPlugin', () => { let importCanonicalMapSpy: ReturnType let importFileRoutesSpy: ReturnType let setAtomHoistSpy: ReturnType - let listSourceFilesSpy: ReturnType + let computeReachableFilesSpy: ReturnType beforeEach(() => { buildCanonicalMapSpy = spyOn( @@ -478,9 +555,9 @@ describe('devupUIWebpackPlugin', () => { ) setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) // Default: no source files so pre-warm is a no-op unless a test opts in. - listSourceFilesSpy = spyOn( + computeReachableFilesSpy = spyOn( pluginUtils, - 'listSourceFiles', + 'computeReachableFiles', ).mockReturnValue([]) }) @@ -490,7 +567,7 @@ describe('devupUIWebpackPlugin', () => { importCanonicalMapSpy.mockRestore() importFileRoutesSpy.mockRestore() setAtomHoistSpy.mockRestore() - listSourceFilesSpy.mockRestore() + computeReachableFilesSpy.mockRestore() }) it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { @@ -511,15 +588,15 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('pre-warms the extractor over all source files in build mode when collapse is active', () => { - // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at - // first import — before later bucket members are extracted — so their - // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from - // the first css-loader call. + it('pre-warms the extractor over the files the entries reach in build mode', () => { + // Without pre-warm, webpack builds each shared stylesheet ONCE at first + // import — before later files are extracted — so their styles are + // dropped. Pre-warming the sheet makes getCss complete from the first + // css-loader call. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) @@ -532,8 +609,23 @@ describe('devupUIWebpackPlugin', () => { package: '@devup-ui/react', singleCss: true, }) - plugin.apply(asCompiler(createCompiler())) - expect(listSourceFilesSpy).toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = { + main: { import: ['./src/parent', './src/other'] }, + worker: {}, + } + compiler.options.context = '/project' + plugin.apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve('/project', 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }), + ) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -544,12 +636,12 @@ describe('devupUIWebpackPlugin', () => { importer: string, ) => { path: string } | undefined expect(resolveModule('./plugin.test', import.meta.path)?.path).toBe( - relative(process.cwd(), import.meta.path).replaceAll('\\', '/'), + relative('/project', import.meta.path).replaceAll('\\', '/'), ) setModuleResolverSpy.mockRestore() expect(codeExtractSpy).toHaveBeenCalledTimes(2) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/parent.tsx', + relative('/project', resolve('src/parent.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -559,7 +651,7 @@ describe('devupUIWebpackPlugin', () => { expect.anything(), ) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/child.tsx', + relative('/project', resolve('src/child.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -570,22 +662,21 @@ describe('devupUIWebpackPlugin', () => { ) }) - it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) - listSourceFilesSpy.mockReturnValue([ - resolve(process.cwd(), 'src', 'a.tsx'), - ]) - readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({}) - plugin.apply(asCompiler(createCompiler())) - expect(codeExtractSpy).not.toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = () => ({}) + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: [] }), + ) }) it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -594,11 +685,11 @@ describe('devupUIWebpackPlugin', () => { expect(codeExtractSpy).not.toHaveBeenCalled() }) - it('swallows pre-warm errors (extraction failure does not break apply)', () => { + it('fails with file and cause when pre-warm extraction fails', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -606,8 +697,9 @@ describe('devupUIWebpackPlugin', () => { throw new Error('extract boom') }) const plugin = new DevupUIWebpackPlugin({}) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /prewarm failed.*parent.tsx.*extract boom/, + ) expect(codeExtractSpy).toHaveBeenCalled() }) @@ -655,13 +747,14 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', () => { + it('fails with root and cause when graph setup fails', () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /graph setup failed.*boom/, + ) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -682,4 +775,112 @@ describe('devupUIWebpackPlugin', () => { expect(compiler.options.module.rules.length).toBeGreaterThan(0) }) }) + + describe('another pass for stylesheets built too early', () => { + function compile(watch = false) { + const compiler = createCompiler() + const plugin = new DevupUIWebpackPlugin({ watch }) + plugin.apply(asCompiler(compiler)) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compilation = { + assets: { 'main.js': {}, 'main.css': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const start = () => { + ;( + compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( + compilation: object, + ) => void + )(compilation) + } + return { compiler, plugin, compilation, taps, run, start } + } + + it('does not request another CSS pass in watch mode', () => { + const { compiler } = compile(true) + expect(compiler.hooks.run.tap).not.toHaveBeenCalled() + expect( + compiler.hooks.thisCompilation.tap.mock.calls.map(([name]) => name), + ).not.toContain('DevupUIWebpackPlugin') + }) + + it('writes the shared base to disk when it does not hold it yet', () => { + getCssSpy.mockReturnValue('base') + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockImplementation((file: string) => + file.endsWith('tsconfig.json') ? '{}' : 'base', + ) + const { plugin, start } = compile() + writeFileSyncSpy.mockClear() + start() + expect(writeFileSyncSpy).not.toHaveBeenCalled() + + readFileSyncSpy.mockReturnValue('older') + start() + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + 'utf-8', + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', () => { + getCssSpy.mockReturnValue('before') + const { compiler, compilation, plugin, taps, run, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'before') + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.js') + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.css') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + '/df/devup-ui/devup-ui-1.css', + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + run() + expect(taps.needAdditionalPass!()).toBe(true) + + // a watching compiler rebuilds through the files the loaders write + compiler.watchMode = true + start() + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + + it('keeps the pass when every stylesheet is current', () => { + getCssSpy.mockReturnValue('same') + const { compilation, taps, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'same') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.mjs b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 000000000..8b4e10c1c --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { DevupUIWebpackPlugin } from '../../dist/index.mjs' + +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const { webpack } = landing('next/dist/compiled/webpack/webpack') +const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'webpack-resolution-watch-')), +) +const root = join(fixture, 'apps/app') +const palette = join(fixture, 'packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +const preserveSymlinks = process.argv[2] === 'preserve' +let compiler +let watching +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (error, stats) => { + clearTimeout(timer) + next = undefined + error ? rejectBuild(error) : resolveBuild(stats) + } + if (pending.length) next(...pending.shift()) + }) +const state = async (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const module = [...stats.compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const css = ( + await Promise.all( + Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith('.css')) + .map((name) => + readFile(join(stats.compilation.outputOptions.path, name), 'utf-8'), + ), + ) + ).join('\n') + return { code, selected, css } +} +const configuration = (watch) => ({ + context: root, + mode: 'development', + entry: './src/main.js', + cache: false, + output: { path: join(root, watch ? 'out' : 'cold') }, + resolve: { + symlinks: !preserveSymlinks, + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + optimization: { minimize: false }, + plugins: [new DevupUIWebpackPlugin({ watch, singleCss: false })], +}) +try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + compiler = webpack(configuration(true)) + const initial = nextBuild() + watching = compiler.watch({}, (error, stats) => + next ? next(error, stats) : pending.push([error, stats]), + ) + const beforeStats = await initial + const before = await state(beforeStats) + assert.ok( + before.css.includes(`.${before.selected}{background:red}`), + before.css, + ) + const watchedManifest = preserveSymlinks + ? join(root, 'node_modules/palette/package.json') + : manifest + assert.ok( + beforeStats.compilation.fileDependencies.has(watchedManifest), + `Manifest not registered: ${watchedManifest}`, + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = await state(await rebuilt) + for ( + let attempt = 0; + attempt < 3 && after.selected === before.selected; + attempt += 1 + ) + after = await state(await nextBuild()) + assert.notEqual(after.selected, before.selected) + assert.ok( + after.css.includes(`.${after.selected}{background:blue}`), + after.css, + ) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + watching = undefined + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + compiler = webpack(configuration(false)) + const coldStats = await new Promise((done, reject) => + compiler.run((error, stats) => (error ? reject(error) : done(stats))), + ) + const cold = await state(coldStats) + assert.ok(cold.css.includes('background:blue'), cold.css) + assert.ok(!cold.css.includes('background:red'), cold.css) + console.info( + JSON.stringify({ preserveSymlinks, watchedManifest, before, after, cold }), + ) +} finally { + if (watching) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + if (compiler) + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..b0ec77f01 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,23 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it.each(['canonical', 'preserve'])( + 'refreshes actual watch CSS when only linked package exports change with %s symlinks', + (mode) => { + // Given the real compiler fixture with two preexisting palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When the runner edits only exports and awaits actual compiler callbacks. + const result = Bun.spawnSync(['node', runner, mode], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then emitted current-class CSS is blue and a cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') + }, + 45000, +) diff --git a/packages/webpack-plugin/src/compiled-guard.ts b/packages/webpack-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..4e1ca767e --- /dev/null +++ b/packages/webpack-plugin/src/compiled-guard.ts @@ -0,0 +1,189 @@ +import { + compiledFacts, + createCompileTimeClassifier, + createDependencyGuard, + type DependencyGuardOptions, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, +} from '@devup-ui/plugin-utils' +import type { Compiler, Dependency, Module } from 'webpack' + +interface ScriptParser { + readonly state: { readonly module: Module } + readonly hooks: { + readonly program: { + tap( + options: { readonly name: string; readonly stage: number }, + callback: (ast: unknown) => void, + ): void + } + } +} + +function isScriptParser(value: object): value is ScriptParser { + const hooks = 'hooks' in value ? value.hooks : undefined + const program = + hooks && typeof hooks === 'object' && 'program' in hooks + ? hooks.program + : undefined + return ( + !!program && + typeof program === 'object' && + 'tap' in program && + typeof program.tap === 'function' && + 'state' in value + ) +} + +export function registerCompiledGuard( + compiler: Compiler, + options: DependencyGuardOptions, +) { + const check = createDependencyGuard(options) + const classify = createCompileTimeClassifier(options.package) + compiler.hooks.thisCompilation.tap( + 'DevupUICompiledSourceGuard', + (compilation, { normalModuleFactory }) => { + const facts = new Map() + for (const type of [ + 'javascript/auto', + 'javascript/esm', + 'javascript/dynamic', + ]) + normalModuleFactory.hooks.parser + .for(type) + .tap('DevupUICompiledSourceGuard', (parser) => { + if (!isScriptParser(parser)) return + parser.hooks.program.tap( + { name: 'DevupUICompiledSourceGuard', stage: -10000 }, + (ast) => { + facts.set(parser.state.module, compiledFacts(ast)) + }, + ) + }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + const errors = check(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + forwarded(dependency, ids) { + const actual = dependencies.get(dependency) + let module = actual && compilation.moduleGraph.getModule(actual) + let names = ids + const seen = new Set() + while (module && names.length && !seen.has(module)) { + seen.add(module) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, names) + ) + return true + const target = compilation.moduleGraph + .getExportsInfo(module) + .getExportInfo(names[0]) + .getTarget(compilation.moduleGraph) + module = target?.module + names = target?.export + ? [...target.export, ...names.slice(1)] + : [] + } + return false + }, + }) + function resolved(module: Module, request: string) { + const dependency = module.dependencies.find( + (item) => 'request' in item && item.request === request, + ) + return dependency + ? compilation.moduleGraph.getModule(dependency) + : undefined + } + function origins( + module: Module, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + if (seen.has(module)) return new Map() + const key = JSON.stringify([module.identifier(), ids]) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, ids) + ) + return new Map([[key, true]]) + const symbols = facts.get(module) + if (!symbols || !ids.length) return new Map() + const next = new Set([...seen, module]) + const direct = symbols.exports.get(ids[0]) + if (symbols.exports.has(ids[0])) { + const target = direct && resolved(module, direct.request) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + if (ids[0] === 'default') return new Map() + return new Map( + symbols.stars.flatMap((request) => { + const target = resolved(module, request) + return target ? [...origins(target, ids, next)] : [] + }), + ) + } + for (const module of modules) { + if ( + !('resource' in module) || + typeof module.resource !== 'string' || + isSelectedSource(module.resource, options.mdxExtensions) || + errors.some((error) => error.filename === module.resource) + ) + continue + for (const ref of facts.get(module)?.references ?? []) { + const target = resolved(module, ref.request) + const targets = target + ? origins(target, ref.ids) + : new Map() + const prefix = ( + module.originalSource()?.source().toString() ?? '' + ).slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + if ( + isCompileTimeAlias(reference, options.importAliases) || + (targets.size === 1 && [...targets.values()][0]) + ) { + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + break + } + } + } + compilation.errors.push(...errors) + }, + ) + }, + ) +} diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index bc6344070..450d9e671 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,8 +2,14 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { servedCss } from './served-css' + const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - const fileNum = getFileNumByFilename(this.resourcePath) - this.callback(null, getCss(fileNum, true), map, meta) + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const css = getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e758..43b1cbff8 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,15 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + createStateWriter, + isMdxSource, + type ModuleAliasOptions, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, +} from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -21,6 +29,11 @@ export interface DevupUILoaderOptions { watch: boolean singleCss: boolean importAliases?: Record + rootDir?: string + conditions?: readonly string[] + mdxExtensions?: readonly string[] + alias?: ModuleAliasOptions + symlinks?: boolean } function toLoaderError(error: unknown): Error { @@ -34,18 +47,32 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } -let moduleResolver: ReturnType | undefined - +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) /** Resolve imports to the cwd-relative ids this loader extracts files under */ -function setCwdModuleResolver(): void { - moduleResolver ??= createModuleResolver({ - toId: (path) => relative(process.cwd(), path).replaceAll('\\', '/'), +function setCwdModuleResolver(options: { + readonly rootDir: string + readonly conditions: readonly string[] + readonly mdxExtensions: readonly string[] + readonly alias: ModuleAliasOptions | undefined + readonly onResolutionInputs: ResolutionInputObserver +}): void { + const { rootDir, conditions, mdxExtensions, alias, onResolutionInputs } = + options + const moduleResolver = createModuleResolver({ + cwd: rootDir, + includeMdx: mdxExtensions, + conditions, + alias, + onResolutionInputs, + toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), }) setModuleResolver(moduleResolver) } const devupUILoader: RawLoaderDefinitionFunction = - function (source) { + function (source, inputSourceMap) { const { watch, package: libPackage, @@ -55,6 +82,11 @@ const devupUILoader: RawLoaderDefinitionFunction = fileMapFile, singleCss, importAliases = {}, + rootDir = process.cwd(), + conditions = ['import', 'module', 'node'], + mdxExtensions = ['.mdx'], + alias, + symlinks = true, } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -72,10 +104,21 @@ const devupUILoader: RawLoaderDefinitionFunction = // FILE_ROUTES keys (built with forward slashes by plugin-utils). Without // this, single-importer collapse and atom hoisting silently no-op on // Windows. No-op on POSIX. - const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') + const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver() + setCwdModuleResolver({ + rootDir, + conditions, + mdxExtensions, + alias, + onResolutionInputs: (inputs) => { + for (const file of inputs.fileDependencies) + this.addDependency(resolutionWatchPath(file, !symlinks)) + for (const path of inputs.missingDependencies) + this.addMissingDependency(resolutionWatchPath(path, !symlinks)) + }, + }) const { code, css = '', @@ -92,32 +135,39 @@ const devupUILoader: RawLoaderDefinitionFunction = false, true, importAliases, + ...(isMdxSource(id, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) + this.addDependency(resolve(rootDir, dependency)) } const sourceMap = parseSourceMap(map) const promises: Promise[] = [] if (updatedBaseStyle) { // 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) { const content = `${this.resourcePath} ${Date.now()}` // should be reset css promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), watch ? `/* ${content} */` : css, ), ) if (watch) { promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } } @@ -126,7 +176,15 @@ const devupUILoader: RawLoaderDefinitionFunction = (error) => callback(toLoaderError(error)), ) } catch (error) { - callback(toLoaderError(error)) + callback( + isMdxSource(id, mdxExtensions) + ? remapMdxError( + error, + relative(rootDir, id).replaceAll('\\', '/'), + inputSourceMap, + ) + : toLoaderError(error), + ) } return } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a9..2a73744e3 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,21 +1,36 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, extname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, + getFileNumByFilename, type ImportAliases, - listSourceFiles, + isSelectedSource, loadDevupConfigSync, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -30,6 +45,8 @@ import { importSheet, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -37,6 +54,9 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { registerCompiledGuard } from './compiled-guard' +import { servedCss } from './served-css' + export interface DevupUIWebpackPluginOptions { package: string cssDir: string @@ -48,6 +68,8 @@ export interface DevupUIWebpackPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold. * @@ -79,23 +101,36 @@ export class DevupUIWebpackPlugin { classMapFile: string fileMapFile: string private importAliases: WasmImportAliases + private excludeModules: RegExp + private readonly mdxExtensions: readonly string[] + private seedWarningEmitted = false + private pathOptions: { + readonly devupFile: string + readonly distDir: string + readonly cssDir: string + } constructor({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir = join(distDir, 'devup-ui'), watch = false, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions, }: Partial = {}) { + this.mdxExtensions = normalizeMdxExtensions(mdxExtensions) registerShorthands(shorthands ?? {}) this.importAliases = mergeImportAliases(userImportAliases) + this.excludeModules = createNodeModulesExcludeRegex(include) + this.pathOptions = { devupFile, distDir, cssDir } this.options = { package: libPackage, @@ -108,6 +143,7 @@ export class DevupUIWebpackPlugin { singleCss, prefix, atomHoist, + sourceDirs, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -116,24 +152,17 @@ export class DevupUIWebpackPlugin { } writeDataFiles() { - try { - const config = loadDevupConfigSync(this.options.devupFile) - const theme = config.theme ?? {} + const config = loadDevupConfigSync(this.options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(this.options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(this.options.package), + ) - if (interfaceCode) { - writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { - encoding: 'utf-8', - }) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { + encoding: 'utf-8', + }) if (!existsSync(this.options.cssDir)) mkdirSync(this.options.cssDir, { recursive: true }) if (this.options.watch) @@ -143,26 +172,20 @@ export class DevupUIWebpackPlugin { ) } - /** - * Extract every source file under `src` into the shared WASM sheet so that a - * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed - * members), not just the first member webpack happened to build. Mirrors the - * loader's `codeExtract` call (same filename keying + options) so re-extraction - * during compilation is idempotent. Best-effort: extraction errors are - * swallowed so a single bad file never breaks the build. - */ - private prewarmExtractor() { - try { - const cwd = process.cwd() - // The same resolver as the loader's, so imported constants and - // stylesheets extract to the classes the loader emits - setModuleResolver( - createModuleResolver({ - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - const srcDir = resolve(cwd, 'src') - for (const file of listSourceFiles(srcDir)) { + private prewarmExtractor(options: { + graph: StaticImportGraph + entries: string[] + cwd: string + }) { + const { graph, entries, cwd } = options + for (const file of computeReachableFiles({ + srcDir: graph.files, + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -179,18 +202,117 @@ export class DevupUIWebpackPlugin { true, this.importAliases, ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${cwd}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - } catch { - // Best-effort warm-up; on failure the css-loader still serves whatever - // atoms were extracted, matching pre-fix behavior. } } apply(compiler: Compiler) { - setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) + if (compiler.hooks.afterEnvironment) { + compiler.hooks.afterEnvironment.tap('DevupUIWebpackPlugin', () => { + this.setupCompiler(compiler) + }) + } else { + this.setupCompiler(compiler) } + } + + private setupCompiler(compiler: Compiler) { + const cwd = compiler.context ?? compiler.options.context ?? process.cwd() + const paths = resolveProjectPaths(cwd, this.pathOptions) + this.options = { ...this.options, ...paths } + this.sheetFile = join(paths.distDir, 'sheet.json') + this.classMapFile = join(paths.distDir, 'classMap.json') + this.fileMapFile = join(paths.distDir, 'fileMap.json') + const sourceDirs = resolveSourceDirs(cwd, this.options.sourceDirs) + const resolveOptions = compiler.options.resolve + const inputFiles = new Set() + const missingInputs = new Set() + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + for (const path of inputs.missingDependencies) + missingInputs.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + } + compiler.hooks.afterCompile.tap( + 'DevupUIResolutionInputs', + (compilation) => { + for (const path of inputFiles) compilation.fileDependencies.add(path) + for (const path of missingInputs) + compilation.missingDependencies.add(path) + }, + ) + const baseConditions = resolveOptions?.conditionNames ?? [ + 'webpack', + compiler.options.mode === 'development' ? 'development' : 'production', + 'browser', + ] + const conditions = ( + resolveOptions?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = compiler.options.entry + const entries = + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.flatMap((request) => { + const resource = request + .slice(request.lastIndexOf('!') + 1) + .split('?')[0] + if ( + !resource || + resource.startsWith('\0') || + (/^[a-z][a-z\d+.-]*:/i.test(resource) && + !/^[a-z]:[\\/]/i.test(resource)) + ) + return [] + return isSelectedSource(resource, this.mdxExtensions) || + !extname(resource) + ? [resolve(cwd, resource)] + : [] + }), + ) + const roots = [ + ...new Set([...sourceDirs, ...entries.map((file) => dirname(file))]), + ] + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) + try { + setModuleResolver( + createModuleResolver({ + cwd, + includeMdx: this.mdxExtensions, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] resolver setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + setDebug(this.options.debug) + setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -218,7 +340,15 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } - this.writeDataFiles() + try { + this.writeDataFiles() + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] theme setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader // runs codeExtract (apply() body is synchronous, loaders run during @@ -234,16 +364,24 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. - let canonicalMap: Record = {} + let graph: StaticImportGraph try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') - const cwd = process.cwd() - canonicalMap = buildCanonicalMap({ + const srcDir = roots + const tsconfigPath = resolve(cwd, 'tsconfig.json') + graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: this.mdxExtensions, + cwd, + include: this.options.include, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + }) + const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, }) importCanonicalMap(canonicalMap) @@ -253,6 +391,8 @@ export class DevupUIWebpackPlugin { tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, + entries: entries.length ? entries : undefined, }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { @@ -264,24 +404,54 @@ export class DevupUIWebpackPlugin { ) } } - } catch { - // Best-effort; on failure canonical() is the identity (no merge) and atom - // hoisting stays off. + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: this.mdxExtensions, + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (cause) { + if (!this.seedWarningEmitted) { + this.seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: cwd, cause }, + ) + } + } + // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // - // Under collapse, several source files share ONE devup-ui-N.css (the - // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack - // builds that shared .css module ONCE — at the FIRST import resolution, - // before the bucket's other members have been extracted — so their atoms - // would be dropped. Turbopack avoids this via its idle coordinator; webpack - // has no such re-serve, so we extract every source file up front (single - // shared WASM instance) to populate the bucket fully BEFORE any css-loader - // runs. Re-extraction by the per-file loader is then idempotent (set-based - // atom dedup). Only needed for one-shot builds when collapse is active. - if (!this.options.watch && Object.keys(canonicalMap).length > 0) { - this.prewarmExtractor() + // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared + // base (global styles of every file) and, under collapse, a bucket's + // devup-ui-N.css shared by several source files. Files extracted after that + // would be missing, so the files the entries reach are extracted up front + // (single shared WASM instance). Re-extraction by the per-file loader is + // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets + // through the files the loaders write instead. + if (!this.options.watch) { + try { + this.prewarmExtractor({ graph, entries, cwd }) + } catch (cause) { + endBuild() + throw cause + } } if (this.options.watch) { @@ -303,12 +473,72 @@ export class DevupUIWebpackPlugin { compilation.fileDependencies.add(resolve(this.options.devupFile)) }) - compiler.options.plugins.push( - new compiler.webpack.DefinePlugin({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), - }), - ) + const definePlugin = new compiler.webpack.DefinePlugin({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + }) + if (compiler.hooks.afterEnvironment) definePlugin.apply(compiler) + else compiler.options.plugins.push(definePlugin) if (!this.options.watch) { + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. Watch mode rebuilds it + // through the stylesheet files the loaders write instead. + let passes = 0 + compiler.hooks.run.tap('DevupUIWebpackPlugin', () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPlugin', + (compilation) => { + const basePath = join(this.options.cssDir, 'devup-ui.css') + const base = getCss(null, true) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin, so it + // must hold every style extracted so far + if (!existsSync(basePath) || readFileSync(basePath, 'utf-8') !== base) + writeFileSync(basePath, base, 'utf-8') + let stale: string[] = [] + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => { + if (compiler.watchMode) return + const changed = new Set( + [...servedCss(compilation)] + .filter( + ([path, css]) => + getCss(getFileNumByFilename(path), true) !== css, + ) + .map(([path]) => path), + ) + if (getCss(null, true) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: 'DevupUIWebpackPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap( + 'DevupUIWebpackPlugin', + () => { + if (stale.length === 0 || passes > 0) return undefined + passes += 1 + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + return true + }, + ) + }, + ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { // write css file @@ -321,31 +551,39 @@ export class DevupUIWebpackPlugin { }) } + const exclude = this.excludeModules + const sourceLoader = { + loader: createRequire(import.meta.url).resolve( + '@devup-ui/webpack-plugin/loader', + ), + options: { + package: this.options.package, + cssDir: this.options.cssDir, + sheetFile: this.sheetFile, + classMapFile: this.classMapFile, + fileMapFile: this.fileMapFile, + watch: this.options.watch, + singleCss: this.options.singleCss, + importAliases: this.importAliases, + rootDir: cwd, + conditions, + alias: resolveOptions?.alias, + mdxExtensions: this.mdxExtensions, + symlinks: resolveOptions?.symlinks, + }, + } compiler.options.module.rules.push( { - test: /\.(tsx|ts|js|mjs|jsx)$/, - exclude: createNodeModulesExcludeRegex( - this.options.include, - '.mdx.[tj]sx?$', - ), + test: SOURCE_FILE_RE, + exclude, enforce: 'pre', - use: [ - { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/loader', - ), - options: { - package: this.options.package, - cssDir: this.options.cssDir, - sheetFile: this.sheetFile, - classMapFile: this.classMapFile, - fileMapFile: this.fileMapFile, - watch: this.options.watch, - singleCss: this.options.singleCss, - importAliases: this.importAliases, - }, - }, - ], + use: [sourceLoader], + }, + { + test: mdxSourceFilter(this.mdxExtensions), + exclude, + enforce: 'post', + use: [sourceLoader], }, { test: this.options.cssDir, @@ -362,5 +600,10 @@ export class DevupUIWebpackPlugin { ], }, ) + registerCompiledGuard(compiler, { + package: this.options.package, + mdxExtensions: this.mdxExtensions, + importAliases: this.importAliases, + }) } } diff --git a/packages/webpack-plugin/src/served-css.ts b/packages/webpack-plugin/src/served-css.ts new file mode 100644 index 000000000..c04ca03fd --- /dev/null +++ b/packages/webpack-plugin/src/served-css.ts @@ -0,0 +1,18 @@ +declare global { + var __devupUiServedCss: WeakMap> | undefined +} + +/** + * The stylesheet each devup-ui CSS module of `compilation` was built with, by + * resource path. The plugin and its loaders load as separate modules, so the + * record lives on `globalThis`. + */ +export function servedCss(compilation: object): Map { + globalThis.__devupUiServedCss ??= new WeakMap() + let served = globalThis.__devupUiServedCss.get(compilation) + if (!served) { + served = new Map() + globalThis.__devupUiServedCss.set(compilation, served) + } + return served +}