diff --git a/.changeset/README.md b/.changeset/README.md index 77626b6..5ec9ffc 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -14,9 +14,9 @@ The repo is still an internal/private monorepo, so Changesets is used conservati This repo now has two release-adjacent workflows: - `Changeset Status` runs on pull requests and checks whether changes to releasable packages - include a `.changeset/*.md` entry. + include a `.changeset/*.md` entry, while also verifying that `registry/index.json` is current. - `Release Version PR` runs on `main` and opens or updates a version PR by running - `pnpm changeset version`. + `pnpm release:version`. The PR check intentionally focuses on the releasable packages in scope today: @@ -69,7 +69,7 @@ flow is enabled, treat Changesets as the source of truth for: - which changes deserve release notes - which internal dependency bumps should be coordinated -The current automation stops at version intent and version PR creation. It does not publish to a -registry, create tags, or assume any package registry credentials exist yet. +The current automation stops at version intent, registry metadata refresh, and version PR creation. +It does not publish to npm, create tags, or assume any package registry credentials exist yet. See `docs/releasing.md` for the expected workflow around creating and consuming changesets. diff --git a/.github/workflows/changeset-status.yml b/.github/workflows/changeset-status.yml index 336e16a..324263c 100644 --- a/.github/workflows/changeset-status.yml +++ b/.github/workflows/changeset-status.yml @@ -99,3 +99,6 @@ jobs: - name: Validate pending changesets run: pnpm changeset:status --since "origin/${BASE_REF}" + + - name: Validate registry metadata + run: pnpm registry:check diff --git a/.github/workflows/release-version-pr.yml b/.github/workflows/release-version-pr.yml index 2ff9c8e..b6a055d 100644 --- a/.github/workflows/release-version-pr.yml +++ b/.github/workflows/release-version-pr.yml @@ -40,7 +40,7 @@ jobs: - name: Open or update version PR uses: changesets/action@v1 with: - version: pnpm changeset version + version: pnpm release:version commit: "chore: version packages" title: "chore: version packages" env: diff --git a/README.md b/README.md index eb9bd2d..c33b5e8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ default styling with its own tokens, motion recipes, and component contract. - `packages/tokens`: theme tokens, motion tokens, and theme helpers - `packages/ui`: component source, variants, contracts, and tests - `apps/docs`: Storybook docs and usage reference +- `registry`: generated registry metadata plus the source-copy install contract - `tests/e2e`: Playwright smoke coverage for high-value Storybook flows ## System principles @@ -50,6 +51,12 @@ Build Storybook: pnpm build:docs ``` +Build the registry metadata: + +```bash +pnpm registry:build +``` + Run tests: ```bash @@ -64,6 +71,12 @@ pnpm lint pnpm typecheck ``` +Install source-owned components into another project: + +```bash +pnpm registry:install --project ../acme-app button dialog +``` + ## Workspace structure ```txt @@ -74,6 +87,7 @@ packages/ ui/ Component source, variants, tests, and contracts tests/ e2e/ Playwright smoke specs +registry/ Generated item metadata for copy-in installs ``` ## How the component system is organized @@ -88,6 +102,17 @@ The system is layered: The current public component layer lives in `packages/ui/src/components`, with shared helpers in `packages/ui/src/lib`. +## Registry install flow + +Cadence UI now ships a minimal internal registry flow for source-owned adoption. +Consumers pin this repo to a reviewed commit or tag, then run the local installer to +copy selected items into their own codebase. + +- Registry metadata lives in `registry/index.json` and is generated by `pnpm registry:build`. +- The installer copies components into `src/cadence-ui`, adds missing package dependencies, + and writes `src/cadence-ui/.install-manifest.json` so upgrades can reuse the same item set. +- Install and upgrade instructions live in [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md). + ## Docs and QA Storybook is the main usage reference and review surface. Component stories are expected diff --git a/docs/registry.md b/docs/registry.md new file mode 100644 index 0000000..aac7972 --- /dev/null +++ b/docs/registry.md @@ -0,0 +1,118 @@ +# Registry Install and Upgrade + +Cadence UI now supports a minimal internal registry flow for source-owned consumers. +The goal is not package publishing yet. The goal is to make one reviewed repo state +directly consumable by another app. + +## What the registry contains + +- `registry/config.json`: the authored registry format +- `registry/index.json`: generated install metadata +- `scripts/build-registry.mjs`: rebuilds and validates the generated index +- `scripts/registry-install.mjs`: copies selected source files into a consumer project + +`registry/index.json` is intentionally machine-readable. It records: + +- installable item names +- entrypoints and copied files +- transitive source files required by each item +- external package dependencies +- the package versions that produced the registry snapshot + +## Maintainer workflow + +Whenever `packages/ui` or `packages/tokens` changes in a way that affects installable +source, refresh the registry metadata: + +```bash +pnpm registry:build +``` + +To verify that the generated file is current: + +```bash +pnpm registry:check +``` + +The pull request workflow now runs `pnpm registry:check`, and the release version PR +workflow refreshes the registry automatically via `pnpm release:version`. + +## Consumer install flow + +### 1. Pin the Cadence UI source + +Consumers should install from a reviewed Cadence UI repo state, usually: + +- the merge commit of a version PR, or +- a maintainer-created tag that points at that versioned commit + +The consumer does not need this repo as a runtime dependency. It only needs access to +the checked-out source when running the installer. + +### 2. Run the installer from the Cadence UI repo + +From this repository checkout: + +```bash +pnpm registry:install --project ../acme-app button dialog +``` + +The installer will: + +- include `tokens` automatically when a component requires it +- copy files into `src/cadence-ui` +- preserve shared internal imports under `src/cadence-ui/components`, `src/cadence-ui/lib`, + and `src/cadence-ui/tokens` +- add any missing runtime dependencies to the consumer's `package.json` +- write `src/cadence-ui/.install-manifest.json` + +Useful flags: + +- `--target-dir src/shared/cadence-ui`: customize the destination root +- `--skip-package-json`: copy files without editing the consumer package manifest +- `--dry-run`: preview copied files and package changes + +## Consumer upgrade flow + +Upgrades stay source-owned. There is no generated wrapper or hidden runtime. + +1. Update the Cadence UI checkout to the new reviewed commit or tag. +2. Re-run the installer against the same consumer project. + +```bash +pnpm registry:install --project ../acme-app +``` + +If no item names are provided, the installer reuses the list stored in +`src/cadence-ui/.install-manifest.json`. + +After that: + +- review the consumer diff +- run the consumer package manager install if `package.json` changed +- verify imports still point at `src/cadence-ui/tokens/styles.css` + +## Expected consumer import points + +At minimum, the consumer app should import: + +- `src/cadence-ui/tokens/styles.css` from its global app stylesheet or entry module + +If the consumer wants the theme helpers, they can also import from: + +- `src/cadence-ui/tokens/index.ts` + +## Current scope + +This registry flow is intentionally minimal: + +- it copies source files +- it writes dependency intent into the consumer app +- it supports reinstalling the same item set for upgrades + +It does not yet: + +- publish to npm +- host a remote registry API +- auto-tag or auto-publish releases +- resolve consumer-specific codemods during upgrade diff --git a/docs/releasing.md b/docs/releasing.md index 32e8673..fe717b5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,6 +9,7 @@ The current goal is modest: - keep release notes attached to the changes that caused them - avoid inventing ad hoc version bumps when the component system evolves - make release intent enforceable in CI before package work merges +- keep the generated registry metadata aligned with versioned source ## Current assumptions @@ -68,6 +69,7 @@ At minimum, run: ```bash pnpm lint +pnpm registry:check pnpm typecheck pnpm test ``` @@ -104,10 +106,10 @@ keep the dependency graph coherent without requiring manual package edits. ### 4. Version the packages -When it is time to cut a release manually, run the Changesets version step: +When it is time to cut a release manually, run the repo version step: ```bash -pnpm changeset version +pnpm release:version ``` That step is expected to: @@ -115,6 +117,7 @@ That step is expected to: - update package versions - update internal dependency ranges where needed - consume the pending changeset files +- rebuild `registry/index.json` so the copy-in install metadata stays version-aligned Review the resulting package diffs carefully before merging. @@ -122,17 +125,19 @@ On `main`, the repository also runs a `Release Version PR` workflow that opens o version PR with the same command. That workflow is intentionally limited to version-file updates; it does not publish packages. -### 5. Publish or tag +### 5. Distribute the versioned source -Publishing is not fully wired in this repo yet, so treat this step as pending infrastructure. +Publishing is not fully wired in this repo yet, so the consumable artifact is the versioned +repository state itself. -The intended future flow is: +After the version PR merges: -```bash -pnpm changeset publish -``` +- keep the merge commit as a stable install point, or create a maintainer tag for it +- treat the committed `registry/index.json` as the install contract for that repo state +- point consumers at that commit or tag when they run `pnpm registry:install` -But until registry, auth, and CI behavior are explicit, do not assume publish is automated. +See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the consumer +install and upgrade flow. ## CI workflows @@ -153,16 +158,24 @@ pnpm changeset:status --since origin/main This validates that pending changesets resolve cleanly against the base branch. +The workflow also runs: + +```bash +pnpm registry:check +``` + +This prevents registry metadata drift from merging unnoticed. + ### Version PR workflow The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It: - installs dependencies -- runs `pnpm changeset version` +- runs `pnpm release:version` - opens or updates a version PR using `changesets/action` -This is a versioning skeleton, not a publish pipeline. It is safe to enable before registry -credentials or publish commands exist. +This is still a versioning skeleton, not a publish pipeline, but the version PR now refreshes +the committed registry metadata that consumers install from. ## Future publish prerequisites @@ -183,5 +196,6 @@ Before turning the version workflow into a publish workflow, the repo still need ## Main-thread follow-up still needed -This baseline now covers changeset intent checks in PRs and version PR creation on `main`. -What remains is the publish decision and any registry-specific automation. +This baseline now covers changeset intent checks in PRs, registry metadata validation, and +version PR creation on `main`. What remains is the publish decision, tagging policy, and any +registry-specific automation beyond source-copy installs. diff --git a/eslint.config.mjs b/eslint.config.mjs index ec4c926..3dab636 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,14 +7,42 @@ import tseslint from "typescript-eslint"; export default tseslint.config( { ignores: [ + "**/.artifacts/**", + "**/.tmp-home/**", "**/dist/**", "**/node_modules/**", + "**/output/**", + "**/playwright-report/**", "**/storybook-static/**", - "**/coverage/**" + "**/coverage/**", + "**/test-results/**" ] }, js.configs.recommended, ...tseslint.configs.recommended, + { + files: ["**/*.{js,mjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node + } + } + }, + { + files: ["**/*.cjs"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + globals: { + ...globals.node + } + }, + rules: { + "@typescript-eslint/no-require-imports": "off" + } + }, { files: ["**/*.{ts,tsx,mts,cts}"], languageOptions: { diff --git a/package.json b/package.json index 99d2cdd..41459e0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "changeset:status": "changeset status --verbose", "dev:docs": "pnpm --dir apps/docs run storybook", "lint": "eslint .", + "registry:build": "node ./scripts/build-registry.mjs", + "registry:check": "node ./scripts/build-registry.mjs --check", + "registry:install": "node ./scripts/registry-install.mjs", + "release:version": "pnpm changeset version && pnpm registry:build", "test": "pnpm --filter @ai-ui/ui test", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts", diff --git a/registry/README.md b/registry/README.md new file mode 100644 index 0000000..acb5870 --- /dev/null +++ b/registry/README.md @@ -0,0 +1,19 @@ +# Registry + +This directory holds the internal Cadence UI registry contract. + +- `config.json` is the maintained source of registry intent. +- `index.json` is generated by `pnpm registry:build`. + +The generated index is what the installer consumes. It records: + +- installable item names +- copied source files +- required package dependencies +- package versions for the registry snapshot + +Consumers should not edit `index.json` by hand. Maintainers should regenerate it after +source changes that affect installable components or tokens. + +See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the install +and upgrade flow. diff --git a/registry/config.json b/registry/config.json new file mode 100644 index 0000000..30a191d --- /dev/null +++ b/registry/config.json @@ -0,0 +1,20 @@ +{ + "libraryName": "cadence-ui", + "defaultTargetDir": "src/cadence-ui", + "tokens": { + "name": "tokens", + "description": "Base CSS variables, motion tokens, and theme helpers.", + "entrypoints": [ + "packages/tokens/src/styles.css", + "packages/tokens/src/index.ts" + ], + "targetSubdir": "tokens" + }, + "ui": { + "componentsDir": "packages/ui/src/components", + "requires": [ + "tokens" + ], + "targetSubdir": "." + } +} diff --git a/registry/index.json b/registry/index.json new file mode 100644 index 0000000..9de1ea7 --- /dev/null +++ b/registry/index.json @@ -0,0 +1,913 @@ +{ + "generatedBy": "scripts/build-registry.mjs", + "install": { + "defaultTargetDir": "src/cadence-ui", + "manifestFile": ".install-manifest.json" + }, + "items": [ + { + "description": "Base CSS variables, motion tokens, and theme helpers.", + "displayName": "Tokens", + "entrypoints": [ + "packages/tokens/src/styles.css", + "packages/tokens/src/index.ts" + ], + "files": [ + "packages/tokens/src/base.css", + "packages/tokens/src/index.ts", + "packages/tokens/src/motion.css", + "packages/tokens/src/styles.css", + "packages/tokens/src/tokens.css" + ], + "kind": "tokens", + "name": "tokens", + "packageDependencies": {}, + "requires": [], + "sourcePackage": "@ai-ui/tokens", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui/tokens" + }, + { + "description": "Source-owned Alert component.", + "displayName": "Alert", + "entrypoints": [ + "packages/ui/src/components/alert.tsx" + ], + "files": [ + "packages/ui/src/components/alert.tsx", + "packages/ui/src/components/alert.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "alert", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Avatar component.", + "displayName": "Avatar", + "entrypoints": [ + "packages/ui/src/components/avatar.tsx" + ], + "files": [ + "packages/ui/src/components/avatar.tsx", + "packages/ui/src/components/avatar.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "avatar", + "packageDependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Badge component.", + "displayName": "Badge", + "entrypoints": [ + "packages/ui/src/components/badge.tsx" + ], + "files": [ + "packages/ui/src/components/badge.tsx", + "packages/ui/src/components/badge.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "badge", + "packageDependencies": { + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Button component.", + "displayName": "Button", + "entrypoints": [ + "packages/ui/src/components/button.tsx" + ], + "files": [ + "packages/ui/src/components/button.tsx", + "packages/ui/src/components/button.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "button", + "packageDependencies": { + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.38.0", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Card component.", + "displayName": "Card", + "entrypoints": [ + "packages/ui/src/components/card.tsx" + ], + "files": [ + "packages/ui/src/components/card.tsx", + "packages/ui/src/components/card.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "card", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Checkbox component.", + "displayName": "Checkbox", + "entrypoints": [ + "packages/ui/src/components/checkbox.tsx" + ], + "files": [ + "packages/ui/src/components/checkbox.tsx", + "packages/ui/src/components/checkbox.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "checkbox", + "packageDependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Combobox component.", + "displayName": "Combobox", + "entrypoints": [ + "packages/ui/src/components/combobox.tsx" + ], + "files": [ + "packages/ui/src/components/combobox.tsx", + "packages/ui/src/components/combobox.variants.ts", + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "combobox", + "packageDependencies": { + "@radix-ui/react-popover": "^1.1.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Command component.", + "displayName": "Command", + "entrypoints": [ + "packages/ui/src/components/command.tsx" + ], + "files": [ + "packages/ui/src/components/command.tsx", + "packages/ui/src/components/command.variants.ts", + "packages/ui/src/components/dialog.tsx", + "packages/ui/src/components/dialog.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "command", + "packageDependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Data Table component.", + "displayName": "Data Table", + "entrypoints": [ + "packages/ui/src/components/data-table.tsx" + ], + "files": [ + "packages/ui/src/components/button.tsx", + "packages/ui/src/components/button.variants.ts", + "packages/ui/src/components/checkbox.tsx", + "packages/ui/src/components/checkbox.variants.ts", + "packages/ui/src/components/data-table.tsx", + "packages/ui/src/components/data-table.variants.ts", + "packages/ui/src/components/empty-state.tsx", + "packages/ui/src/components/empty-state.variants.ts", + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/input.tsx", + "packages/ui/src/components/input.variants.ts", + "packages/ui/src/components/label.tsx", + "packages/ui/src/components/select.tsx", + "packages/ui/src/components/select.variants.ts", + "packages/ui/src/components/skeleton.tsx", + "packages/ui/src/components/spinner.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "data-table", + "packageDependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.38.0", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Dialog component.", + "displayName": "Dialog", + "entrypoints": [ + "packages/ui/src/components/dialog.tsx" + ], + "files": [ + "packages/ui/src/components/dialog.tsx", + "packages/ui/src/components/dialog.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "dialog", + "packageDependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Dropdown Menu component.", + "displayName": "Dropdown Menu", + "entrypoints": [ + "packages/ui/src/components/dropdown-menu.tsx" + ], + "files": [ + "packages/ui/src/components/dropdown-menu.tsx", + "packages/ui/src/components/dropdown-menu.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "dropdown-menu", + "packageDependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Empty State component.", + "displayName": "Empty State", + "entrypoints": [ + "packages/ui/src/components/empty-state.tsx" + ], + "files": [ + "packages/ui/src/components/empty-state.tsx", + "packages/ui/src/components/empty-state.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "empty-state", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Field component.", + "displayName": "Field", + "entrypoints": [ + "packages/ui/src/components/field.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts" + ], + "kind": "component", + "name": "field", + "packageDependencies": { + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Form component.", + "displayName": "Form", + "entrypoints": [ + "packages/ui/src/components/form.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/form.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts" + ], + "kind": "component", + "name": "form", + "packageDependencies": { + "@radix-ui/react-slot": "^1.2.4", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "react-hook-form": "^7.71.2", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Input component.", + "displayName": "Input", + "entrypoints": [ + "packages/ui/src/components/input.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/input.tsx", + "packages/ui/src/components/input.variants.ts", + "packages/ui/src/components/label.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "input", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Label component.", + "displayName": "Label", + "entrypoints": [ + "packages/ui/src/components/label.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts" + ], + "kind": "component", + "name": "label", + "packageDependencies": { + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Popover component.", + "displayName": "Popover", + "entrypoints": [ + "packages/ui/src/components/popover.tsx" + ], + "files": [ + "packages/ui/src/components/popover.tsx", + "packages/ui/src/components/popover.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "popover", + "packageDependencies": { + "@radix-ui/react-popover": "^1.1.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Progress component.", + "displayName": "Progress", + "entrypoints": [ + "packages/ui/src/components/progress.tsx" + ], + "files": [ + "packages/ui/src/components/progress.tsx", + "packages/ui/src/components/progress.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "progress", + "packageDependencies": { + "@radix-ui/react-progress": "^1.1.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Radio Group component.", + "displayName": "Radio Group", + "entrypoints": [ + "packages/ui/src/components/radio-group.tsx" + ], + "files": [ + "packages/ui/src/components/radio-group.tsx", + "packages/ui/src/components/radio-group.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "radio-group", + "packageDependencies": { + "@radix-ui/react-radio-group": "^1.3.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Select component.", + "displayName": "Select", + "entrypoints": [ + "packages/ui/src/components/select.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/components/select.tsx", + "packages/ui/src/components/select.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "select", + "packageDependencies": { + "@radix-ui/react-select": "^2.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Separator component.", + "displayName": "Separator", + "entrypoints": [ + "packages/ui/src/components/separator.tsx" + ], + "files": [ + "packages/ui/src/components/separator.tsx", + "packages/ui/src/components/separator.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "separator", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Sheet component.", + "displayName": "Sheet", + "entrypoints": [ + "packages/ui/src/components/sheet.tsx" + ], + "files": [ + "packages/ui/src/components/dialog.variants.ts", + "packages/ui/src/components/sheet.tsx", + "packages/ui/src/components/sheet.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "sheet", + "packageDependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Skeleton component.", + "displayName": "Skeleton", + "entrypoints": [ + "packages/ui/src/components/skeleton.tsx" + ], + "files": [ + "packages/ui/src/components/skeleton.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "skeleton", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Spinner component.", + "displayName": "Spinner", + "entrypoints": [ + "packages/ui/src/components/spinner.tsx" + ], + "files": [ + "packages/ui/src/components/spinner.tsx", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "spinner", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Switch component.", + "displayName": "Switch", + "entrypoints": [ + "packages/ui/src/components/switch.tsx" + ], + "files": [ + "packages/ui/src/components/switch.tsx", + "packages/ui/src/components/switch.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "switch", + "packageDependencies": { + "@radix-ui/react-switch": "^1.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Tabs component.", + "displayName": "Tabs", + "entrypoints": [ + "packages/ui/src/components/tabs.tsx" + ], + "files": [ + "packages/ui/src/components/tabs.tsx", + "packages/ui/src/components/tabs.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "tabs", + "packageDependencies": { + "@radix-ui/react-tabs": "^1.1.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Textarea component.", + "displayName": "Textarea", + "entrypoints": [ + "packages/ui/src/components/textarea.tsx" + ], + "files": [ + "packages/ui/src/components/field.tsx", + "packages/ui/src/components/label.tsx", + "packages/ui/src/components/textarea.tsx", + "packages/ui/src/components/textarea.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "textarea", + "packageDependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Toast component.", + "displayName": "Toast", + "entrypoints": [ + "packages/ui/src/components/toast.tsx" + ], + "files": [ + "packages/ui/src/components/toast.tsx", + "packages/ui/src/components/toast.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts", + "packages/ui/src/lib/motion.ts" + ], + "kind": "component", + "name": "toast", + "packageDependencies": { + "@radix-ui/react-toast": "^1.2.15", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + }, + { + "description": "Source-owned Tooltip component.", + "displayName": "Tooltip", + "entrypoints": [ + "packages/ui/src/components/tooltip.tsx" + ], + "files": [ + "packages/ui/src/components/tooltip.tsx", + "packages/ui/src/components/tooltip.variants.ts", + "packages/ui/src/lib/cn.ts", + "packages/ui/src/lib/contracts.ts", + "packages/ui/src/lib/cva.ts" + ], + "kind": "component", + "name": "tooltip", + "packageDependencies": { + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^18.3.1 || ^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "requires": [ + "tokens" + ], + "sourcePackage": "@ai-ui/ui", + "sourceVersion": "0.0.0", + "targetDirectory": "src/cadence-ui" + } + ], + "library": { + "name": "cadence-ui", + "packageManager": "pnpm@10.25.0", + "packages": { + "@ai-ui/tokens": "0.0.0", + "@ai-ui/ui": "0.0.0" + } + } +} diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs new file mode 100644 index 0000000..3d2aedd --- /dev/null +++ b/scripts/build-registry.mjs @@ -0,0 +1,297 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { builtinModules } from "node:module"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const registryDir = path.join(repoRoot, "registry"); +const registryConfigPath = path.join(registryDir, "config.json"); +const registryIndexPath = path.join(registryDir, "index.json"); + +const args = new Set(process.argv.slice(2)); +const checkMode = args.has("--check"); + +const uiPackagePath = path.join(repoRoot, "packages", "ui", "package.json"); +const tokensPackagePath = path.join(repoRoot, "packages", "tokens", "package.json"); +const rootPackagePath = path.join(repoRoot, "package.json"); + +function toPosixPath(value) { + return value.split(path.sep).join(path.posix.sep); +} + +async function readJson(filePath) { + const source = await fs.readFile(filePath, "utf8"); + return JSON.parse(source); +} + +function isBuiltinSpecifier(specifier) { + return specifier.startsWith("node:") || builtinModules.includes(specifier); +} + +function normalizePackageName(specifier) { + if (specifier.startsWith("@")) { + const [scope, name] = specifier.split("/"); + return `${scope}/${name}`; + } + + return specifier.split("/")[0]; +} + +function getDisplayName(name) { + return name + .split("-") + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function getScriptSpecifiers(sourceText) { + const { importedFiles } = ts.preProcessFile(sourceText, true, true); + return importedFiles.map((entry) => entry.fileName); +} + +function getCssSpecifiers(sourceText) { + const specifiers = []; + const pattern = /@import\s+["']([^"']+)["']/g; + + for (const match of sourceText.matchAll(pattern)) { + specifiers.push(match[1]); + } + + return specifiers; +} + +async function resolveLocalImport(fromFile, specifier) { + const basePath = path.resolve(path.dirname(fromFile), specifier); + const candidates = [ + basePath, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.mjs`, + `${basePath}.cjs`, + `${basePath}.css`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + path.join(basePath, "index.js") + ]; + + for (const candidate of candidates) { + try { + const stats = await fs.stat(candidate); + + if (stats.isFile()) { + return candidate; + } + } catch { + continue; + } + } + + throw new Error( + `Unable to resolve "${specifier}" from ${toPosixPath(path.relative(repoRoot, fromFile))}.` + ); +} + +async function collectSourceGraph(entrypoints) { + const filesToVisit = [...entrypoints]; + const visited = new Set(); + const externalPackages = new Set(); + + while (filesToVisit.length > 0) { + const currentFile = filesToVisit.pop(); + + if (!currentFile || visited.has(currentFile)) { + continue; + } + + visited.add(currentFile); + + const sourceText = await fs.readFile(currentFile, "utf8"); + const localSpecifiers = currentFile.endsWith(".css") + ? getCssSpecifiers(sourceText) + : getScriptSpecifiers(sourceText); + + for (const specifier of localSpecifiers) { + if (specifier.startsWith(".") || specifier.startsWith("..")) { + filesToVisit.push(await resolveLocalImport(currentFile, specifier)); + continue; + } + + if (isBuiltinSpecifier(specifier)) { + continue; + } + + externalPackages.add(normalizePackageName(specifier)); + } + } + + return { + externalPackages: [...externalPackages].sort(), + files: [...visited] + .map((filePath) => toPosixPath(path.relative(repoRoot, filePath))) + .sort() + }; +} + +async function discoverComponentEntrypoints(componentsDir) { + const absoluteDir = path.join(repoRoot, componentsDir); + const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((fileName) => fileName.endsWith(".tsx")) + .filter((fileName) => !fileName.endsWith(".test.tsx")) + .sort() + .map((fileName) => ({ + description: `Source-owned ${getDisplayName(fileName.replace(/\.tsx$/, ""))} component.`, + entrypoints: [toPosixPath(path.join(componentsDir, fileName))], + kind: "component", + name: fileName.replace(/\.tsx$/, "") + })); +} + +function createDependencyLookup(...packageJsonFiles) { + const lookup = new Map(); + + for (const packageJson of packageJsonFiles) { + for (const section of [ + packageJson.peerDependencies ?? {}, + packageJson.dependencies ?? {}, + packageJson.devDependencies ?? {} + ]) { + for (const [name, range] of Object.entries(section)) { + if (!lookup.has(name)) { + lookup.set(name, range); + } + } + } + } + + return lookup; +} + +async function buildRegistryIndex() { + const [registryConfig, rootPackage, uiPackage, tokensPackage] = await Promise.all([ + readJson(registryConfigPath), + readJson(rootPackagePath), + readJson(uiPackagePath), + readJson(tokensPackagePath) + ]); + + const dependencyLookup = createDependencyLookup(uiPackage, tokensPackage, rootPackage); + + const itemDefinitions = [ + { + ...registryConfig.tokens, + kind: "tokens", + requires: [], + sourcePackage: tokensPackage.name + }, + ...(await discoverComponentEntrypoints(registryConfig.ui.componentsDir)).map((item) => ({ + ...item, + requires: [...registryConfig.ui.requires], + sourcePackage: uiPackage.name + })) + ]; + + const items = []; + + for (const definition of itemDefinitions) { + const entrypoints = definition.entrypoints.map((entrypoint) => + path.join(repoRoot, ...entrypoint.split("/")) + ); + const sourceGraph = await collectSourceGraph(entrypoints); + const packageDependencies = {}; + + for (const dependencyName of sourceGraph.externalPackages) { + const range = dependencyLookup.get(dependencyName); + + if (!range) { + throw new Error( + `Missing dependency range for "${dependencyName}" while building registry item "${definition.name}".` + ); + } + + packageDependencies[dependencyName] = range; + } + + items.push({ + description: definition.description, + displayName: getDisplayName(definition.name), + entrypoints: definition.entrypoints, + files: sourceGraph.files, + kind: definition.kind, + name: definition.name, + packageDependencies, + requires: definition.requires, + sourcePackage: definition.sourcePackage, + sourceVersion: + definition.sourcePackage === uiPackage.name ? uiPackage.version : tokensPackage.version, + targetDirectory: + definition.kind === "tokens" + ? toPosixPath( + path.posix.join(registryConfig.defaultTargetDir, registryConfig.tokens.targetSubdir) + ) + : toPosixPath( + path.posix.join( + registryConfig.defaultTargetDir, + registryConfig.ui.targetSubdir === "." + ? "" + : registryConfig.ui.targetSubdir + ) + ) + }); + } + + return { + generatedBy: "scripts/build-registry.mjs", + install: { + defaultTargetDir: registryConfig.defaultTargetDir, + manifestFile: ".install-manifest.json" + }, + items, + library: { + name: registryConfig.libraryName, + packageManager: rootPackage.packageManager, + packages: { + [tokensPackage.name]: tokensPackage.version, + [uiPackage.name]: uiPackage.version + } + } + }; +} + +async function main() { + const nextIndex = await buildRegistryIndex(); + const nextContent = `${JSON.stringify(nextIndex, null, 2)}\n`; + + if (checkMode) { + let currentContent = ""; + + try { + currentContent = await fs.readFile(registryIndexPath, "utf8"); + } catch { + currentContent = ""; + } + + if (currentContent !== nextContent) { + console.error("registry/index.json is out of date. Run `pnpm registry:build`."); + process.exitCode = 1; + return; + } + + console.log("registry/index.json is up to date."); + return; + } + + await fs.mkdir(registryDir, { recursive: true }); + await fs.writeFile(registryIndexPath, nextContent); + console.log(`Wrote ${toPosixPath(path.relative(repoRoot, registryIndexPath))}.`); +} + +await main(); diff --git a/scripts/registry-install.mjs b/scripts/registry-install.mjs new file mode 100644 index 0000000..2152594 --- /dev/null +++ b/scripts/registry-install.mjs @@ -0,0 +1,327 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const registryIndexPath = path.join(repoRoot, "registry", "index.json"); + +function toPosixPath(value) { + return value.split(path.sep).join(path.posix.sep); +} + +async function readJson(filePath) { + const source = await fs.readFile(filePath, "utf8"); + return JSON.parse(source); +} + +function sortObject(input) { + return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left.localeCompare(right))); +} + +function parseArgs(argv) { + const options = { + dryRun: false, + project: process.cwd(), + skipPackageJson: false, + targetDir: null + }; + const items = []; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + + if (current === "--dry-run") { + options.dryRun = true; + continue; + } + + if (current === "--skip-package-json") { + options.skipPackageJson = true; + continue; + } + + if (current === "--project" || current === "--target-dir") { + const next = argv[index + 1]; + + if (!next) { + throw new Error(`Expected a value after ${current}.`); + } + + if (current === "--project") { + options.project = next; + } else { + options.targetDir = next; + } + + index += 1; + continue; + } + + if (current.startsWith("--")) { + throw new Error(`Unknown option: ${current}`); + } + + items.push(current); + } + + return { + items, + options + }; +} + +function mapSourceToTarget(sourcePath, targetDir) { + if (sourcePath.startsWith("packages/ui/src/")) { + return toPosixPath(path.posix.join(targetDir, sourcePath.replace("packages/ui/src/", ""))); + } + + if (sourcePath.startsWith("packages/tokens/src/")) { + return toPosixPath( + path.posix.join(targetDir, "tokens", sourcePath.replace("packages/tokens/src/", "")) + ); + } + + throw new Error(`Unsupported registry source path: ${sourcePath}`); +} + +function resolveItems(registryIndex, requestedItems) { + const itemLookup = new Map(registryIndex.items.map((item) => [item.name, item])); + const resolved = new Map(); + const stack = [...requestedItems]; + + while (stack.length > 0) { + const name = stack.pop(); + const item = itemLookup.get(name); + + if (!item) { + const available = [...itemLookup.keys()].sort().join(", "); + throw new Error(`Unknown registry item "${name}". Available items: ${available}.`); + } + + if (resolved.has(name)) { + continue; + } + + resolved.set(name, item); + + for (const dependency of item.requires) { + stack.push(dependency); + } + } + + return [...resolved.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +async function readInstallManifest(manifestPath) { + try { + return await readJson(manifestPath); + } catch { + return null; + } +} + +function collectPackageDependencies(items) { + const dependencies = {}; + + for (const item of items) { + for (const [name, range] of Object.entries(item.packageDependencies)) { + dependencies[name] = range; + } + } + + return sortObject(dependencies); +} + +function upsertDependencySections(packageJson, dependencies) { + const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + const added = {}; + const conflicts = []; + const nextPackageJson = JSON.parse(JSON.stringify(packageJson)); + nextPackageJson.dependencies = nextPackageJson.dependencies ?? {}; + + for (const [name, range] of Object.entries(dependencies)) { + let existingSection = null; + let existingRange = null; + + for (const section of sections) { + const value = nextPackageJson[section]?.[name]; + + if (typeof value === "string") { + existingSection = section; + existingRange = value; + break; + } + } + + if (!existingSection) { + nextPackageJson.dependencies[name] = range; + added[name] = range; + continue; + } + + if (existingRange !== range) { + conflicts.push({ + existingRange, + name, + requiredRange: range, + section: existingSection + }); + } + } + + nextPackageJson.dependencies = sortObject(nextPackageJson.dependencies); + + return { + added, + conflicts, + packageJson: nextPackageJson + }; +} + +async function copyRegistryFiles({ dryRun, items, projectRoot, targetDir }) { + const writtenFiles = []; + const updatedFiles = []; + const unchangedFiles = []; + + for (const item of items) { + for (const sourcePath of item.files) { + const sourceFile = path.join(repoRoot, ...sourcePath.split("/")); + const targetRelativePath = mapSourceToTarget(sourcePath, targetDir); + const targetFile = path.join(projectRoot, ...targetRelativePath.split("/")); + const content = await fs.readFile(sourceFile, "utf8"); + let existingContent = null; + + try { + existingContent = await fs.readFile(targetFile, "utf8"); + } catch { + existingContent = null; + } + + if (existingContent === content) { + unchangedFiles.push(targetRelativePath); + continue; + } + + if (!dryRun) { + await fs.mkdir(path.dirname(targetFile), { recursive: true }); + await fs.writeFile(targetFile, content); + } + + if (existingContent === null) { + writtenFiles.push(targetRelativePath); + } else { + updatedFiles.push(targetRelativePath); + } + } + } + + return { + unchangedFiles, + updatedFiles, + writtenFiles + }; +} + +async function main() { + const { items: requestedItems, options } = parseArgs(process.argv.slice(2)); + const registryIndex = await readJson(registryIndexPath); + const projectRoot = path.resolve(options.project); + const targetDir = toPosixPath(options.targetDir ?? registryIndex.install.defaultTargetDir); + const targetRoot = path.join(projectRoot, ...targetDir.split("/")); + const manifestPath = path.join(targetRoot, registryIndex.install.manifestFile); + + let itemNames = requestedItems; + + if (itemNames.length === 0) { + const manifest = await readInstallManifest(manifestPath); + + if (!manifest?.items?.length) { + throw new Error( + `No registry items were provided and ${toPosixPath( + path.relative(projectRoot, manifestPath) + )} does not exist yet.` + ); + } + + itemNames = manifest.items; + } + + const resolvedItems = resolveItems(registryIndex, itemNames); + const packageDependencies = collectPackageDependencies(resolvedItems); + const copyResult = await copyRegistryFiles({ + dryRun: options.dryRun, + items: resolvedItems, + projectRoot, + targetDir + }); + + let dependencyResult = { + added: {}, + conflicts: [] + }; + + if (!options.skipPackageJson) { + const packageJsonPath = path.join(projectRoot, "package.json"); + const packageJson = await readJson(packageJsonPath); + dependencyResult = upsertDependencySections(packageJson, packageDependencies); + + if (!options.dryRun) { + await fs.writeFile( + packageJsonPath, + `${JSON.stringify(dependencyResult.packageJson, null, 2)}\n` + ); + } + } + + const installManifest = { + items: resolvedItems.map((item) => item.name), + packageDependencies, + registry: registryIndex.library.name, + sourcePackages: registryIndex.library.packages, + targetDir + }; + + if (!options.dryRun) { + await fs.mkdir(targetRoot, { recursive: true }); + await fs.writeFile(manifestPath, `${JSON.stringify(installManifest, null, 2)}\n`); + } + + console.log( + `${options.dryRun ? "Planned" : "Installed"} registry items: ${resolvedItems + .map((item) => item.name) + .join(", ")}` + ); + console.log( + `Files written: ${copyResult.writtenFiles.length}, updated: ${copyResult.updatedFiles.length}, unchanged: ${copyResult.unchangedFiles.length}` + ); + + if (!options.skipPackageJson) { + const added = Object.keys(dependencyResult.added); + console.log( + added.length > 0 + ? `Added package dependencies: ${added.join(", ")}` + : "Added package dependencies: none" + ); + } + + if (dependencyResult.conflicts.length > 0) { + console.log("Dependency version conflicts:"); + + for (const conflict of dependencyResult.conflicts) { + console.log( + `- ${conflict.name}: keeping ${conflict.section}=${conflict.existingRange}, registry expects ${conflict.requiredRange}` + ); + } + } + + console.log(`Target directory: ${targetDir}`); + console.log(`Tokens import path: ${targetDir}/tokens/styles.css`); + console.log( + options.dryRun + ? "Dry run complete." + : "Review the diff, then run your package manager install if package.json changed." + ); +} + +await main();