diff --git a/.changeset/layered-configs.md b/.changeset/layered-configs.md new file mode 100644 index 00000000..46ffcbde --- /dev/null +++ b/.changeset/layered-configs.md @@ -0,0 +1,13 @@ +--- +'onsystem': minor +--- + +An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's, unless it sets `components` of its own: `components`, with the package's `importPath`, `docs`, `elements`, `tokens` and `libraries`. A config that sets `components` reads its tokens as before: without `tokens`, from the app's own stylesheet (`app/globals.css`) and the ones it imports. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` element by element; the package's `exclude` patterns are added to the app's; its `overrides`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's rules where its config leaves them out, and the package's components and tokens when it sets no `components` of its own. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. + +A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read. + +`init --force` in an app on such a package, without a config or with one whose `components` match no file outside the package, writes a config with only `$schema`, for the app's own rules and overrides, and keeps the package's components, `importPath`, tokens and rules. Before, it wrote the package's paths into the app's config, or a config that lost the package's components. In an app whose config names components of its own, it writes them again, as before. + +`init` in an app whose design system zero config reads from an installed package leaves `components` and `tokens` to zero config. It wrote paths into the package's folder, which pnpm names by version (`node_modules/.pnpm/@acme+ui@1.0.0_react@19.0.0/node_modules/@acme/ui/…`), so after an upgrade `check` failed with `Token file not found`, and the component files it listed matched nothing. + +In a design-system package whose `files` leave out its component sources, `init` says that apps installing it read its components from the type declarations it publishes, when its `package.json` names some. It said they get no component checks, which holds only without type declarations. diff --git a/docs/configuration.md b/docs/configuration.md index 88629683..ddc0ecc1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ Most projects need no config: onsystem finds the design system the way your app ## Zero config -Without a config file of its own, a project first takes on the config its design-system package declares in its `package.json`, if one of its dependencies does ([design-system packages](design-system-packages.md)). With a config file of its own, it reads none of the package's config, so copy the package's `components`, `importPath` and `tokens` into it ([what it loses otherwise](design-system-packages.md#in-the-apps)). Without a config file (or with one that leaves `components` unset), the server looks for the design system in this order, and `inspect` prints what it found on its `detected` line: +Without a config file of its own, a project first takes on the config its design-system package declares in its `package.json`, if one of its dependencies does ([design-system packages](design-system-packages.md)). A config file of its own builds on the package's: its `rules` apply over the package's, and, unless it sets `components` of its own, what it leaves out, such as `components`, `importPath` and `tokens`, comes from there; `"extends": false` reads it alone ([an app's own config](design-system-packages.md#an-apps-own-config)). Without a config file (or with one that leaves `components` unset), the server looks for the design system in this order, and `inspect` prints what it found on its `detected` line: 1. **`components.json`** (shadcn/ui): the `ui` alias, resolved through tsconfig `paths` (`@/registry/new-york-v4/ui`) or a workspace package's `exports` (`@workspace/ui/components` in shadcn's monorepo templates), and `tailwind.css` for tokens. The folders `npx shadcn add` fills from the other registries it names are read too, as read-only design systems ([code from shadcn registries](#code-from-shadcn-registries)). 2. **The root is a design-system package** (when it has no `components.json`): its `package.json` `exports` lead to three or more component files (`"./button": "./src/components/button.tsx"` or `"./components/*": "./src/components/*.tsx"`). An export that points at a barrel (`"./components/button": "./components/button/index.ts"`, as `@calcom/ui` has them) stands for the `.tsx` files the barrel re-exports, through `export *`, `export { … } from` and imported bindings exported again. An export built to `dist/` is read from its source: the `entry` of a tsup, tsdown or Vite config (read as text, never run), else the same path under `src/`, and `.` from `src/index.*`, so Dub's `@dub/ui` (`"import": "./dist/index.mjs"`) is read from `src/index.tsx` and what it re-exports. Exported stylesheets that exist are read as tokens, else `src/globals.css` and the like. @@ -19,6 +19,8 @@ A candidate whose files cannot be found is skipped. Components found through `ex An app built on MUI has no components folder of its own: its design system is `@mui/material`, installed in `node_modules`. When the project's `package.json` depends on `@mui/material` and the package is installed, onsystem reads its components from the package's type declarations, the same files the app's own imports resolve to, with the TypeScript checker. `inspect` says so on its `library` line: `library MUI (@mui/material 9.4.0): 149 components from its type declarations`. This happens whatever the config says about `components`, and works for MUI 5 to 9. To turn it off, set `"libraries": false` in the config; a list (`"libraries": ["mui"]`) reads only the libraries it names. `inspect` then says the library is `left out by "libraries"`. +A design-system package installed with compiled code and type declarations, whose component sources the project does not read, is read the same way, from its `.d.ts` ([how it works](how-it-works.md)): one that declares its config ([design-system packages](design-system-packages.md#what-apps-get)); without one, one named like a design system in the app's own npm scope (`@acme/ui` for `@acme/web`); or the dependency the config names as its `importPath` (`"importPath": "@dub/ui"`). `inspect` lists it on its `library` line (`Design-system package (@acme/ui 1.0.0): 15 components from its type declarations`). `"libraries": false` turns that off too; a list of libraries leaves it on. + - **Components and props.** Every export with a capitalised name whose type takes props is a component: function and class components and MUI's `OverridableComponent`. Its props are those of each of its call signatures, so ``, with `ChevronLeft` from `@acme/ui`, is reported for having no accessible name. Without `importPath`, that button is not reported. +- **`importPath` does not turn the component rules on**, with no declarations to read. `init` sets it when apps import the package by one name, such as `@acme/ui`. With no component to compare against, imports from the package are not checked: `` from `@acme/ui` is not reported, although the package has no `Badge`. It still tells `icon-button-accessible-name` that an icon the package re-exports is an icon, so ``, with `ChevronLeft` from `@acme/ui`, is reported for having no accessible name. Without `importPath`, that button is not reported. - **The token rules check against the published tokens:** `no-hardcoded-color` when they hold colors, `no-hardcoded-spacing` and `no-hardcoded-radius` when they set a spacing scale and radii. - **`--require-design-system` fails** (exit code 2), and it is on by default in the [GitHub Action](required-check.md). -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. +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, publish the type declarations with `dist/`, or add `src/` to `files` in `package.json`; `init` says when `files` leaves out the component or token files, and whether apps then read the components from type declarations. ### Without the config @@ -90,7 +101,7 @@ An app reads an installed package that declares no config, or whose declared con - **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`. +- **A package named otherwise, or one whose `exports` point at compiled code, needs the config,** or type declarations that are read without it (in the app's own npm scope, or named by its `importPath`), as does anything beyond components and tokens: the rules' severities, `elements`, `importPath` and `libraries`. ## On real code diff --git a/docs/plugin.md b/docs/plugin.md index 74052190..b6a2044a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -29,7 +29,7 @@ The plugin is the fastest way to keep Claude on your design system. It bundles t - **Errors come back to be fixed.** The hook runs after the file is written. It exits with Claude Code's blocking-error code, and the findings, each with its fix, go to Claude, which corrects the file before moving on. - **Warnings don't.** They reach Claude as a note, without blocking. - **Only what Claude just changed.** After an Edit it lists the findings on the lines the edit changed, not the unchanged lines it repeats around them, and only counts older ones. When it can't tell which lines those are (the new text is in the file more than once, or a formatter rewrote it), it passes the findings on as a note and doesn't block. It honours a [baseline](ci.md#adopting-it-in-an-existing-codebase). -- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md) that publishes only compiled code, the hook checks the tokens, and their errors block. +- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md#what-apps-get) that publishes only compiled code, the hook checks the components it reads from the package's type declarations, and the tokens, and their errors block; when the package publishes no type declarations, it checks no component of the package, only the tokens and native elements. - **The design system's own files are left alone.** Like `check`, it skips them: an edit to `components/ui/button.tsx` changes the design system, which is a call for you and your reviewers rather than a lint error. - **So is what `check .` leaves out.** A file the config's `exclude` covers, tests and stories by default (`*.test.tsx`, `*.stories.tsx`, `__tests__` folders) or a folder you list such as `app/legacy`, is not checked: the hook runs `check --skip-excluded`, which leaves such a file out even though it is named. Add a folder to `exclude` to keep the hook off code you don't hold to the design system. A project's own install from before that flag checks the file, as it did before. - **It says when it can't check.** When the CLI can't run (npx can't fetch it because of a [min-release-age policy](troubleshooting.md#a-min-release-age-policy-blocks-npx), a private registry or no network, or the config is broken), it says so once per session and project, as a message to you, and never blocks the edit. When npm could not reach the registry, or npx didn't finish within the hook's 55 seconds, the hook doesn't start npx again for ten minutes, so an offline session waits for npm once, not on every edit. diff --git a/schema.json b/schema.json index 9071e9f4..5479e9bc 100644 --- a/schema.json +++ b/schema.json @@ -7,6 +7,10 @@ "$schema": { "type": "string" }, + "extends": { + "description": "Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency, found from the dependencies): its rules apply over the package's, and, unless it sets components of its own, what it leaves out comes from the package's (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone.", + "type": "boolean" + }, "components": { "description": "Globs for component source files, relative to the root.", "anyOf": [ @@ -103,7 +107,7 @@ "type": "string" }, "importPath": { - "description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths.", + "description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths.", "type": "string" }, "elements": { diff --git a/src/cli.ts b/src/cli.ts index 67825026..532369d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1124,7 +1124,7 @@ async function inspect( } const lines: string[] = [ `root ${model.root}`, - `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : '(defaults)'}`, + `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config, (f) => displayPath(io.cwd, f))}` : '(defaults)'}`, ...(config.detected ? [`detected ${config.detected}`] : []), ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`), `files ${plural(model.stats.files.components, 'component file')}, ${plural(model.stats.files.tokens, 'token file')}, ${plural(model.stats.files.docs, 'doc')}`, @@ -1296,8 +1296,9 @@ async function inspectWorkspace( const roots = ds.roots(); const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`); const own = - project.config.configFile && project.config.configFile !== workspace.configFile - ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` + project.config.configFile && + (project.config.configFile !== workspace.configFile || project.config.designSystemPackage) + ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config, (f) => displayPath(workspace.root, f))}` : undefined; lines.push( '', @@ -1362,9 +1363,15 @@ function explainWorkspace( return lines; } -/** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */ -function declaredBy(config: ResolvedConfig): string { - return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''; +/** + * ` (declared by @acme/ui)` after a config a design-system package declares, `, over + * packages/ui/onsystem.config.json (declared by @acme/ui)` after one read over it; empty otherwise. + */ +function declaredBy(config: ResolvedConfig, display: (file: string) => string): string { + const declared = config.designSystemPackage; + if (!declared) return ''; + const over = config.configFile === declared.config ? '' : `, over ${display(declared.config)}`; + return `${over} (declared by ${declared.name})`; } /** `A, B, C` up to a dozen names, then `and 40 more`. */ diff --git a/src/config.ts b/src/config.ts index 87f82651..19291c5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -130,6 +130,16 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))] const configShape = { $schema: z.string().optional(), + extends: z + .boolean({ + // `"extends": "@acme/ui"` otherwise reads "expected boolean, received string". + error: (issue) => + `"extends" is true or false, not ${JSON.stringify(issue.input)}: the design-system package is found from the dependencies, the one whose package.json declares its config ("onsystem"). Leave "extends" out to build on it, or set it to false to read this config alone.`, + }) + .optional() + .describe( + 'Whether this config builds on the one a design-system package declares ("onsystem" in the package.json of a dependency, found from the dependencies): its rules apply over the package\'s, and, unless it sets components of its own, what it leaves out comes from the package\'s (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone.', + ), components: globsSchema .optional() .describe('Globs for component source files, relative to the root.'), @@ -151,7 +161,7 @@ const configShape = { .string() .optional() .describe( - 'Package name components are imported from, e.g. "@acme/ui", or a pattern for packages imported by path: "@acme/ui/{path}" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths.', + 'Package name components are imported from, e.g. "@acme/ui", or a pattern for packages imported by path: "@acme/ui/{path}" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths.', ), elements: z .record(z.string(), z.string()) @@ -268,12 +278,21 @@ export interface ResolvedConfig { */ base?: string | undefined; /** - * The design-system package whose config this is: a dependency whose package.json declares it - * (`"onsystem": "./onsystem.config.json"`), taken on by a project with no config of its own. + * The design-system package whose config this project reads: a dependency whose package.json + * declares it (`"onsystem": "./onsystem.config.json"`). A project with no config of its own + * takes it on (`configFile` is the package's); one with a config of its own, or a workspace + * root's, reads it under that one (`configFile` is that one), unless it sets `"extends": false`. * Its paths are rebased from the package; its `tsconfig`, `overrides` and * `includeDesignSystem` stay with it. */ designSystemPackage?: DesignSystemPackage | undefined; + /** + * The project's config sets no `components`, so what it leaves out of `components`, + * `importPath`, `docs`, `elements`, `tokens` and `libraries` comes from `designSystemPackage`'s. + */ + inherits?: boolean | undefined; + /** The project's config sets `"extends": false`, so no design-system package's config is read. */ + extendsOff?: boolean | undefined; /** `components` comes from the config file or `--components`, not from detection or the defaults. */ configured?: boolean | undefined; /** @@ -406,9 +425,11 @@ export async function loadConfigWith( const finder = (dir: string) => packages?.root === dir ? packages : (packages = new PackageFinder(dir)); let lookup = context.lookup; + /** + * The project's own config: a config file in the folder searched, `--config`, or a workspace + * root's that a project in it takes on. + */ let configFile: string | undefined; - /** The design-system package whose config this project takes on, having none of its own. */ - let declared: DesignSystemPackage | undefined; if (options.config) { configFile = path.resolve(cwd, options.config); } else { @@ -418,11 +439,30 @@ export async function loadConfigWith( const legacy = find(LEGACY_CONFIG_FILES); if (legacy) deprecations.push(legacyConfigNotice(legacy, configFile)); configFile ??= legacy ?? ownDeclaredConfig(searchDir); - if (!configFile) { - lookup ??= findDesignSystemPackage(searchDir, finder(searchDir)); - if (lookup?.problems.length) throw declarationError(lookup, searchDir); + } + const config: ParsedConfig = configFile ? await readConfig(configFile, options) : {}; + // An explicit --config sets the root by its folder; one found by name is in the folder searched. + const root = options.root + ? path.resolve(cwd, options.root) + : options.config && configFile + ? path.dirname(configFile) + : searchDir; + if (!isDirectory(root)) throw new ConfigError(`Project root not found: ${root}`); + + // The design-system package a dependency declares a config for: a project with no config of + // its own takes it on, and one with a config of its own reads it under that one. + let declared: DesignSystemPackage | undefined; + if (config.extends !== false) { + lookup ??= findDesignSystemPackage(root, finder(root)); + if (!lookup?.problems.length) { declared = lookup?.found; - configFile = declared?.config; + } else if (!configFile) { + throw declarationError(lookup, root); + } else { + // The project's own config still works on its own; the package's team can fix theirs. + warnings.push( + `${lookup.problems.map((p) => `${p.name}: ${p.problem}`).join('\n')}\nSo ${configFile} is read alone, without the config a design-system package declares. Fix the "${PACKAGE_FIELD}" field in that package, or set "extends": false in ${path.basename(configFile)}.`, + ); } } // Installed packages whose declaration cannot be read: the project is read as if they had none. @@ -431,48 +471,30 @@ export async function loadConfigWith( `${name}'s onsystem config was skipped: ${problem} ${name} is installed in node_modules, so this project is read as if it declared none.`, ); } + const pkg: ParsedConfig | undefined = declared + ? await readConfig(declared.config, options) + : undefined; - let raw: unknown = {}; - if (configFile) { - if (!fs.existsSync(configFile)) throw new ConfigError(`Config file not found: ${configFile}`); - if (options.jsonConfigOnly && !configFile.endsWith('.json')) { - throw new ConfigError( - `${configFile} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`, - ); - } - raw = await readConfigFile(configFile); - } - const parsed = configSchema.safeParse(raw); - if (!parsed.success) { - throw new ConfigError( - `Invalid config in ${configFile ?? '(defaults)'}:\n${z.prettifyError(parsed.error)}`, - ); - } - const config = parsed.data; - // An explicit --config sets the root by its folder; one found by name is in the folder searched. - const root = options.root - ? path.resolve(cwd, options.root) - : options.config && configFile - ? path.dirname(configFile) - : searchDir; - if (!isDirectory(root)) throw new ConfigError(`Project root not found: ${root}`); // A workspace root's config, taken on by a project in it: its paths are relative to the // workspace root. Files and token globs are rebased onto the project's folder; `exclude` // keeps the patterns that can match in it; `overrides` are matched from the workspace root. - // A design-system package's config, taken on by an app that depends on it: its paths are - // relative to the package, and `exclude` keeps every pattern, rebased onto the package. - const base = declared ? declared.dir : options.base ? path.resolve(cwd, options.base) : root; - const inherited = base !== root; - const fromBase = (p: string) => - inherited ? toPosix(path.relative(root, path.resolve(base, slashGlob(p)))) || '.' : p; + // A design-system package's config: its paths are relative to the package, and `exclude` + // keeps every pattern, rebased onto the package. + const base = options.base ? path.resolve(cwd, options.base) : root; + const rebase = + (from: string) => + (p: string): string => + from === root ? p : toPosix(path.relative(root, path.resolve(from, slashGlob(p)))) || '.'; + const fromBase = rebase(base); + const fromPackage = rebase(declared?.dir ?? root); const toProject = toPosix(path.relative(base, root)); const excludeFromBase = (e: string): string | undefined => { - if (!inherited || e.startsWith('**/')) return e; - if (declared) return fromBase(e); + if (base === root || e.startsWith('**/')) return e; // The project's own folder (`apps/web`) leaves out all of it, as `apps/web/**` does. if (e === toProject) return '**'; return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : undefined; }; + const excludeFromPackage = (e: string) => (e.startsWith('**/') ? e : fromPackage(e)); // A dependency's config reads only within its package when installed, or its workspace: a // path that leaves it is ignored, so a published package cannot have its apps read files // elsewhere on the machine. @@ -482,46 +504,70 @@ export async function loadConfigWith( outside.push(`${field} ${p}`); return false; }; - const configComponents = - config.components === undefined + const pathOf = (t: TokenSource) => (typeof t === 'string' ? t : t.path); + const tokensFrom = + (move: (p: string) => string) => + (t: TokenSource): TokenSourceConfig => + typeof t === 'string' ? { path: move(t) } : { ...t, path: move(t.path) }; + const packageComponents = + pkg?.components === undefined ? undefined - : toArray(config.components).filter(within('components')); - const configTokens = - config.tokens === undefined + : toArray(pkg.components).filter(within('components')).map(fromPackage); + const packageTokens = + pkg?.tokens === undefined ? undefined - : toArray(config.tokens).filter((t) => within('tokens')(typeof t === 'string' ? t : t.path)); - const configDocs = - config.docs === undefined ? undefined : toArray(config.docs).filter(within('docs')); + : toArray(pkg.tokens) + .filter((t) => within('tokens')(pathOf(t))) + .map(tokensFrom(fromPackage)); + const packageDocs = + pkg?.docs === undefined ? undefined : toArray(pkg.docs).filter(within('docs')).map(fromPackage); if (declared && outside.length) { warnings.push( `${declared.name}'s onsystem config names paths outside ${declared.installed ? 'the package' : 'the workspace'}, which were ignored: ${outside.join(', ')}.`, ); } - + // The design system's components, and what goes with them (importPath, docs, elements, tokens, + // libraries), come from the package's config unless this one names components of its own: + // then zero config fills in what it leaves out, such as the app's own stylesheet for tokens. + const inherits = pkg !== undefined && config.components === undefined; + const configComponents = + config.components === undefined ? packageComponents : toArray(config.components).map(fromBase); + const configTokens = + config.tokens === undefined + ? inherits + ? packageTokens + : undefined + : toArray(config.tokens).map(tokensFrom(fromBase)); + const configDocs = + config.docs === undefined + ? inherits + ? packageDocs + : undefined + : toArray(config.docs).map(fromBase); + const importPath = config.importPath ?? (inherits ? pkg.importPath : undefined); + const librariesSetting = config.libraries ?? (inherits ? pkg.libraries : undefined); + + // The package's rules, then this config's over them. const rules = {} as Record; for (const id of RULE_IDS) { - const setting = config.rules?.[id]; + const setting = config.rules?.[id] ?? pkg?.rules?.[id]; rules[id] = setting === undefined ? { severity: DEFAULT_SEVERITY[id], options: {} } : resolveRule(setting); } // A package's `overrides` are about its own files, which an app does not check. - const overrides: ResolvedOverride[] = (declared ? [] : (config.overrides ?? [])).map( - (override) => ({ - files: toArray(override.files).map(slashGlob), - rules: Object.fromEntries( - Object.entries(override.rules).map(([id, setting]) => [id, resolveRule(setting)]), - ), - }), - ); + const overrides: ResolvedOverride[] = (config.overrides ?? []).map((override) => ({ + files: toArray(override.files).map(slashGlob), + rules: Object.fromEntries( + Object.entries(override.rules).map(([id, setting]) => [id, resolveRule(setting)]), + ), + })); // Paths may be written Windows-style; globs and POSIX need forward slashes. An app keeps its // own tsconfig over a design-system package's, which resolves the package's aliases, not its. const tsconfig = - config.tsconfig === undefined || declared - ? undefined - : fromBase(config.tsconfig.replaceAll('\\', '/')); + config.tsconfig === undefined ? undefined : fromBase(config.tsconfig.replaceAll('\\', '/')); if (tsconfig !== undefined && !fs.existsSync(path.resolve(root, tsconfig))) { // Without it, path aliases do not resolve and most checks go quiet. throw new ConfigError( @@ -530,7 +576,7 @@ export async function loadConfigWith( } // Without component globs, look for components.json or a design-system package. - const componentsSet = Boolean(options.components?.length) || config.components !== undefined; + const componentsSet = Boolean(options.components?.length) || configComponents !== undefined; const detection = componentsSet ? undefined : detectProject(root, tsconfig, undefined, declared, finder(root)); @@ -540,9 +586,9 @@ export async function loadConfigWith( // from a registry ships only some of its files. const explicitTokens = options.tokens?.length ? { paths: options.tokens, from: '--tokens' } - : config.tokens !== undefined && !declared + : config.tokens !== undefined ? { - paths: toArray(config.tokens).map((t) => fromBase(typeof t === 'string' ? t : t.path)), + paths: toArray(config.tokens).map((t) => fromBase(pathOf(t))), from: `"tokens" in ${configFile ?? 'the config'}`, } : undefined; @@ -556,33 +602,25 @@ export async function loadConfigWith( const components = ( options.components?.length ? options.components - : configComponents !== undefined - ? configComponents.map(fromBase) - : detection + : (configComponents ?? + (detection ? [...(detection.withDefaults ? DEFAULT_COMPONENTS : []), ...detection.components] - : DEFAULT_COMPONENTS + : DEFAULT_COMPONENTS)) ).map(slashGlob); // A design-system package whose sources these globs do not read is read from its declarations. - const libraries = detectLibraries(root, config.libraries, { - importPath: config.importPath, - components, - }); + const libraries = detectLibraries(root, librariesSetting, { importPath, components }); - const tokenSources = options.tokens?.length + const tokenSources: TokenSourceConfig[] = options.tokens?.length ? options.tokens.map((p) => ({ path: p })) - : configTokens !== undefined - ? configTokens.map((t) => - typeof t === 'string' ? { path: fromBase(t) } : { ...t, path: fromBase(t.path) }, - ) - : // Detected stylesheets replace the stylesheet guesses, not DTCG files. - [ - ...(detection?.tokens.length - ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith('.json'))] - : DEFAULT_TOKENS - ).map((p) => ({ path: p })), - ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), - ]; + : (configTokens ?? [ + // Detected stylesheets replace the stylesheet guesses, not DTCG files. + ...(detection?.tokens.length + ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith('.json'))] + : DEFAULT_TOKENS + ).map((p) => ({ path: p })), + ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), + ]); // `exclude` adds to the defaults. A pattern that starts with `!` takes out the default it // names (`!**/*.d.ts`), for a project that checks what the defaults leave out. @@ -591,59 +629,63 @@ export async function loadConfigWith( // `./app/legacy/` is the folder `app/legacy`, also when a root config's pattern is rebased. .replace(/^(?:\.\/)+/, '') .replace(/(.)\/+$/, '$1'); - const listed = config.exclude ?? []; - const dropped = new Set(listed.filter((e) => e.startsWith('!')).map((e) => tidy(e.slice(1)))); - for (const pattern of dropped) { - if (DEFAULT_EXCLUDE.includes(pattern)) continue; - warnings.push( - `"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(', ')}: a pattern that starts with "!" names one of them.`, - ); - } - const exclude = [ - ...new Set([ - ...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), - ...listed.filter((e) => !e.startsWith('!')).map(tidy), - ]), - ] - .map(excludeFromBase) - .filter((e): e is string => e !== undefined); + const excludeOf = (listed: readonly string[], move: (e: string) => string | undefined) => { + const dropped = new Set(listed.filter((e) => e.startsWith('!')).map((e) => tidy(e.slice(1)))); + for (const pattern of dropped) { + if (DEFAULT_EXCLUDE.includes(pattern)) continue; + warnings.push( + `"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(', ')}: a pattern that starts with "!" names one of them.`, + ); + } + return [ + ...new Set([ + ...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), + ...listed.filter((e) => !e.startsWith('!')).map(tidy), + ]), + ] + .map(move) + .filter((e): e is string => e !== undefined); + }; + const exclude = excludeOf(config.exclude ?? [], excludeFromBase); + // A package's own list finds the package's files. The project's own files keep its defaults: + // the package's patterns only add to them, whatever defaults it takes out. + const packageExclude = pkg?.exclude ? excludeOf(pkg.exclude, excludeFromPackage) : undefined; return { root, - configFile, + configFile: configFile ?? declared?.config, components, - // An app keeps the default excludes for its own files: a package's own `exclude` is about - // the package, and only adds to them, whatever defaults it takes out. Finding the package's - // files uses its list as it is. - exclude: declared && config.exclude ? [...new Set([...DEFAULT_EXCLUDE, ...exclude])] : exclude, - ...(declared && config.exclude ? { designSystemExclude: exclude } : {}), + exclude: packageExclude + ? [...new Set([...exclude, ...packageExclude.filter((e) => !DEFAULT_EXCLUDE.includes(e))])] + : exclude, + ...(packageExclude && inherits ? { designSystemExclude: packageExclude } : {}), tokens: tokenSources.map((t) => ({ ...t, path: slashGlob(t.path) })), ...(!options.tokens?.length && configTokens === undefined ? { tokensDetected: true } : {}), docs: (options.docs?.length ? options.docs - : configDocs === undefined - ? [...new Set([...DEFAULT_DOCS, ...(detection?.docs ?? [])])] - : configDocs.map(fromBase) + : (configDocs ?? [...new Set([...DEFAULT_DOCS, ...(detection?.docs ?? [])])]) ).map(slashGlob), tsconfig, - importPath: config.importPath, + importPath, // Components a package's config names are imported by its exports, as detection reads them. imports: - detection?.imports ?? (declared && componentsSet ? packageImports(root, declared.dir) : []), + detection?.imports ?? + (declared && inherits && componentsSet ? packageImports(root, declared.dir) : []), detected: detection?.source, ...(detection?.designSystems ? { designSystems: detection.designSystems } : {}), tailwindConfig: findTailwindConfig(root), libraries: libraries.read, - libraryIds: config.libraries, + libraryIds: librariesSetting, librariesOff: libraries.off, librariesUnread: libraries.unread, - elements: config.elements ?? {}, - includeDesignSystem: - options.includeDesignSystem ?? (declared ? undefined : config.includeDesignSystem) ?? false, + elements: { ...(inherits ? pkg.elements : {}), ...config.elements }, + includeDesignSystem: options.includeDesignSystem ?? config.includeDesignSystem ?? false, rules, overrides, - ...(inherited && !declared ? { base } : {}), + ...(base !== root ? { base } : {}), ...(declared ? { designSystemPackage: declared } : {}), + ...(inherits ? { inherits: true } : {}), + ...(config.extends === false ? { extendsOff: true } : {}), configured: componentsSet, ...(deprecations.length ? { deprecations } : {}), ...(warnings.length ? { warnings } : {}), @@ -653,7 +695,7 @@ export async function loadConfigWith( /** * Whether `dir` has a config of its own: a config file under its current or old name, or one its * package.json names in its `onsystem` field. Without one, a project takes on its design-system - * package's config, else the workspace root's. + * package's config, and, from a workspace root, the root's over it. */ export function hasOwnConfig(dir: string): boolean { if ([...CONFIG_FILES, ...LEGACY_CONFIG_FILES].some((name) => fs.existsSync(path.join(dir, name)))) @@ -756,6 +798,27 @@ export function nodeRunsTypeScript(version: string = process.versions.node): boo return major > 23 || (major === 23 && minor >= 6) || (major === 22 && minor >= 18); } +type ParsedConfig = z.output; +type TokenSource = z.output; + +/** A config file, read and validated: a JSON one only with `jsonConfigOnly`. */ +async function readConfig( + file: string, + options: Pick, +): Promise { + if (!fs.existsSync(file)) throw new ConfigError(`Config file not found: ${file}`); + if (options.jsonConfigOnly && !file.endsWith('.json')) { + throw new ConfigError( + `${file} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`, + ); + } + const parsed = configSchema.safeParse(await readConfigFile(file)); + if (!parsed.success) { + throw new ConfigError(`Invalid config in ${file}:\n${z.prettifyError(parsed.error)}`); + } + return parsed.data; +} + async function readConfigFile(file: string): Promise { if (/\.[cm]?ts$/.test(file) && !nodeRunsTypeScript()) { throw new ConfigError( diff --git a/src/design-system.ts b/src/design-system.ts index d36d0899..9ea58e08 100644 --- a/src/design-system.ts +++ b/src/design-system.ts @@ -822,16 +822,17 @@ export class DesignSystemHost { } /** - * Watches the config file, the tsconfig and the tsconfigs it extends through - * their directories: editors save by renaming a new file over the old one, - * which ends a watch on the file. Called again after each build, so a new - * `extends` target is picked up. + * Watches the config file, the config a design-system package declares, which a project with + * a config of its own reads under that one, the tsconfig and the tsconfigs it extends through + * their directories: editors save by renaming a new file over the old one, which ends a watch + * on the file. Called again after each build, so a new `extends` target is picked up. */ private watchConfigFiles(): void { const { root, configFile, tsconfig, designSystemPackage } = this.config; const files = [ configFile, - // The design-system package's package.json, which names its config. + // The design-system package's config, and its package.json, which names it. + designSystemPackage?.config, designSystemPackage && path.join(designSystemPackage.dir, 'package.json'), // Zero-config detection reads these; a change re-runs it. ...DETECTION_FILES.map((f) => path.join(root, f)), @@ -919,10 +920,15 @@ export class DesignSystemHost { /** Re-reads the config, re-watches what it points at and rebuilds. */ private async reloadConfig(): Promise { const { root, configFile, designSystemPackage, warnings } = this.config; - // A design-system package's config is found again from the project, so it is rebased again. + // A design-system package's config is found again from the project, so it is rebased again; + // a config of the project's own is read again, over it. const load = this.options.loadConfig ?? - (() => loadConfig({ root, config: designSystemPackage ? undefined : configFile })); + (() => + loadConfig({ + root, + config: configFile === designSystemPackage?.config ? undefined : configFile, + })); try { this.config = await load(); } catch (error) { diff --git a/src/explain.ts b/src/explain.ts index 83b445f1..3c5e2249 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -23,9 +23,14 @@ export function explainProject(ds: DesignSystem, cwd: string, indent = ''): stri out(` ${(step.accepted ? 'found' : 'no').padEnd(6)} ${step.candidate}: ${step.reason}`); } if (config.configured) { - out( - ` components come from ${config.configFile ? display(cwd, config.configFile) : '--components'}, so zero config did not look`, - ); + // A config that names no components of its own takes the package's, from its config. + const source = + config.inherits && config.designSystemPackage + ? display(cwd, config.designSystemPackage.config) + : config.configFile + ? `${display(cwd, config.configFile)}${over(config, cwd)}` + : '--components'; + out(` components come from ${source}, so zero config did not look`); } else { const steps: DetectionStep[] = []; detectProject(config.root, config.tsconfig, steps, config.designSystemPackage); @@ -64,7 +69,7 @@ export function explainProject(ds: DesignSystem, cwd: string, indent = ''): stri field( 'config', config.configFile - ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''}` + ? `${display(cwd, config.configFile)}${over(config, cwd)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''}` : '(none)', ); field('components', listOf(config.components) || '(none)'); @@ -219,22 +224,42 @@ export function nodeModulesFor(dir: string): string | undefined { } } +/** `, over packages/ui/onsystem.config.json` for a config read over a design-system package's. */ +function over(config: ResolvedConfig, cwd: string): string { + const declared = config.designSystemPackage; + return declared && config.configFile !== declared.config + ? `, over ${display(cwd, declared.config)}` + : ''; +} + function display(cwd: string, file: string): string { const rel = relativePath(cwd, file); return rel && !rel.startsWith('..') ? rel : file; } /** - * Whether a dependency's declared config was taken on, for a project without a config of its - * own: the package it came from and the others that declare one, or that none does. + * Whether a dependency's declared config was read, taken on by a project without a config of its + * own or under the one it has: the package it came from and the others that declare one, or that + * none does. */ function packageSteps(config: ResolvedConfig, cwd: string): DetectionStep[] { const declared = config.designSystemPackage; - // A config of its own (or --config) is read first; the dependencies are not looked at. - if (config.configFile && !declared) return []; + const candidate = "a design-system package's config"; + // A config of its own (or --config) read alone: it sets `"extends": false`, which names the + // package it leaves out, or no dependency declares one. + if (config.configFile && !declared) { + const found = config.extendsOff ? findDesignSystemPackage(config.root)?.found : undefined; + if (!found) return []; + return [ + { + candidate, + accepted: false, + reason: `${found.name} (${found.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, found.config)}, not read: "extends": false in ${display(cwd, config.configFile)} reads it alone`, + }, + ]; + } const lookup = findDesignSystemPackage(config.root); if (!lookup) return []; - const candidate = "a design-system package's config"; const skipped = lookup.skipped.length ? `; ${lookup.skipped.map((p) => p.name).join(', ')} ${lookup.skipped.length === 1 ? 'declares one' : 'declare one'} that cannot be read, skipped since installed` : ''; @@ -251,7 +276,7 @@ function packageSteps(config: ResolvedConfig, cwd: string): DetectionStep[] { { candidate, accepted: true, - reason: `${declared.name} (${declared.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, declared.config)}, which this project takes on, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(', ')} also declare one, not read: one config is taken on, from the package the code imports most` : ''}${skipped}`, + reason: `${declared.name} (${declared.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, declared.config)}, which this project ${config.configFile === declared.config ? 'takes on' : `reads under ${display(cwd, config.configFile ?? '')}`}, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(', ')} also declare one, not read: one config is taken on, from the package the code imports most` : ''}${skipped}`, }, ]; } diff --git a/src/init.ts b/src/init.ts index 15c56afe..b024c14f 100644 --- a/src/init.ts +++ b/src/init.ts @@ -7,6 +7,8 @@ import { BASELINE_FILE, LEGACY_BASELINE_FILE } from './baseline.js'; import { CONFIG_FILES, DEFAULT_DOCS, + DEFAULT_TOKENS, + FALLBACK_TOKENS, LEGACY_CONFIG_FILES, type LoadConfigOptions, type ResolvedConfig, @@ -67,7 +69,8 @@ export const GUIDE_FILE = 'ONSYSTEM.md'; * (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so * every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those * apps' teams how to add the agent tools and the CI check. In an app that takes on such a - * package's config, it writes no config of its own unless --force says to. + * package's config, it writes no config of its own unless --force says to, and then one that + * builds on the package's, for the app's own rules and overrides. * * In a terminal, it offers to add a short section on onsystem to the project's AGENTS.md or * CLAUDE.md, for coding agents. @@ -114,28 +117,50 @@ export async function init( const relative = (abs: string) => relativePath(root, abs); // An app on a design-system package that declares its config needs none of its own. const inherited = options.force ? undefined : config.designSystemPackage; - // With --force, the config written here is read instead of the package's. - const replaced = options.force ? config.designSystemPackage : undefined; + // With --force, the config written here builds on the package's, unless the one there names + // components of the app's own: those are written out again, as they are without a package. + // Components given on the command line are the app's own, as in a config without the package. + const declaring = + options.force && !configOptions.components?.length ? config.designSystemPackage : undefined; + const replaced = + declaring && (config.inherits || !(await namesOwnComponents(config, declaring))) + ? declaring + : undefined; // "components" holds what zero config found, not globs the user gave. let found = false; if (inherited) { notes.push( - `This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one, which is read instead.`, + `This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one for its own rules and overrides, which builds on ${inherited.name}'s.`, ); } else if (configOptions.components?.length) { file.components = configOptions.components; if (configOptions.tokens?.length) file.tokens = configOptions.tokens; if (configOptions.docs?.length) file.docs = configOptions.docs; + } else if (replaced) { + // What the config leaves out comes from the package's, as it does with none: it holds only + // the app's own rules and overrides, once they are added, and the old config's tokens when + // they name a stylesheet of the app's own beside the package's. + const tokens = existing ? ownTokens(existing, root, replaced) : undefined; + if (tokens) file.tokens = tokens; } else if (workspace) { // At a monorepo root, each project keeps zero config; the file holds what they share. notes.push( - `This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(', ') || 'none'}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own.`, + `This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(', ') || 'none'}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own when it is checked from this folder, over the config its design-system package declares.`, ); } else if (config.detected) { notes.push(`Found the design system through ${config.detected}.`); const specifiers = new Set((config.imports ?? []).map((m) => m.specifier)); const tokens = (await tokenFiles(config)).map(relative); - if (specifiers.size > 1) { + const fromInstalled = installedFields(config.components, tokens); + if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens)); + // With "tokens" left to zero config, a config that names components can read other ones. + const changed = + fromInstalled.includes('tokens') && !fromInstalled.includes('components') + ? await tokensWithComponents(config, tokens) + : undefined; + if (fromInstalled.includes('components')) { + // Spelled out, they would miss the package's next version. + } else if (specifiers.size > 1) { notes.push( `"components" is left to zero config: apps import them by ${specifiers.size} specifiers from the package's exports (${[...specifiers].slice(0, 2).join(', ')}, …), which a config cannot spell.`, ); @@ -143,6 +168,10 @@ export async function init( notes.push( `"components" is left to zero config: it found ${config.components.length} files through the package's exports, which it keeps following as the package changes.`, ); + } else if (changed) { + notes.push( + `"components" is left to zero config as well: a config that names them reads the tokens only from the usual stylesheets (${DEFAULT_TOKENS[0]} and the like)${changed.lost.length ? `, which leave out ${andList(changed.lost)}` : ', not the ones zero config reads'}.`, + ); } else { // The detected globs, spelled out; a package imported by one name keeps it. A pattern // (`@acme/ui/components/*`) is no import: zero config keeps giving each file its own. @@ -154,27 +183,21 @@ export async function init( const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d)); if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs]; } - if (tokens.length) file.tokens = tokens; + if (tokens.length && !fromInstalled.includes('tokens')) file.tokens = tokens; } else if ((await componentFiles(config)).length) { // Nothing detected, but the default folders hold components (`components/ui` without a // components.json), as `check` and `inspect` find them: spell out the ones that match. - // With --force in an app, they are the folders a design-system package's config names. - const declared = replaced && config.configured; const matching: string[] = []; for (const glob of config.components) { if ((await componentFiles({ ...config, components: [glob] })).length) matching.push(glob); } - notes.push( - declared - ? `Found the design system through the config ${replaced.name} declares: ${matching.join(', ')}.` - : `Found the design system in the default folders: ${matching.join(', ')}.`, - ); + notes.push(`Found the design system in the default folders: ${matching.join(', ')}.`); file.components = matching; found = true; - // Imported as the package's config says, not by a path into it. - if (declared && config.importPath) file.importPath = config.importPath; const tokens = (await tokenFiles(config)).map(relative); - if (tokens.length) file.tokens = tokens; + const fromInstalled = installedFields([], tokens); + if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens)); + else if (tokens.length) file.tokens = tokens; } else { if (!ask) { io.stderr( @@ -223,11 +246,7 @@ export async function init( fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`); written.push(CONFIG_FILE); } - if (replaced) { - // What the project reads now, next to what it took on. - const own = (await loadTarget({ ...configOptions, config: undefined })).config; - notes.push(await replacedNote(replaced, config, own, io.cwd)); - } + if (replaced) notes.push(replacedNote(replaced, io.cwd, file.tokens !== undefined)); let registry: Unshipped | undefined; if (designSystem && manifest && name) { written.push(...declarePackage(root, manifest, name, options.force, notes)); @@ -506,7 +525,7 @@ function declarePackage( written.push('package.json'); } notes.push( - `This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ', also added to "files"' : ''}), which every app that depends on ${name} reads when it has no config of its own.`, + `This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ', also added to "files"' : ''}), which every app that depends on ${name} reads, under its own config when it has one.`, ); const guide = path.join(root, GUIDE_FILE); if (force || !fs.existsSync(guide)) { @@ -530,7 +549,8 @@ interface Unshipped { * files its `files` list leaves out of the published package, and those outside the package * (`../tailwind-config/theme.css`), which an installed package's config cannot reach. Undefined * for a private package, or when they find every file. Without a `files` list, npm publishes - * everything in the package. + * everything in the package. Without any component file, those apps read the components from + * the type declarations the package publishes, when it names some. */ async function unshipped( config: ResolvedConfig, @@ -547,10 +567,12 @@ async function unshipped( files ? list.filter((f) => !outside(f) && !ships(files, f)) : []; const missing = { components: left(components), tokens: left(tokens) }; const away = { components: components.filter(outside), tokens: tokens.filter(outside) }; - // With every component file left out, the component rules know no component in those apps. + // With every component file left out, those apps read the components from the package's type + // declarations, or, when it publishes none, know no component. const noComponents = components.length > 0 && missing.components.length + away.components.length === components.length; + const declarations = noComponents ? publishedDeclarations(config.root, json, files) : []; // `(3 component files and 1 token file). Those apps do not know the components or tokens in it…` const lost = (lists: { components: string[]; tokens: string[] }, one: boolean) => { const c = lists.components.length; @@ -559,12 +581,16 @@ async function unshipped( ...(c ? [count(c, 'component file')] : []), ...(t ? [count(t, 'token file')] : []), ]); + const them = one ? 'it' : 'them'; + if (c && noComponents && declarations.length) { + return `(${kinds}). Those apps read the components from the type declarations it publishes instead (${andList(declarations)})${t ? `, and do not know the tokens in ${them}` : ''}.`; + } const what = andList([...(c ? ['components'] : []), ...(t ? ['tokens'] : [])], 'or'); const checks = c && noComponents - ? `: they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` + ? `: with no type declarations published either, they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : ''; - return `(${kinds}). Those apps do not know the ${what} in ${one ? 'it' : 'them'}${checks}.`; + return `(${kinds}). Those apps do not know the ${what} in ${them}${checks}.`; }; const notes: string[] = []; @@ -588,50 +614,168 @@ async function unshipped( notes, next: noComponents && missing.components.length - ? `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` + ? declarations.length + ? `reads its components from the type declarations it publishes, not from ${andList([...new Set(missing.components.map(top))])}` + : `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` : `does not read ${andList([...folders, ...beyond])}`, }; } /** - * Said when --force writes a config in an app that took on a design-system package's: the new - * one is read instead, and what it does not take from the package. `taken` is the config the - * app took on, `own` the one it reads now. + * The type declarations a package's package.json names, as apps that install it look for them: + * `types` or `typings`, a `types` condition in its `exports`, or an `index.d.ts` beside it, each + * that its `files` list publishes. Relative to the package. */ -async function replacedNote( - pkg: DesignSystemPackage, - taken: ResolvedConfig, - own: ResolvedConfig, - cwd: string, -): Promise { - const lacks = async (list: (c: ResolvedConfig) => Promise) => { - const kept = new Set((await list(own)).map((f) => path.resolve(f))); - return (await list(taken)).some((f) => !kept.has(path.resolve(f))); +function publishedDeclarations( + root: string, + json: Record, + files: readonly unknown[] | undefined, +): string[] { + const named: string[] = []; + for (const field of ['types', 'typings']) { + const value = json[field]; + if (typeof value === 'string') named.push(value); + } + const conditions = (value: unknown, key?: string): void => { + if (typeof value === 'string') { + if (key === 'types') named.push(value); + } else if (Array.isArray(value)) { + for (const item of value) conditions(item); + } else if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) conditions(v, k); + } }; - const components = await lacks(componentFiles); - const importPath = taken.importPath !== undefined && own.importPath === undefined; - const tokens = await lacks(tokenFiles); + conditions(json.exports); + if (fs.existsSync(path.join(root, 'index.d.ts'))) named.push('index.d.ts'); + return [...new Set(named.map((f) => f.replace(/^\.\//, '')))].filter( + (f) => !files || ships(files, f), + ); +} + +/** + * Said when --force writes a config in an app on a design-system package that declares one: the + * new config builds on the package's, which still gives it what it leaves out. + */ +function replacedNote(pkg: DesignSystemPackage, cwd: string, keptTokens: boolean): string { const declared = relativePath(cwd, pkg.config) || pkg.config; - const replaced = `This project took on the config ${pkg.name} declares (${declared}); with --force it has one of its own, which is read instead, so ${pkg.name}'s rules and later changes to its config no longer reach it.`; - if (!components && !importPath && !tokens) return replaced; - const effects = andList([ - ...(components ? [`does not know ${pkg.name}'s components`] : []), - ...(importPath && !components - ? [`does not match imports from ${pkg.name} to its components`] - : []), - ...(tokens ? [`does not read ${pkg.name}'s tokens`] : []), - ]); - // What the package's config spells out can be copied; what zero config found there cannot. - const fields = [ - ...(components ? ['"components"'] : []), - ...(importPath || (components && taken.importPath !== undefined) ? ['"importPath"'] : []), - ...(tokens ? ['"tokens"'] : []), + const fields = keptTokens + ? '"components" and "importPath"' + : '"components", "importPath" and "tokens"'; + const kept = keptTokens + ? ' It keeps the "tokens" the old one listed, which name a stylesheet of this project\'s own.' + : ''; + return `This project reads the config ${pkg.name} declares (${declared}). With --force it has one of its own, ${CONFIG_FILE}, read over that one: what it leaves out, such as ${fields}, still comes from ${pkg.name}'s config, and the rules and overrides you add to it apply over ${pkg.name}'s rules.${kept} "extends": false in it reads it alone.`; +} + +/** + * The `tokens` an existing JSON config lists, when one of them is outside the design-system + * package, links resolved: an app that takes the package's components and reads its own + * stylesheet too lists both. A list of the package's files only is left to the package's config. + */ +function ownTokens(file: string, root: string, pkg: DesignSystemPackage): string[] | undefined { + let tokens: unknown; + try { + tokens = (JSON.parse(fs.readFileSync(file, 'utf8')) as { tokens?: unknown }).tokens; + } catch { + return undefined; + } + if (!Array.isArray(tokens) || !tokens.every((t): t is string => typeof t === 'string')) { + return undefined; + } + const real = (abs: string) => { + try { + return fs.realpathSync(abs); + } catch { + return abs; + } + }; + const dir = real(pkg.dir); + return tokens.some((t) => !isInside(dir, real(path.resolve(root, t)))) ? tokens : undefined; +} + +/** + * Whether the files a config's `components` match include some outside the design-system + * package, links resolved: a config that names only the package's (copied from its config, as + * `node_modules/@acme/ui/src/*.tsx`), or none, has no components of its own. + */ +async function namesOwnComponents( + config: ResolvedConfig, + pkg: DesignSystemPackage, +): Promise { + const real = (file: string) => { + try { + return fs.realpathSync(file); + } catch { + return path.resolve(file); + } + }; + const dir = real(pkg.dir); + return (await componentFiles(config)).some((file) => !isInside(dir, real(file))); +} + +/** Whether a path, relative to the root, is in a package installed in `node_modules`. */ +const isInstalledPath = (file: string) => /(?:^|\/)node_modules\//.test(file); + +/** + * The fields `init` leaves to zero config because what zero config found for them is in a + * package installed in `node_modules`: `components` (their globs) and `tokens` (files). + */ +function installedFields( + components: readonly string[], + tokens: readonly string[], +): ('components' | 'tokens')[] { + return [ + ...(components.some(isInstalledPath) ? ['components' as const] : []), + ...(tokens.some(isInstalledPath) ? ['tokens' as const] : []), ]; - const copy = - (!components || taken.configured === true) && (!tokens || taken.tokensDetected !== true) - ? `copy ${andList(fields)} from ${declared} into ${CONFIG_FILE}, or ` - : ''; - return `${replaced} It ${effects}: ${copy}delete ${CONFIG_FILE} to take on ${pkg.name}'s config again.`; +} + +/** + * Why `fields` are left to zero config: a path into an installed package breaks when it is + * upgraded, since pnpm keeps each version in a folder of its own + * (`node_modules/.pnpm/@acme+ui@1.2.0_react@19.2.0/node_modules/@acme/ui`), and a list of its + * files misses the ones a new version adds. Zero config finds it again each time. + */ +function installedNote( + fields: readonly ('components' | 'tokens')[], + tokens: readonly string[], +): string { + const one = fields.length === 1; + // The app's own stylesheet, next to the package's, is left to zero config with it. + const some = fields.includes('tokens') && !tokens.every(isInstalledPath); + const what = some + ? `${fields.includes('components') ? 'the components and ' : ''}some of the token files come` + : one + ? 'it comes' + : 'they come'; + return `${andList(fields.map((f) => `"${f}"`))} ${one ? 'is' : 'are'} left to zero config: ${what} from a package installed in node_modules, which zero config finds again after each upgrade, while a path written here would break once the package's folder or files change (pnpm keeps each version in a folder of its own in node_modules/.pnpm).`; +} + +/** + * What changes when a config names components and leaves `tokens` (relative to the root, as + * zero config finds them) to zero config: such a config turns detection off, so the stylesheets + * it found (components.json's `app/styles/tailwind.css`, and what they import from an installed + * package) give way to the usual ones ({@link DEFAULT_TOKENS}), with what those import. Undefined + * when that reads the same token files; else `lost` holds the ones it leaves out, the app's own + * first. + */ +async function tokensWithComponents( + config: ResolvedConfig, + tokens: readonly string[], +): Promise<{ lost: string[] } | undefined> { + const usual = await tokenFiles({ + ...config, + tokens: [ + ...DEFAULT_TOKENS.map((p) => ({ path: p })), + ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), + ], + tokensDetected: true, + }); + const read = new Set(usual.map((f) => relativePath(config.root, f))); + if (read.size === tokens.length && tokens.every((t) => read.has(t))) return undefined; + const lost = tokens.filter((t) => !read.has(t)); + const own = lost.filter((t) => !isInstalledPath(t)); + return { lost: own.length ? own : lost }; } const REGISTRY_DOCS = 'https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries'; @@ -807,7 +951,7 @@ function consumerGuide(name: string): string { `npx -y ${NAME} inspect`, '```', '', - `An app with an ${CONFIG_FILE} of its own reads that instead.`, + `An app with an ${CONFIG_FILE} of its own reads it over ${name}'s config: the rules and overrides it sets apply over ${name}'s rules, and, unless it sets components of its own, what it leaves out, such as the components, \`importPath\` and tokens, comes from ${name}'s. \`"extends": false\` in it reads it alone.`, '', '## Coding agents', '', diff --git a/src/workspace.ts b/src/workspace.ts index ebcfa823..7746946a 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -167,7 +167,10 @@ async function discoverWorkspace( for (const dir of workspacePackageDirs(root)) { const config = await loadProjectConfig(dir, root, rootConfig.configFile, options); const files = (await componentFiles(config)).length; - const own = config.configFile !== undefined && config.configFile !== rootConfig.configFile; + // A config of its own, or one its design-system package declares, read under the root's. + const own = + (config.configFile !== undefined && config.configFile !== rootConfig.configFile) || + config.designSystemPackage !== undefined; // An app on a published library (MUI) has its design system in node_modules. const library = config.libraries?.some((l) => l.dir !== undefined) ?? false; if (files || own || library) { @@ -196,10 +199,11 @@ async function discoverWorkspace( } /** - * A project's config: its own config file, else the config its design-system package declares - * (a dependency with `"onsystem"` in its package.json), else the workspace root's, with paths - * relative to the workspace root, else zero config. `loadConfig` from the project's folder finds - * the first two the same way. CLI flags that apply to every project carry over. + * A project's config: its own config file, else the workspace root's, with paths relative to + * the workspace root, read over the config its design-system package declares (a dependency with + * `"onsystem"` in its package.json), else zero config. `loadConfig` from the project's folder + * finds its own config and the package's the same way. CLI flags that apply to every project + * carry over. */ function loadProjectConfig( dir: string, @@ -219,7 +223,7 @@ function loadProjectConfig( } const lookup = findDesignSystemPackage(dir, packages); // A declaration that cannot be read is reported from the project's own load. - if (lookup?.found || lookup?.problems.length) { + if (lookup?.problems.length) { return loadConfigWith({ ...shared, root: dir }, { packages, lookup }); } return loadConfigWith( diff --git a/test/design-system-package.test.ts b/test/design-system-package.test.ts index 2b98d92b..1274efff 100644 --- a/test/design-system-package.test.ts +++ b/test/design-system-package.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; import { DEFAULT_COMPONENTS, DEFAULT_EXCLUDE, loadConfig } from '../src/config.js'; import { DesignSystemHost } from '../src/design-system.js'; +import { WorkspaceProjects } from '../src/server/projects.js'; import { serveStdio } from '../src/server/stdio.js'; import { silentLogger } from '../src/util/log.js'; import { loadTarget } from '../src/workspace.js'; @@ -100,14 +101,14 @@ describe('init in a design-system package', () => { const init = await run(['init', '--yes', '--no-cache'], kit); expect(init.code).toBe(0); expect(init.stdout).toContain( - 'This is a design-system package: package.json declares its config ("onsystem": "./onsystem.config.json", also added to "files"), which every app that depends on @acme/kit reads when it has no config of its own.', + 'This is a design-system package: package.json declares its config ("onsystem": "./onsystem.config.json", also added to "files"), which every app that depends on @acme/kit reads, under its own config when it has one.', ); expect(init.stdout).toContain( 'Apps that install @acme/kit from a registry get only what "files" in package.json publishes, which leaves out src/ (3 component files and 1 token file)', ); expect(init.stdout).toContain('Wrote onsystem.config.json, package.json, ONSYSTEM.md.'); expect(init.stdout).toContain( - 'A workspace app that depends on @acme/kit now finds it with no config of its own: run `npx onsystem inspect` in one to see it, and give its team ONSYSTEM.md. An app that installs @acme/kit from a registry gets no component checks until "files" publishes src/.', + 'A workspace app that depends on @acme/kit now finds it with no config of its own: run `npx onsystem inspect` in one to see it, and give its team ONSYSTEM.md. An app that installs @acme/kit from a registry reads its components from the type declarations it publishes, not from src/.', ); const manifest = read(path.join(kit, 'package.json')); expect(manifest.onsystem).toBe('./onsystem.config.json'); @@ -152,12 +153,14 @@ describe('init in a design-system package', () => { ); expect(fs.existsSync(path.join(app, 'onsystem.config.json'))).toBe(false); expect(appInit.stdout).toContain('Baseline: 2 findings in 1 file → onsystem.baseline.json'); - // --force writes the app a config of its own, which is then read instead. + // --force writes the app a config of its own, which builds on the package's. expect((await run(['init', '--yes', '--force', '--no-cache'], app)).code).toBe(0); expect(fs.existsSync(path.join(app, 'onsystem.config.json'))).toBe(true); - expect((await run(['inspect', '--no-cache'], app)).stdout).toContain( - 'config onsystem.config.json\n', + const forced = (await run(['inspect', '--no-cache'], app)).stdout; + expect(forced).toContain( + `config onsystem.config.json, over ${path.join(kit, 'onsystem.config.json')} (declared by @acme/kit)\n`, ); + expect(forced).toContain('files 3 component files, 1 token file'); }); it('keeps package.json as it is written, an ONSYSTEM.md already there, and a private package unnoted', async () => { @@ -333,40 +336,56 @@ describe('a config a design-system package declares', () => { ); }); - it("gives way to the app's own config, and wins over a workspace root's, from the root too", async () => { + it("is read under the app's own config, and under a workspace root's from the root", async () => { const files = { ...MONOREPO, ...kitAt('packages/kit', { rules: { 'no-hardcoded-spacing': 'error' } }), - 'onsystem.config.json': json({ rules: { 'no-hardcoded-spacing': 'off' } }), + 'onsystem.config.json': json({ + rules: { 'no-hardcoded-spacing': 'off', 'no-unknown-variant': 'off' }, + }), }; const root = fixture(files); + // From the app's folder, the root's config is not read. const fromApp = await loadConfig({ root: path.join(root, 'apps/web') }); expect(fromApp.designSystemPackage?.name).toBe('@acme/kit'); + expect(fromApp.configFile).toBe(path.join(root, 'packages/kit/onsystem.config.json')); expect(fromApp.rules['no-hardcoded-spacing'].severity).toBe('error'); - // From the monorepo root, the app's project reads the same config. + // From the monorepo root, the app's project reads the root's config over the package's. const { workspace } = await loadTarget({ cwd: root }); const project = await workspace?.projectFor('apps/web/app/page.tsx'); - expect(project?.config.configFile).toBe(fromApp.configFile); - expect(project?.config.rules['no-hardcoded-spacing'].severity).toBe('error'); + expect(project?.config.configFile).toBe(path.join(root, 'onsystem.config.json')); + expect(project?.config.designSystemPackage?.name).toBe('@acme/kit'); + expect(project?.config.components).toEqual(fromApp.components); + expect(project?.config.rules['no-hardcoded-spacing'].severity).toBe('off'); const atRoot = await run(['inspect', '--no-cache'], root); expect(atRoot.stdout).toContain( - `apps/web (@acme/web)\n config ${path.join('packages', 'kit', 'onsystem.config.json')} (declared by @acme/kit)`, + `apps/web (@acme/web)\n config onsystem.config.json, over ${path.join('packages', 'kit', 'onsystem.config.json')} (declared by @acme/kit)`, ); + // check from the root reports what check in the app's folder does, but for the rule the + // root's config turns off. const checked = await run(['check', '.', '--format', 'json', '--no-cache'], root); + const inApp = await findings(path.join(root, 'apps/web')); + expect(inApp.some((f) => f.includes('no-unknown-variant'))).toBe(true); expect( (JSON.parse(checked.stdout) as Results) .flatMap((r) => r.diagnostics.map((d) => `${r.file} ${d.ruleId}`)) .filter((f) => f.startsWith('apps/web/')), ).toEqual( - (await findings(path.join(root, 'apps/web'))).map((f) => `apps/web/${f}`.replace(/:.*$/, '')), + inApp + .filter((f) => !f.includes('no-unknown-variant')) + .map((f) => `apps/web/${f}`.replace(/:.*$/, '')), ); - // The app's own config wins over the package's. + // The app's own config builds on the package's, or, with "extends": false, replaces it. fs.writeFileSync(path.join(root, 'apps/web/onsystem.config.json'), json({})); const own = await loadConfig({ root: path.join(root, 'apps/web') }); - expect(own.designSystemPackage).toBeUndefined(); + expect(own.designSystemPackage?.name).toBe('@acme/kit'); expect(own.configFile).toBe(path.join(root, 'apps/web/onsystem.config.json')); - expect(own.rules['no-hardcoded-spacing'].severity).toBe('warn'); + expect(own.rules['no-hardcoded-spacing'].severity).toBe('error'); + fs.writeFileSync(path.join(root, 'apps/web/onsystem.config.json'), json({ extends: false })); + const alone = await loadConfig({ root: path.join(root, 'apps/web') }); + expect(alone.designSystemPackage).toBeUndefined(); + expect(alone.rules['no-hardcoded-spacing'].severity).toBe('warn'); }); it('is an error when the package names one that cannot be read, a config in code among them', async () => { @@ -656,4 +675,77 @@ describe('a config a design-system package declares', () => { await expect(changed).resolves.toEqual(['Button', 'Card', 'Input']); host.close(); }, 15_000); + + it("is read again when it changes, under the app's own config", async () => { + const root = fixture({ + ...MONOREPO, + ...kitAt('packages/kit', {}), + 'apps/web/onsystem.config.json': json({ rules: { 'no-hardcoded-color': 'warn' } }), + }); + const app = path.join(root, 'apps/web'); + const config = await loadConfig({ root: app }); + expect(config.configFile).toBe(path.join(app, 'onsystem.config.json')); + const host = new DesignSystemHost(config, { cache: false }); + await host.get(); + const changed = new Promise((resolve) => { + host.onChange((ds) => { + if (ds.config.rules['no-hardcoded-spacing'].severity === 'error') { + resolve(ds.components.map((c) => c.name).sort()); + } + }); + }); + host.watch(); + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync( + path.join(root, 'packages/kit/onsystem.config.json'), + json({ rules: { 'no-hardcoded-spacing': 'error' } }), + ); + await expect(changed).resolves.toEqual(['Button', 'Card', 'Input']); + expect((await host.get()).config.rules['no-hardcoded-color'].severity).toBe('warn'); + host.close(); + }, 15_000); + + it('is read again when it changes, under a monorepo root config, by the server at the root', async () => { + const root = fixture({ + ...MONOREPO, + ...kitAt('packages/kit', {}), + 'onsystem.config.json': json({ rules: { 'no-hardcoded-color': 'warn' } }), + }); + const { workspace } = await loadTarget({ cwd: root }); + if (!workspace) throw new Error('expected a workspace'); + const page = 'apps/web/app/page.tsx'; + const severity = async () => { + const { config } = (await projects.forPath(page)).ds; + return config.rules['no-hardcoded-spacing'].severity; + }; + let reloaded: () => void = () => undefined; + const projects = new WorkspaceProjects(workspace, { + cache: false, + watch: true, + logger: silentLogger, + onChange: () => { + reloaded(); + }, + }); + const { ds } = await projects.forPath(page); + expect(ds.config.configFile).toBe(path.join(root, 'onsystem.config.json')); + expect(ds.config.designSystemPackage?.name).toBe('@acme/kit'); + const changed = new Promise((resolve) => { + reloaded = () => { + void severity().then((s) => { + if (s === 'error') resolve(); + }); + }; + }); + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync( + path.join(root, 'packages/kit/onsystem.config.json'), + json({ rules: { 'no-hardcoded-spacing': 'error' } }), + ); + await changed; + const after = (await projects.forPath(page)).ds; + expect(after.config.rules['no-hardcoded-color'].severity).toBe('warn'); + expect(after.components.map((c) => c.name).sort()).toEqual(['Button', 'Card', 'Input']); + projects.close(); + }, 15_000); }); diff --git a/test/design-system-packages-docs.test.ts b/test/design-system-packages-docs.test.ts index a5a9ae9b..c6090b47 100644 --- a/test/design-system-packages-docs.test.ts +++ b/test/design-system-packages-docs.test.ts @@ -7,9 +7,9 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; -import { RULE_IDS } from '../src/config.js'; +import { loadConfig, RULE_IDS } from '../src/config.js'; import { createServer } from '../src/server/index.js'; -import { fixture, load } from './helpers.js'; +import { fixture, load, TSCONFIG } from './helpers.js'; import { section } from './markdown.js'; // docs/design-system-packages.md: what an app that depends on a design-system package gets, by @@ -48,8 +48,16 @@ const PACKAGE = { 'src/input.tsx': 'export function Input(props: { name?: string }) { return }\n', 'dist/index.mjs': 'export function Button() {}\nexport function Card() {}\nexport function Input() {}\n', - 'dist/index.d.ts': - 'export declare function Button(props: { variant?: "default" | "outline" }): unknown;\nexport declare function Card(props: object): unknown;\nexport declare function Input(props: object): unknown;\n', + // As tsc emits them from the sources. + 'dist/index.d.ts': [ + "import * as react from 'react';", + "import * as react_jsx_runtime from 'react/jsx-runtime';", + 'declare function Button(props: react.ButtonHTMLAttributes & { variant?: "default" | "outline" }): react_jsx_runtime.JSX.Element;', + 'declare function Card(props: react.HTMLAttributes): react_jsx_runtime.JSX.Element;', + 'declare function Input(props: react.InputHTMLAttributes): react_jsx_runtime.JSX.Element;', + 'export { Button, Card, Input };', + '', + ].join('\n'), }; /** @@ -180,14 +188,40 @@ function published(pkg: Record): Record { const at = (prefix: string, files: Record) => Object.fromEntries(Object.entries(files).map(([file, text]) => [`${prefix}/${file}`, text])); -/** An app that installs the package from a registry, with `pkg` in node_modules. */ -function installedApp(pkg: Record, extra: Record = {}): string { - return fixture({ - 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '1.0.0' } }), - 'app/page.tsx': PAGE, - ...at('node_modules/@acme/ui', pkg), - ...extra, - }); +/** + * An app that installs the package from a registry, with `pkg` in node_modules. With `react`, + * React's types are installed too, which type declarations need to declare a component. + */ +function installedApp( + pkg: Record, + extra: Record = {}, + { react = false, name = 'web' }: { react?: boolean; name?: string } = {}, +): string { + return fixture( + { + 'package.json': json({ + name, + dependencies: { '@acme/ui': '1.0.0', ...(react ? { react: '19.0.0' } : {}) }, + }), + ...(react ? { 'tsconfig.json': TSCONFIG } : {}), + 'app/page.tsx': PAGE, + ...at('node_modules/@acme/ui', pkg), + ...extra, + }, + { nodeModules: react }, + ); +} + +/** A published package without its type declarations: no `.d.ts`, and no `types` to name one. */ +function withoutTypes(pkg: Record): Record { + const manifest = JSON.parse(pkg['package.json'] ?? '{}') as { + exports: Record; + }; + const { types: _, ...main } = manifest.exports['.'] as Record; + return { + ...without(pkg, (file) => file.endsWith('.d.ts')), + 'package.json': json({ ...manifest, exports: { ...manifest.exports, '.': main } }), + }; } /** A pnpm monorepo: the package in packages/ui, and the app in apps/web. Returns the app. */ @@ -251,11 +285,12 @@ async function tool(root: string, name: string, args: Record): } describe('what apps get from a design-system package (docs/design-system-packages.md)', () => { - it('has a row for a workspace package and for four ways of publishing one', () => { + it('has a row for a workspace package and for five ways of publishing one', () => { expect(rows().map(([how]) => how)).toEqual([ 'As a workspace package', 'From a registry, with the config, the token files and the component sources (`src/`) published', - 'From a registry, with the config, the token files and compiled `dist/` published, but not `src/`', + 'From a registry, with the config, the token files, compiled `dist/` and its type declarations (`.d.ts`) published, but not `src/`', + 'From a registry, with the config, the token files and compiled `dist/` published, but neither `src/` nor type declarations', 'From a registry, without the config, named like a design system (`@acme/ui`), with `exports` that point at the component sources (`./src/index.ts`)', 'From a registry, without the config, with `exports` that point at `dist/`, or named otherwise (`@acme/kit`)', ]); @@ -271,10 +306,30 @@ describe('what apps get from a design-system package (docs/design-system-package ); }); - it('runs the token rules from a package published without src/, and says the component rules did not run', async () => { + it('runs every rule from a package published as dist/ with its type declarations, which give its components', async () => { const pkg = await initialised(); const distOnly = without(published(pkg), (file) => file.startsWith('src/')); - expect(Object.keys(distOnly).sort()).toEqual([ + const app = installedApp(distOnly, {}, { react: true }); + const { rules, stderr } = await check(app); + expect(rules).toEqual(rulesIn(rows()[2]?.[1] ?? '')); + expect(rows()[2]?.[1]).toBe('All eight, from its type declarations'); + expect(stderr).not.toContain('No design-system components found'); + const library = + 'library Design-system package (@acme/ui 1.0.0): 3 components from its type declarations'; + expect((await run(['inspect', '--no-cache'], app)).stdout).toContain(library); + expect(whatAppsGet).toContain(`\`${library}\``); + expect(await tool(app, 'get_component', { name: 'Button' })).toContain('@acme/ui'); + expect(whatAppsGet).toContain( + 'every rule runs, and agents find the components with `list_components`, `search_components` and `get_component`.', + ); + // --require-design-system finds the design system (exit code 2 would say it did not). + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(1); + }); + + it('runs the token rules from a package published without src/ or type declarations, and says the component rules did not run', async () => { + const pkg = await initialised(); + const withTypes = without(published(pkg), (file) => file.startsWith('src/')); + expect(Object.keys(withTypes).sort()).toEqual([ 'dist/index.d.ts', 'dist/index.mjs', 'onsystem.config.json', @@ -282,12 +337,13 @@ describe('what apps get from a design-system package (docs/design-system-package 'theme.css', ]); // init's config, as published: apps import the package by one name, so it sets importPath. - expect(JSON.parse(distOnly['onsystem.config.json'] ?? '{}')).toMatchObject({ + expect(JSON.parse(withTypes['onsystem.config.json'] ?? '{}')).toMatchObject({ importPath: '@acme/ui', }); - const app = installedApp(distOnly); + expect(whatAppsGet).toContain('When the package publishes no type declarations either:'); + const app = installedApp(withoutTypes(withTypes), {}, { react: true }); const { rules, findings, stderr } = await check(app); - const cell = rows()[2]?.[1] ?? ''; + const cell = rows()[3]?.[1] ?? ''; expect(rules).toEqual(rulesIn(cell)); // The icon rule runs on the native ;\n', + }); + const before = await check(app); + expect(before.findings).toEqual([ + 'app/page.tsx:2 no-hardcoded-color error', + 'app/page.tsx:2 no-hardcoded-spacing warning', + ]); + const init = await run(['init', '--yes', '--force', '--no-cache'], app); + expect(init.code).toBe(0); + expect(init.stdout).not.toContain('This project reads the config @acme/ui declares'); + expect(JSON.parse(fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'))).toEqual({ + $schema: 'https://unpkg.com/onsystem/schema.json', + components: ['components/ui/**/*.tsx'], + tokens: ['app/globals.css'], + }); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + // Its own Button is still a component, not app code, and --color-brand still a token. + expect((await check(app)).findings).toEqual(before.findings); + }); +}); + +describe('inspect --explain in an app on a design-system package', () => { + it("names the package's config as where the components come from, unless the app's config names its own", async () => { + const explain = async (config: string) => { + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': config, + 'components/ui/button.tsx': BUTTON, + }); + const { stdout } = await run(['inspect', '--explain', '--no-cache'], app); + return stdout.split('\n').find((line) => line.startsWith(' components come from')); + }; + expect(await explain(ONE_RULE)).toBe( + ' components come from node_modules/@acme/ui/onsystem.config.json, so zero config did not look', + ); + expect(await explain(json({ components: ['components/ui/**/*.tsx'] }))).toBe( + ' components come from onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json, so zero config did not look', + ); + }); +}); + +describe('init in an app that zero config reads an installed design-system package for', () => { + /** `@acme/ui` in pnpm's layout: its folder in node_modules/.pnpm, linked from node_modules. */ + function pnpmApp(version: string): string { + const app = fixture({ + 'package.json': json({ name: 'web', dependencies: { '@acme/ui': version } }), + 'app/page.tsx': + 'import { Button } from "@acme/ui";\nexport default () => ;\n', + }); + install(app, version); + return app; + } + + /** Installs `@acme/ui` at `version` as pnpm does, in place of the one installed. */ + function install(app: string, version: string): void { + const link = path.join(app, 'node_modules/@acme/ui'); + fs.rmSync(link, { force: true, recursive: true }); + fs.rmSync(path.join(app, 'node_modules/.pnpm'), { force: true, recursive: true }); + const dir = path.join( + app, + `node_modules/.pnpm/@acme+ui@${version}_react@19.0.0/node_modules/@acme/ui`, + ); + const files = { + 'package.json': json({ + name: '@acme/ui', + version, + files: ['src'], + exports: { '.': './src/index.tsx', './globals.css': './src/globals.css' }, + }), + 'src/globals.css': '@theme {\n --color-primary: oklch(0.55 0.2 260);\n}\n', + 'src/index.tsx': + 'export * from "./button";\nexport * from "./card";\nexport * from "./input";\n', + 'src/button.tsx': BUTTON, + 'src/card.tsx': CARD, + 'src/input.tsx': INPUT, + }; + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + } + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(dir, link, 'junction'); + } + + it('leaves its components and tokens to zero config, which finds them again after an upgrade', async () => { + const app = pnpmApp('1.0.0'); + const init = await run(['init', '--yes', '--no-cache'], app); + expect(init.code).toBe(0); + expect(init.stdout).toContain('Found the design system through installed package @acme/ui.'); + expect(init.stdout).toContain( + '"components" and "tokens" are left to zero config: they come from a package installed in node_modules, which zero config finds again after each upgrade', + ); + const written = fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'); + expect(written).not.toContain('node_modules'); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + const before = await check(app, 'app/page.tsx'); + expect(before.rules).toEqual( + new Set(['no-unknown-variant', 'no-unknown-prop', 'no-hardcoded-color']), + ); + + // pnpm puts 1.1.0 in a folder of its own; the config still finds the package. + install(app, '1.1.0'); + const after = await check(app, 'app/page.tsx'); + expect(after.code).toBe(1); + expect(after.findings).toEqual(before.findings); + }); +}); + +describe("init in an app whose own stylesheet imports an installed design-system package's", () => { + /** `@acme/ui` as published: compiled code, its type declarations and its theme. */ + const PUBLISHED = { + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + types: './dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './dist/index.mjs' }, + './theme.css': './theme.css', + }, + }), + 'theme.css': '@theme {\n --color-primary: oklch(0.55 0.2 260);\n --spacing: 0.25rem;\n}\n', + 'dist/index.mjs': 'export function Card() {}\n', + 'dist/index.d.ts': + "import * as react from 'react';\ndeclare function Card(props: react.HTMLAttributes): react.JSX.Element;\nexport { Card };\n", + }; + + /** + * A shadcn app in @acme's npm scope, so zero config reads @acme/ui from its type declarations + * and resolves its theme: components.json names `css`, which imports the theme. @acme/ui is + * installed as npm does it, or as pnpm does, in a folder of its own under node_modules/.pnpm. + */ + function shadcnApp(layout: 'npm' | 'pnpm', css: string): string { + const app = fixture( + { + 'package.json': json({ + name: '@acme/web', + dependencies: { '@acme/ui': '1.0.0', react: '19.0.0' }, + }), + 'tsconfig.json': TSCONFIG, + 'components.json': json({ + style: 'new-york', + tsx: true, + tailwind: { css, baseColor: 'neutral', cssVariables: true }, + aliases: { components: '@/components', ui: '@/components/ui' }, + }), + 'components/ui/button.tsx': BUTTON, + 'components/ui/input.tsx': INPUT, + [css]: + '@import "tailwindcss";\n@import "@acme/ui/theme.css";\n@theme {\n --color-brand: oklch(0.7 0.1 150);\n}\n', + 'app/page.tsx': + 'import { Button } from "@/components/ui/button";\nexport default () =>
;\n', + }, + { nodeModules: true }, + ); + installUi(app, layout, '1.0.0'); + return app; + } + + /** Installs @acme/ui at `version` in place of the one installed. */ + function installUi(app: string, layout: 'npm' | 'pnpm', version: string): void { + const link = path.join(app, 'node_modules/@acme/ui'); + fs.rmSync(link, { force: true, recursive: true }); + fs.rmSync(path.join(app, 'node_modules/.pnpm'), { force: true, recursive: true }); + const dir = + layout === 'npm' + ? link + : path.join( + app, + `node_modules/.pnpm/@acme+ui@${version}_react@19.0.0/node_modules/@acme/ui`, + ); + for (const [file, text] of Object.entries(PUBLISHED)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text.replace('"1.0.0"', `"${version}"`)); + } + if (layout === 'pnpm') { + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(dir, link, 'junction'); + } + } + + /** `init --yes` in the app: what it says and writes, and what `check` then reports. */ + async function init(app: string) { + const zero = await check(app, 'app/page.tsx'); + const { code, stdout } = await run(['init', '--yes', '--no-cache'], app); + expect(code).toBe(0); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + const written = fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'); + return { zero, stdout, written, after: await check(app, 'app/page.tsx') }; + } + + it.each(['npm', 'pnpm'] as const)( + 'leaves components to zero config as well when the stylesheet is not one of the usual ones (%s)', + async (layout) => { + const app = shadcnApp(layout, 'app/styles/tailwind.css'); + const { zero, stdout, written, after } = await init(app); + expect(zero.rules).toEqual( + new Set(['no-unknown-prop', 'no-hardcoded-color', 'prefer-design-system-component']), + ); + expect(stdout).toContain( + '"tokens" is left to zero config: some of the token files come from a package installed in node_modules, which zero config finds again after each upgrade', + ); + expect(stdout).toContain( + '"components" is left to zero config as well: a config that names them reads the tokens only from the usual stylesheets (app/globals.css and the like), which leave out app/styles/tailwind.css.', + ); + expect(JSON.parse(written)).toEqual({ $schema: 'https://unpkg.com/onsystem/schema.json' }); + expect(after.findings).toEqual(zero.findings); + const inspect = await run(['inspect', '--no-cache'], app); + expect(inspect.stdout).toContain('files 2 component files, 2 token files'); + + // A new version moves pnpm's folder; zero config finds the theme again. + installUi(app, layout, '1.1.0'); + expect((await check(app, 'app/page.tsx')).findings).toEqual(zero.findings); + }, + ); + + it.each(['npm', 'pnpm'] as const)( + 'writes the components when the usual stylesheets lead to the same tokens (%s)', + async (layout) => { + const app = shadcnApp(layout, 'app/globals.css'); + const { zero, stdout, written, after } = await init(app); + expect(zero.rules).toContain('no-hardcoded-color'); + expect(stdout).not.toContain('"components" is left to zero config'); + expect(JSON.parse(written)).toEqual({ + $schema: 'https://unpkg.com/onsystem/schema.json', + components: ['components/ui/**/*.{tsx,jsx}'], + }); + expect(after.findings).toEqual(zero.findings); + installUi(app, layout, '1.1.0'); + expect((await check(app, 'app/page.tsx')).findings).toEqual(zero.findings); + }, + ); +}); + +describe('init in a design-system package published as dist/', () => { + const DTS = `import * as react from 'react'; +import * as react_jsx_runtime from 'react/jsx-runtime'; +declare function Button(props: react.ButtonHTMLAttributes & { variant?: "default" | "outline" }): react_jsx_runtime.JSX.Element; +declare function Card(props: react.HTMLAttributes): react_jsx_runtime.JSX.Element; +declare function Input(props: react.InputHTMLAttributes): react_jsx_runtime.JSX.Element; +export { Button, Card, Input }; +`; + + /** The package before `init`, with `files` publishing `dist/` and the theme, and `types` or not. */ + function distPackage(types: boolean): Record { + const { 'onsystem.config.json': _, ...sources } = acmeUi({}); + return { + ...sources, + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + files: ['dist', 'theme.css'], + exports: { + '.': { ...(types ? { types: './dist/index.d.ts' } : {}), import: './dist/index.mjs' }, + './theme.css': './theme.css', + }, + }), + ...(types ? { 'dist/index.d.ts': DTS } : {}), + }; + } + + /** `init --yes` in the package, then an app that installs what npm publishes of it. */ + async function setUp(types: boolean) { + const pkg = fixture(distPackage(types)); + const init = await run(['init', '--yes', '--no-cache'], pkg); + expect(init.code).toBe(0); + const published = ['package.json', 'onsystem.config.json', 'theme.css', 'dist/index.mjs']; + if (types) published.push('dist/index.d.ts'); + const app = fixture( + { + 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '1.0.0', react: '19' } }), + 'tsconfig.json': TSCONFIG, + 'app/page.tsx': PAGE, + ...Object.fromEntries( + published.map((file) => [ + `node_modules/@acme/ui/${file}`, + fs.readFileSync(path.join(pkg, file), 'utf8'), + ]), + ), + }, + { nodeModules: true }, + ); + return { init, app }; + } + + it('says that apps read the components from the type declarations it publishes', async () => { + const { init, app } = await setUp(true); + expect(init.stdout).toContain( + 'Apps that install @acme/ui from a registry get only what "files" in package.json publishes, which leaves out src/ (3 component files). Those apps read the components from the type declarations it publishes instead (dist/index.d.ts). Add it to "files" for them; workspace apps read the sources either way.', + ); + expect(init.stdout).toContain( + 'An app that installs @acme/ui from a registry reads its components from the type declarations it publishes, not from src/.', + ); + const { rules } = await check(app, 'app/page.tsx'); + for (const rule of COMPONENT_RULES) expect(rules).toContain(rule); + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(1); + }); + + it('says that apps get no component checks when it publishes no type declarations', async () => { + const { init, app } = await setUp(false); + expect(init.stdout).toContain( + 'which leaves out src/ (3 component files). Those apps do not know the components in it: with no type declarations published either, they get no component, prop, variant or native-element checks, and `onsystem check --require-design-system` fails in them.', + ); + expect(init.stdout).toContain( + 'An app that installs @acme/ui from a registry gets no component checks until "files" publishes src/.', + ); + const { rules } = await check(app, 'app/page.tsx'); + for (const rule of COMPONENT_RULES) expect(rules).not.toContain(rule); + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(2); + }); +});