Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/installed-source-packages.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 17 additions & 8 deletions docs/design-system-packages.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 `<Button>`, check none of the app's imports from the package, and nothing says so. With it, the same rules run as with the package's config, but the imports that findings and `get_component` suggest name `@acme/ui`, not the subpath. `init --force` in such an app says what it loses, but it writes no `importPath` there either.
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 package named like a design system, as a workspace package or installed in `node_modules` with `exports` that point at its component sources ([without the config](#without-the-config)), but not one named otherwise (`@acme/kit`) or one whose `exports` point at compiled code. 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 `<Button>`, check none of the app's imports from the package, and nothing says so. With it, the same rules run as with the package's config, but the imports that findings and `get_component` suggest name `@acme/ui`, not the subpath. `init --force` in such an app says what it loses, but it writes no `importPath` there either.

The declared config is read as JSON and never run, so a dependency cannot make an app run its code. A field that names a `.js` or `.ts` config, a file outside the package, or a file that does not exist is an error that names the package when it is a workspace package, which its team can fix. In a package installed in `node_modules`, the declaration is skipped with a warning, and the app is read as if the package declared none. An app with a config of its own reads that instead.

Expand All @@ -67,12 +67,13 @@ The declared config is read as JSON and never run, so a dependency cannot make a

An app that installs the package reads what was published, and onsystem reads components from their source, never from compiled code (except for the [published libraries](configuration.md#published-libraries-mui) it knows). So the rules that run in an app depend on how it gets the package and what the package publishes:

| The app gets the package | Rules that run in the app |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| As a workspace package | All eight [rules](rules.md) |
| From a registry, with the config, the token files and the component sources (`src/`) published | All eight |
| From a registry, with the config, the token files and compiled `dist/` published, but not `src/` | `no-hardcoded-color`, `no-hardcoded-spacing` and `no-hardcoded-radius`, and `icon-button-accessible-name` on native elements |
| From a registry, without the config | What zero config finds in the app itself, and nothing from the package: the declaration is skipped with a warning |
| The app gets the package | Rules that run in the app |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| As a workspace package | All eight [rules](rules.md) |
| From a registry, with the config, the token files and the component sources (`src/`) published | All eight |
| From a registry, with the config, the token files and compiled `dist/` published, but not `src/` | `no-hardcoded-color`, `no-hardcoded-spacing` and `no-hardcoded-radius`, and `icon-button-accessible-name` on native elements |
| From a registry, without the config, named like a design system (`@acme/ui`), with `exports` that point at the component sources (`./src/index.ts`) | All eight, through zero config ([without the config](#without-the-config)) |
| From a registry, without the config, with `exports` that point at `dist/`, or named otherwise (`@acme/kit`) | What zero config finds in the app itself, and nothing from the package. A declaration whose config is not published is skipped with a warning |

In an app whose package publishes only `dist/`:

Expand All @@ -83,6 +84,14 @@ In an app whose package publishes only `dist/`:

The CLI, the MCP server, the ESLint plugin, the hook and the Action read the package the same way, so this holds in each. To give these apps every rule, add `src/` to `files` in `package.json`; `init` says when `files` leaves out the component or token files.

### 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"`). Its `exports` are read through the conditions an app resolves: those of Node.js (`import`, `require`, `module-sync`, `node`, `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/`), and no export that points at JavaScript in any other folder (`./index.js`, `./es/index.mjs`, `./build-module/index.js`), whose re-exports are not followed. So a package that publishes a `.js` barrel in front of its `.jsx` files is not read either; point its `exports` at the `.jsx` files, or declare the config. `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. Only the first condition an app resolves counts, as for components: `"./x.css": { "style": "./dist/x.css", "default": "./src/x.css" }` gives apps the compiled `dist/x.css`, so `src/x.css` brings no tokens either. 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`.
- **A package named otherwise, or one whose `exports` point at compiled code, needs the config,** as does anything beyond components and tokens: the rules' severities, `elements`, `importPath` and `libraries`.

## On real code

The [corpus](../corpus) runs `init --yes` in Dub's `@dub/ui`, a workspace package that Dub also publishes, then checks Dub's app, which has no config of its own (the `dub-web-package` run). The app takes on the config `@dub/ui` declares, and reports what zero config reports, file for file.
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Most problems show up in `npx onsystem inspect`: from the app's folder it prints
`inspect` prints `Components (0 + 0 parts)`, or `check` says `No design system found (no components or color tokens)`.

1. **Run it from the app's folder**, the one with the app's `package.json` and, in a shadcn/ui project, `components.json`, or from the monorepo root: see the [next section](#a-monorepo-root-misses-a-project).
2. **Read the `detected` line,** or run `inspect --explain` for each candidate's reason (`the ui alias ~/ui resolves through neither tsconfig paths nor a workspace package`, `not a workspace package: an installed package is compiled`). It says how the components were found. With no `detected` line, none of the [zero-config layouts](configuration.md#zero-config) matched: a `components.json`, a design-system package's `exports`, a workspace dependency named like a design system (`@acme/ui`), or a flat `src/` of components that wrap a primitives library.
2. **Read the `detected` line,** or run `inspect --explain` for each candidate's reason (`the ui alias ~/ui resolves through neither tsconfig paths nor a workspace package`, `its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources`). It says how the components were found. With no `detected` line, none of the [zero-config layouts](configuration.md#zero-config) matched: a `components.json`, a design-system package's `exports`, a dependency named like a design system (`@acme/ui`), in the workspace or installed with `exports` that point at its component sources, or a flat `src/` of components that wrap a primitives library.
3. **Point at them with a config file.** `npx onsystem init` asks where the components and tokens are and writes it. An `onsystem.config.json` in the app's folder with a `components` glob is enough; add `tokens` when the stylesheet is not one of the default paths:

```json
Expand Down
Loading
Loading