From e7335d80e6e98c52fab78a7dc883daa9a38428c2 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 05:57:51 +0100 Subject: [PATCH 1/4] feat: read installed design-system packages that publish their sources Zero config skipped every dependency installed in node_modules as compiled, so an app on a source-only package such as @trycompai/design-system (exports at ./src/index.ts, 72 .tsx files) got no components and no tokens. An installed dependency named like a design system is now read through its exports when they lead to the .tsx or .jsx sources it publishes, from its real folder so pnpm's sibling dependencies resolve, or through the app's node_modules when pnpm's store is elsewhere. Its build output is not walked. Its exported stylesheets are its tokens, with the partials of the same package they import, and an app stylesheet's @import of such a package is followed into node_modules. Other installed packages (tailwindcss) are not read. inspect --explain now says a package publishes build output and no component source, instead of calling every installed package compiled. --- .changeset/installed-source-packages.md | 5 + docs/design-system-packages.md | 25 +- docs/troubleshooting.md | 2 +- src/detect.ts | 150 +++++++--- src/tokens/imports.ts | 5 +- test/design-system-packages-docs.test.ts | 60 +++- test/init.test.ts | 2 +- test/installed-source-packages.test.ts | 337 +++++++++++++++++++++++ 8 files changed, 534 insertions(+), 52 deletions(-) create mode 100644 .changeset/installed-source-packages.md create mode 100644 test/installed-source-packages.test.ts diff --git a/.changeset/installed-source-packages.md b/.changeset/installed-source-packages.md new file mode 100644 index 00000000..cffab02b --- /dev/null +++ b/.changeset/installed-source-packages.md @@ -0,0 +1,5 @@ +--- +'onsystem': minor +--- + +Zero config now reads a design-system package installed from a registry when it is named like one (`@acme/ui`, `@acme/design-system`) and its `exports` lead to the `.tsx` or `.jsx` sources it publishes, as it reads a workspace package, with no config in the app or the package. Before, such an app got no components and no tokens: a package that publishes `src/`, such as one whose `exports` point at `./src/index.ts`, was skipped as compiled. Its exported stylesheets are its tokens, with the ones of the package they `@import`, and an app stylesheet's `@import "@acme/ui/globals.css"` is followed into `node_modules` when the app's config names no `tokens`. Other installed packages, such as `tailwindcss`, are not read. With pnpm the package is read in `node_modules/.pnpm`, so the props its components take from their own dependencies resolve. A package that publishes build output alone is still not read, and `inspect --explain` now says why: `it publishes build output (node_modules/@acme/ui/dist/index.mjs) and no component source`, instead of calling every installed package compiled. An app whose own config leaves `components` unset now picks up such a package too. diff --git a/docs/design-system-packages.md b/docs/design-system-packages.md index 8ab7cb5a..0297bb68 100644 --- a/docs/design-system-packages.md +++ b/docs/design-system-packages.md @@ -1,6 +1,6 @@ # Design-system packages -A design-system team owns one package that many apps use. Set onsystem up once, in that package, and every app that depends on it finds the design system with no config of its own: in the CLI, the MCP server, the ESLint plugin, the agent plugin's hook and the GitHub Action. What an app checks depends on what it can read of the package: every rule from a workspace package, or from a published one that ships its component sources, and the token rules, but none of its components, from one that publishes compiled code alone ([what apps get](#what-apps-get)). +A design-system team owns one package that many apps use. Set onsystem up once, in that package, and every app that depends on it finds the design system with no config of its own: in the CLI, the MCP server, the ESLint plugin, the agent plugin's hook and the GitHub Action. What an app checks depends on what it can read of the package: every rule from a workspace package, or from a published one that ships its component sources, and the token rules, but none of its components, from one that publishes compiled code alone ([what apps get](#what-apps-get)). A package named like a design system that publishes its component sources is read even without the setup ([without the config](#without-the-config)). ## Set up the package @@ -57,7 +57,7 @@ The config a project reads is the first of these that it has: `check` from a monorepo root and from the app's folder read the same config. The root itself reads no dependency's config: each project in it finds its own. -An app's own config replaces the package's, rules and all. Zero config fills in only the fields it leaves out, as in any project: it finds a workspace package named like a design system, but never one installed in `node_modules`. So a config that only changes a rule's severity can leave the app without the package's components and tokens, and `check` then says which rules did not run. Copy `components`, `importPath` and `tokens` from the package's config into the app's, with paths into the package: `node_modules/@acme/ui/src/**/*.tsx`, or `../../packages/ui/src/**/*.tsx` from a workspace app. `init` writes no `importPath` for a package that apps import by subpath (`@acme/ui/components/button`), so when the package's config has none, set it to the package's name (`@acme/ui`). Without it, `no-unknown-component`, `no-unknown-prop`, `no-unknown-variant`, and `icon-button-accessible-name` on the package's ` + +
New
+ + ); +} +`; + +const at = (prefix: string, files: Record) => + Object.fromEntries(Object.entries(files).map(([file, text]) => [`${prefix}/${file}`, text])); + +/** The app, with the package as npm installs it. */ +function npmApp(extra: Record = {}): string { + return fixture({ + 'package.json': json({ + name: 'web', + dependencies: { '@acme/design-system': '^1.1.0', tailwindcss: '^4.0.0' }, + }), + 'app/globals.css': '@import "tailwindcss";\n@import "@acme/design-system/globals.css";\n', + 'app/page.tsx': PAGE, + ...at('node_modules/@acme/design-system', PACKAGE), + ...OTHERS, + ...extra, + }); +} + +/** Where pnpm puts the package, and its own dependencies beside it. */ +const STORE = 'node_modules/.pnpm/@acme+design-system@1.1.0/node_modules'; + +/** pnpm's layout: node_modules/@acme/design-system links to the package in `store`. */ +function linkPackage(root: string, store: string): void { + fs.mkdirSync(path.join(root, 'node_modules/@acme'), { recursive: true }); + fs.symlinkSync( + path.join(store, STORE, '@acme/design-system'), + path.join(root, 'node_modules/@acme/design-system'), + 'junction', + ); +} + +/** + * The package's Button with its props from a dependency, which pnpm installs beside the package + * and not in the app's node_modules. + */ +const WITH_PRIMITIVE = { + [`${STORE}/@acme/design-system/src/components/atoms/button.tsx`]: + 'import type { PrimitiveProps } from "@acme/primitive";\nexport function Button(props: PrimitiveProps & { variant?: "default" | "outline" }) { return + + + ); +} +`, + ...extra, + }); +} + +/** What an app's design system is made of: what it detected, its components and its tokens. */ +async function contents(root: string) { + const ds = await load(root); + return { + detected: ds.config.detected, + components: ds.components.map((c) => c.name).sort(), + tokens: ds.tokens.map((t) => `${t.name}: ${t.value}`).sort(), + }; +} + /** Token files from installed packages other than the design system. */ const OTHER_FILES = /node_modules\/(?:tailwindcss|tw-animate-css)\//; @@ -238,7 +314,7 @@ describe('an installed design-system package that publishes its sources', () => }); it("is read through the app's node_modules when pnpm's store is elsewhere", async () => { - const store = fixture({ ...at(`${STORE}/@acme/design-system`, PACKAGE) }); + const store = fixture({ ...at(`${STORE}/@acme/design-system`, PACKAGE), ...WITH_PRIMITIVE }); const root = fixture({ 'package.json': json({ name: 'web', dependencies: { '@acme/design-system': '^1.1.0' } }), 'app/page.tsx': PAGE, @@ -251,6 +327,11 @@ describe('an installed design-system package that publishes its sources', () => ); expect(ds.tokens.map((t) => t.name)).toContain('muted'); expect(ds.model.warnings.join('\n')).not.toContain('Not read'); + // Read through the link, the package's own dependencies, beside it in the store, do not + // resolve: the props its Button takes from one are not known. + const button = ds.getComponent('Button'); + expect(button?.props.map((p) => p.name)).toContain('variant'); + expect(button?.props.map((p) => p.name)).not.toContain('render'); }); it('is read from the workspace root, where npm hoists it', async () => { @@ -277,7 +358,7 @@ describe('an installed design-system package that publishes its sources', () => }); describe('installed packages zero config does not read', () => { - it('says that a package publishing build output alone publishes no component source', async () => { + it('says that a package whose exports point at build output exports no component source', async () => { // Three .jsx files in dist/: the build output is not read, even where it could be. const root = fixture({ 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '^1.0.0' } }), @@ -297,11 +378,76 @@ describe('installed packages zero config does not read', () => { expect(ds.components).toEqual([]); const { stdout } = await run(['inspect', '--explain', '--no-cache'], root); expect(stdout).toContain( - 'no installed package @acme/ui: it publishes build output (node_modules/@acme/ui/dist/index.mjs) and no component source, and only sources are read', + 'no installed package @acme/ui: its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources, and only sources are read', ); expect(stdout).not.toContain('is compiled'); }); + it('leaves out a kit named like a design system whose exports point at dist/, with src/ published beside it', async () => { + // In a shadcn/ui app, the kit adds no component, no token and no finding: the app reads + // exactly what it reads without the kit installed. + const root = shadcnApp(at('node_modules/@acme/react-ui', KIT)); + const bare = shadcnApp(); + expect(await contents(root)).toEqual(await contents(bare)); + expect((await contents(root)).detected).toBe( + 'components.json (ui: @/components/ui → components/ui)', + ); + expect(await findings(root, 'app/chat.tsx')).toEqual(await findings(bare, 'app/chat.tsx')); + const { stdout } = await run(['inspect', '--explain', '--no-cache'], root); + expect(stdout).toContain( + 'no installed package @acme/react-ui: its exports point at build output (node_modules/@acme/react-ui/dist/index.mjs, node_modules/@acme/react-ui/dist/index.css), not at component sources, and only sources are read', + ); + // As the only candidate, it is not read either. + const alone = fixture({ + 'package.json': json({ name: 'web', dependencies: { '@acme/react-ui': '^1.0.0' } }), + 'app/globals.css': '@import "@acme/react-ui/styles.css";\n', + 'app/page.tsx': 'import { Chat } from "@acme/react-ui";\nexport default () => ;\n', + ...at('node_modules/@acme/react-ui', KIT), + }); + const ds = await load(alone); + expect(ds.config.detected).toBeUndefined(); + expect(ds.components).toEqual([]); + expect(ds.tokens.filter((t) => t.name.startsWith('kit-'))).toEqual([]); + }); + + it('takes no tokens from a stylesheet in the build folder of a package it reads', async () => { + const manifest = JSON.parse(PACKAGE['package.json']) as { + exports: Record; + files: string[]; + }; + const compiled = { + 'node_modules/@acme/design-system/package.json': json({ + ...manifest, + exports: { ...manifest.exports, './styles.css': './dist/styles.css' }, + files: [...manifest.files, 'dist'], + }), + 'node_modules/@acme/design-system/dist/styles.css': + ':root {\n --compiled: oklch(0.6 0.2 30);\n}\n', + 'node_modules/@acme/design-system/dist/partial.css': + ':root {\n --compiled-partial: oklch(0.6 0.2 90);\n}\n', + // A source stylesheet that imports one from the build folder, by a relative path. + 'node_modules/@acme/design-system/src/styles/theme.css': `@import '../../dist/partial.css';\n${PACKAGE['src/styles/theme.css']}`, + }; + const compiledNames = (names: string[]) => names.filter((n) => n.startsWith('compiled')); + // Zero config: the exported stylesheets are the tokens, but the one in dist/. + const names = await tokenNames(npmApp(compiled)); + expect(names).toEqual(expect.arrayContaining(['primary', 'muted', 'radius-card'])); + expect(compiledNames(names)).toEqual([]); + // The app's own @import of it, by its export or by its path, is not followed either. + const configured = npmApp({ + ...compiled, + 'app/globals.css': + '@import "@acme/design-system/globals.css";\n@import "@acme/design-system/styles.css";\n@import "@acme/design-system/dist/partial.css";\n', + 'onsystem.config.json': json({ + components: ['node_modules/@acme/design-system/src/components/**/*.tsx'], + importPath: '@acme/design-system', + }), + }); + const fromApp = await tokenNames(configured); + expect(fromApp).toEqual(expect.arrayContaining(['primary', 'muted'])); + expect(compiledNames(fromApp)).toEqual([]); + }); + it('leaves out a package that is not named like a design system', async () => { const root = fixture({ 'package.json': json({ name: 'web', dependencies: { '@acme/kit': '^1.1.0' } }), From e80b44b2d499e06f3c6433cb1edd3b7b4c574dd0 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 07:45:00 +0100 Subject: [PATCH 3/4] fix: resolve an installed package's exports through the conditions an app resolves A custom condition before default, such as @expo/ui's "expo-source" or react-native-builder-bob's "source", points at src/ for the package's own repository; apps that install it get the build after it. Reading the first condition detected @expo/ui@58.0.18 as an Expo app's design system (147 components from its src/). Installed packages now take each export through import, require, module-sync, node, module, browser, style and default only, for components and for the stylesheets an app's @import reaches. --- .changeset/installed-source-packages.md | 2 +- docs/design-system-packages.md | 2 +- src/detect.ts | 62 ++++++--- test/design-system-packages-docs.test.ts | 22 +++- test/installed-source-packages.test.ts | 158 ++++++++++++++++++++++- 5 files changed, 224 insertions(+), 22 deletions(-) diff --git a/.changeset/installed-source-packages.md b/.changeset/installed-source-packages.md index d5549887..e53d24e3 100644 --- a/.changeset/installed-source-packages.md +++ b/.changeset/installed-source-packages.md @@ -2,4 +2,4 @@ 'onsystem': minor --- -Zero config now reads a design-system package installed from a registry when it is named like one (`@acme/ui`, `@acme/design-system`) and its `exports` point at the `.tsx` or `.jsx` sources it publishes, directly or through barrels, as it reads a workspace package, with no config in the app or the package. Before, such an app got no components and no tokens: a package that publishes `src/`, such as one whose `exports` point at `./src/index.ts`, was skipped as compiled. Its exported stylesheets are its tokens, with the ones of the package they `@import`, and an app stylesheet's `@import "@acme/ui/globals.css"` is followed into `node_modules` when the app's config names no `tokens`. Other installed packages, such as `tailwindcss`, are not read. Unlike in a workspace package, exports that point at `dist/` are not followed back to `src/`, and nothing compiled in the package is read (JavaScript, JSX or CSS in `dist/`, `build/`, `lib/`, `out/`, `esm/` or `cjs/`), so a component kit named like a design system that publishes `src/` next to `dist/` for its source maps is not read as a second design system. With pnpm the package is read in `node_modules/.pnpm`, so the props its components take from their own dependencies resolve. A package whose `exports` point at build output is still not read, and `inspect --explain` now says why: `its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources`, instead of calling every installed package compiled. An app whose own config leaves `components` unset now picks up such a package too. +Zero config now reads a design-system package installed from a registry when it is named like one (`@acme/ui`, `@acme/design-system`) and its `exports` point at the `.tsx` or `.jsx` sources it publishes, directly or through barrels, as it reads a workspace package, with no config in the app or the package. Before, such an app got no components and no tokens: a package that publishes `src/`, such as one whose `exports` point at `./src/index.ts`, was skipped as compiled. Its exported stylesheets are its tokens, with the ones of the package they `@import`, and an app stylesheet's `@import "@acme/ui/globals.css"` is followed into `node_modules` when the app's config names no `tokens`. Other installed packages, such as `tailwindcss`, are not read. Its `exports` are read through the conditions an app resolves (`import`, `require`, `module`, `browser`, `style`, `default`), so a package whose `source` or `expo-source` condition points at `src/` before a `default` that points at its build, such as `@expo/ui` or one built with react-native-builder-bob, is not read either. Unlike in a workspace package, exports that point at `dist/` are not followed back to `src/`, and nothing compiled in the package is read (JavaScript, JSX or CSS in `dist/`, `build/`, `lib/`, `out/`, `esm/` or `cjs/`), so a component kit named like a design system that publishes `src/` next to `dist/` for its source maps is not read as a second design system. With pnpm the package is read in `node_modules/.pnpm`, so the props its components take from their own dependencies resolve. A package whose `exports` point at build output is still not read, and `inspect --explain` now says why: `its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources`, instead of calling every installed package compiled. An app whose own config leaves `components` unset now picks up such a package too. diff --git a/docs/design-system-packages.md b/docs/design-system-packages.md index d797fe87..d4ffcf51 100644 --- a/docs/design-system-packages.md +++ b/docs/design-system-packages.md @@ -86,7 +86,7 @@ The CLI, the MCP server, the ESLint plugin, the hook and the Action read the pac ### Without the config -An app reads an installed package that declares no config, or whose declared config is not published, as zero config reads a workspace package (step 3 of [zero config](configuration.md#zero-config)), when the package is named like a design system (`@acme/ui`, `@acme/ui-kit`, `@acme/design-system`, `acme-ui`) and its `exports` point at the `.tsx` or `.jsx` sources it publishes: directly (`"./components/*": "./src/components/*.tsx"`) or through barrels (`".": "./src/index.ts"`). Unlike in a workspace package, exports that point at `dist/` are not followed back to `src/`: a component kit published with `src/` next to `dist/` for its source maps is not the app's design system, even when it is named like one (`@acme/react-ui`). Nothing compiled in the package is read: no JavaScript, JSX or CSS in a build folder (`dist/`, `build/`, `lib/`, `out/`, `esm/`, `cjs/`). `inspect --explain` says which it was: `found installed package @acme/ui: node_modules/@acme/ui, 3 component files`, or `no installed package @acme/ui: its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources, and only sources are read`. +An app reads an installed package that declares no config, or whose declared config is not published, as zero config reads a workspace package (step 3 of [zero config](configuration.md#zero-config)), when the package is named like a design system (`@acme/ui`, `@acme/ui-kit`, `@acme/design-system`, `acme-ui`) and its `exports` point at the `.tsx` or `.jsx` sources it publishes: directly (`"./components/*": "./src/components/*.tsx"`) or through barrels (`".": "./src/index.ts"`). Its `exports` are read through the conditions an app resolves: those of Node.js (`import`, `require`, `default`), and `module`, `browser` and `style`. A condition for the package's own repository, such as `source` (react-native-builder-bob) or `expo-source` (`@expo/ui`), is passed over: `{ "source": "./src/index.tsx", "default": "./lib/module/index.js" }` points at build output, which is what an app that installs the package gets. Unlike in a workspace package, exports that point at `dist/` are not followed back to `src/`: a component kit published with `src/` next to `dist/` for its source maps is not the app's design system, even when it is named like one (`@acme/react-ui`). Nothing compiled in the package is read: no JavaScript, JSX or CSS in a build folder (`dist/`, `build/`, `lib/`, `out/`, `esm/`, `cjs/`). `inspect --explain` says which it was: `found installed package @acme/ui: node_modules/@acme/ui, 3 component files`, or `no installed package @acme/ui: its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources, and only sources are read`. - **It is read where it really is.** With pnpm, that is its folder in `node_modules/.pnpm`, where pnpm also installs the packages it depends on, so the props its components take from them are known. When pnpm's store is outside the project, the package is read through the app's `node_modules`; there, the props its components take from their own dependencies are not known. - **Its exported stylesheets are the tokens** (`"./globals.css": "./src/styles/globals.css"`), with the stylesheets of the package that they `@import`, but none in a build folder: `"./styles.css": "./dist/index.css"` is compiled, and brings no tokens. Other installed packages are not read: `@import "tailwindcss"` in them brings none of Tailwind's palette. An app whose config names the package's components but no `tokens` gets them through its own stylesheet, whose `@import "@acme/ui/globals.css"` is followed into `node_modules`. diff --git a/src/detect.ts b/src/detect.ts index 9c4ceb44..017eae6c 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -562,9 +562,10 @@ function fromPackageComponentsJson( * A package whose `exports` point at component source files is a design system. An export * that leads to a barrel (`./components/button` → `components/button/index.ts`, or `.` → * `src/index.tsx` behind `dist/`) stands for the files the barrel re-exports, each - * suggested with the export's specifier. An `installed` package's exports are read as they - * are, not from `dist/` back to `src/`: a widget kit publishes `src/` beside `dist/` for its - * source maps, and it is not the app's design system. Its compiled files ({@link isBuildOutput}) + * suggested with the export's specifier. An `installed` package's exports are read as an app + * resolves them ({@link APP_CONDITIONS}), not from `dist/` back to `src/`: a widget kit + * publishes `src/` beside `dist/` for its source maps, or points a `source` condition at it for + * its own repository, and it is not the app's design system. Its compiled files ({@link isBuildOutput}) * are not read at all: no barrel, component or stylesheet in them. */ function fromPackage( @@ -772,7 +773,8 @@ function importedSubpaths(root: string, names: string[]): Map | undefined; const mappings: ImportMapping[] = []; for (const [key, value] of entries) { - const target = exportTarget(value); + const target = exportTarget(value, asWritten ? APP_CONDITIONS : undefined); if (!target) continue; const source = asWritten ? undefined @@ -837,20 +839,42 @@ function sourcePattern(dir: string, target: string): string { return relative; } -/** The source file an export condition map points at: the first string target that is not a declaration file. */ -function exportTarget(value: unknown): string | undefined { +/** + * The `exports` conditions an app resolves an installed package through: Node.js's own + * (`import`, `require`, `module-sync`, `node`, `default`), and the `module`, `browser` and + * `style` that bundlers and Tailwind CSS add. Others, such as `source`, `expo-source` and + * `development`, lead the package's own repository to its sources, and an app that installs it + * builds the target after them: `@expo/ui`'s `"default": "./build/universal/index.js"`, not its + * `"expo-source": "./src/universal/index.ts"` before it. + */ +const APP_CONDITIONS: ReadonlySet = new Set([ + 'import', + 'require', + 'module-sync', + 'node', + 'module', + 'browser', + 'style', + 'default', +]); + +/** + * The source file an export condition map points at: the first string target that is not a + * declaration file, through any condition but `types`, or through `conditions` alone. + */ +function exportTarget(value: unknown, conditions?: ReadonlySet): string | undefined { if (typeof value === 'string') return value.endsWith('.d.ts') ? undefined : value; if (Array.isArray(value)) { for (const item of value) { - const target = exportTarget(item); + const target = exportTarget(item, conditions); if (target) return target; } return undefined; } if (value && typeof value === 'object') { for (const [condition, nested] of Object.entries(value)) { - if (condition === 'types') continue; - const target = exportTarget(nested); + if (condition === 'types' || (conditions && !conditions.has(condition))) continue; + const target = exportTarget(nested, conditions); if (target) return target; } } @@ -1003,6 +1027,7 @@ export function stylesheetResolver( dir, subpath ? `./${subpath}` : '.', (f) => f.endsWith('.css') && !(installed && isBuildOutput(installed, f)), + installed ? APP_CONDITIONS : undefined, ) ?? (subpath ? stylesheet(path.join(dir, subpath), installed) @@ -1040,13 +1065,14 @@ function installedPackageOf(file: string): string | undefined { /** * The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition - * (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the - * package, that `accept` takes. + * (`style`, `import`, `default`), or through `conditions` alone, and `*` patterns: the first + * target on disk, inside the package, that `accept` takes. */ function exportedFile( dir: string, subpath: string, accept: (file: string) => boolean, + conditions?: ReadonlySet, ): string | undefined { const exports = readJson(path.join(dir, 'package.json'))?.exports; const entries: [string, unknown][] = @@ -1078,7 +1104,7 @@ function exportedFile( } matches.sort((a, b) => b.rank - a.rank); for (const { middle, value } of matches) { - for (const target of exportTargets(value)) { + for (const target of exportTargets(value, conditions)) { const file = path.resolve(dir, target.replaceAll('*', middle)); if (isInside(dir, file) && accept(file) && isFile(file)) return file; } @@ -1086,13 +1112,15 @@ function exportedFile( return undefined; } -/** Every target of an export condition map, in order, but its types. */ -function exportTargets(value: unknown): string[] { +/** Every target of an export condition map, in order, but its types, or those of `conditions` alone. */ +function exportTargets(value: unknown, conditions?: ReadonlySet): string[] { if (typeof value === 'string') return [value]; - if (Array.isArray(value)) return value.flatMap(exportTargets); + if (Array.isArray(value)) return value.flatMap((item) => exportTargets(item, conditions)); if (value && typeof value === 'object') { return Object.entries(value).flatMap(([condition, nested]) => - condition === 'types' ? [] : exportTargets(nested), + condition === 'types' || (conditions && !conditions.has(condition)) + ? [] + : exportTargets(nested, conditions), ); } return []; diff --git a/test/design-system-packages-docs.test.ts b/test/design-system-packages-docs.test.ts index 4bce919a..05e3d2c3 100644 --- a/test/design-system-packages-docs.test.ts +++ b/test/design-system-packages-docs.test.ts @@ -401,7 +401,24 @@ describe('what apps get from a design-system package (docs/design-system-package expect(withoutTheConfig).toContain(`\`${why}\``); expect(rows()[4]?.[0]).toContain('with `exports` that point at `dist/`'); const distOnly = without(published(PACKAGE), (file) => file.startsWith('src/')); - for (const pkg of [published(PACKAGE), distOnly]) { + // A `source` condition for the package's own repository, before the build that apps get. + const sourceCondition = { + ...published(PACKAGE), + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + files: ['dist', 'src', 'theme.css'], + exports: { + '.': { + source: './src/index.tsx', + types: './dist/index.d.ts', + import: './dist/index.mjs', + }, + './theme.css': './theme.css', + }, + }), + }; + for (const pkg of [published(PACKAGE), distOnly, sourceCondition]) { const app = installedApp(pkg); const { rules } = await check(app); expect(rules).toEqual(new Set(['icon-button-accessible-name'])); @@ -411,6 +428,9 @@ describe('what apps get from a design-system package (docs/design-system-package expect(withoutTheConfig).toContain( 'Unlike in a workspace package, exports that point at `dist/` are not followed back to `src/`', ); + expect(withoutTheConfig).toContain( + "A condition for the package's own repository, such as `source` (react-native-builder-bob) or `expo-source` (`@expo/ui`), is passed over", + ); }); it('runs the token rules only for what the published tokens define', async () => { diff --git a/test/installed-source-packages.test.ts b/test/installed-source-packages.test.ts index 924d121a..158ff279 100644 --- a/test/installed-source-packages.test.ts +++ b/test/installed-source-packages.test.ts @@ -198,6 +198,60 @@ const KIT = { 'src/styles.css': ':root {\n --kit-primary-color: rgb(28, 28, 28);\n}\n', }; +/** + * `@acme/ui` with three components in `src/`, behind the barrel `index`, and its build in + * `build`: a package whose `exports` point a condition of its own repository at `src/`, before + * the build output that apps resolve. + */ +function builtWithSource(exports: unknown, index: string, build: string): Record { + return at('node_modules/@acme/ui', { + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + main: `./${build}/index.js`, + exports, + files: ['src', build], + }), + [index]: 'export * from "./button";\nexport * from "./card";\nexport * from "./input";\n', + 'src/button.tsx': BUTTON, + 'src/card.tsx': CARD, + 'src/input.tsx': INPUT, + [`${build}/index.js`]: + 'export * from "./button.js";\nexport * from "./card.js";\nexport * from "./input.js";\n', + [`${build}/button.js`]: 'export function Button() { return null; }\n', + [`${build}/card.js`]: 'export function Card() { return null; }\n', + [`${build}/input.js`]: 'export function Input() { return null; }\n', + }); +} + +/** `@expo/ui`'s layout: `expo-source` before `default`, for types too. */ +const EXPO_SOURCE = builtWithSource( + { + '.': { + types: { 'expo-source': './src/index.ts', default: './build/index.d.ts' }, + 'expo-source': './src/index.ts', + default: './build/index.js', + }, + './package.json': './package.json', + }, + 'src/index.ts', + 'build', +); + +/** react-native-builder-bob's layout: `source` before `default`, built to `lib/module`. */ +const BOB_SOURCE = builtWithSource( + { + '.': { + source: './src/index.tsx', + types: './lib/typescript/src/index.d.ts', + default: './lib/module/index.js', + }, + './package.json': './package.json', + }, + 'src/index.tsx', + 'lib/module', +); + /** A shadcn/ui app (components.json, components/ui) that also uses the kit. */ function shadcnApp(extra: Record = {}): string { return fixture({ @@ -334,6 +388,32 @@ describe('an installed design-system package that publishes its sources', () => expect(button?.props.map((p) => p.name)).not.toContain('render'); }); + it('is read through the conditions an app resolves, past those of its own repository', async () => { + const manifest = JSON.parse(PACKAGE['package.json']) as Record; + const root = npmApp({ + 'node_modules/@acme/design-system/package.json': json({ + ...manifest, + exports: { + '.': { + source: './src/index.ts', + import: { types: './src/index.ts', default: './src/index.ts' }, + require: './dist/index.cjs', + }, + './components/*': { + 'expo-source': './src/components/ui/*.tsx', + browser: './src/components/ui/*.tsx', + default: './dist/components/*.js', + }, + './globals.css': { development: './dist/globals.css', style: './src/styles/globals.css' }, + }, + }), + }); + const ds = await load(root); + expect(ds.config.detected).toBe('installed package @acme/design-system'); + expect(ds.components.map((c) => c.name).sort()).toEqual(['Button', 'Card', 'Input']); + expect(ds.tokens.map((t) => t.name)).toEqual(expect.arrayContaining(['primary', 'muted'])); + }); + it('is read from the workspace root, where npm hoists it', async () => { const workspace = fixture({ 'package.json': json({ name: 'acme', private: true, workspaces: ['apps/*'] }), @@ -383,6 +463,70 @@ describe('installed packages zero config does not read', () => { expect(stdout).not.toContain('is compiled'); }); + it.each([ + ['expo-source before default, as @expo/ui has them', EXPO_SOURCE, 'build/index.js'], + [ + 'source before default, as react-native-builder-bob writes them', + BOB_SOURCE, + 'lib/module/index.js', + ], + [ + 'source before import and require, as older react-native-builder-bob versions write them', + builtWithSource( + { + '.': { + source: './src/index.tsx', + import: { types: './lib/typescript/index.d.ts', default: './lib/module/index.js' }, + require: { types: './lib/typescript/index.d.ts', default: './lib/commonjs/index.js' }, + }, + }, + 'src/index.tsx', + 'lib/module', + ), + 'lib/module/index.js', + ], + [ + 'development before default', + builtWithSource( + { '.': { development: './src/index.ts', default: './dist/index.js' } }, + 'src/index.ts', + 'dist', + ), + 'dist/index.js', + ], + ])( + 'reads no source that only a condition of its own repository leads to: %s', + async (_, files, built) => { + const root = fixture({ + 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '^1.0.0' } }), + 'app/page.tsx': + 'import { Button } from "@acme/ui";\nexport default () =>