diff --git a/.changeset/installed-source-packages.md b/.changeset/installed-source-packages.md new file mode 100644 index 00000000..c0318fde --- /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` 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-sync`, `node`, `module`, `browser`, `style`, `default`), and the first of them decides, for its components and its stylesheets alike, 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/`, or an export that points at JavaScript anywhere, such as `./index.js` or `./es/index.mjs`, whose re-exports are not followed), 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 8ab7cb5a..ff792869 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 whose `exports` point at 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)\//; + +describe('an installed design-system package that publishes its sources', () => { + it('is read with zero config, through its exports and barrels', async () => { + const root = npmApp(); + 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.getComponent('Button')?.importPath).toBe('@acme/design-system'); + expect(ds.model.warnings.join('\n')).not.toContain('Not read'); + + const explained = await run(['inspect', '--explain', '--no-cache'], root); + expect(explained.stdout).toContain( + 'found installed package @acme/design-system: node_modules/@acme/design-system, 3 component files', + ); + expect(await getComponent(root, 'Button')).toContain( + 'import { Button } from "@acme/design-system"', + ); + }); + + it('gets the component and token rules in check', async () => { + expect(await findings(npmApp())).toEqual( + expect.arrayContaining([ + 'no-unknown-variant: "danger" is not a valid variant for