diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe235a1..ead0a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,20 +78,38 @@ jobs: run: | pnpm run build pnpm pack --pack-destination "$RUNNER_TEMP" - - - name: Install the tarball into a clean React project + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP" + + # pnpm, as every consumer installs: pnpm 11 fails the install with + # ERR_PNPM_IGNORED_BUILDS when a dependency's build script is neither + # allowed nor declined, which npm never reports. fixture/pnpm-workspace.yaml + # holds the allowBuilds block a consumer needs, and makes the fixture its + # own workspace, apart from this repository's. + - name: Install the tarballs into a clean React project working-directory: fixture run: | - TARBALL=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-*.tgz' | head -1) - echo "Installing $TARBALL" - npm install --no-package-lock "$TARBALL" - npm install --no-package-lock + LIB=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-[0-9]*.tgz' | head -1) + CLI=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-cli-*.tgz' | head -1) + echo "Installing $LIB and $CLI" + pnpm add "$LIB" + pnpm add --save-dev "$CLI" - name: Type-check and build the fixture against the packed artifact working-directory: fixture run: | - npx tsc --noEmit - npx vite build + pnpm exec tsc --noEmit + pnpm exec vite build + + # The CLI resolves the project's @lablup/ui-common and the Astryx CLI it + # pins from two different packages; this runs it where a consumer would. + - name: Run the ui-common bin from the fixture + working-directory: fixture + run: | + pnpm exec ui-common --version --verbose + pnpm exec ui-common --help > /dev/null + pnpm exec ui-common agents > /dev/null + pnpm exec ui-common component Button --json > /dev/null + pnpm exec ui-common upgrade --from 0.1 --dry-run > /dev/null # Building is not the same as being styled. 0.1.0-alpha.0 built here # green while shipping every component's CSS as an asset nothing could diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1734c47..6b59761 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,39 +56,71 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. + # The registry still sets `latest` on a package's first publish, so + # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the + # docs say `@next` until then. Do not move `latest` here. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: pnpm publish --no-git-checks --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: pnpm pack && ls -la *.tgz + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done # GitHub Packages requires authentication even for public packages, so an # open-source consumer cannot install from it without every contributor @@ -130,46 +162,78 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. + # The registry still sets `latest` on a package's first publish, so + # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the + # docs say `@next` until then. Do not move `latest` here. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Confirm the credential is valid for npmjs run: npm whoami --registry https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # npm rather than pnpm here: provenance is an npm CLI feature and this - # is the one step that needs it. The tarball is identical either way, - # since both pack from the same files allowlist. + # npm publishes the tarballs pnpm packed: provenance is an npm CLI + # feature, and npm would leave the CLI's `workspace:*` peer unrewritten + # if it packed the directory itself. - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: npm publish --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 2d577cb..1e30d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ .eslintcache fixture/node_modules/ fixture/dist/ +fixture/pnpm-lock.yaml diff --git a/.prettierignore b/.prettierignore index 529117b..85289f4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ coverage/ pnpm-lock.yaml LICENSE src/theme/*/built/ -test/upgrade/fixtures/ +packages/cli/test/upgrade/fixtures/ .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffd2e9..e0d24ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,64 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Changed + +- **The `ui-common` CLI is its own package, `@lablup/ui-common-cli`**, in + this repository under `packages/cli` and released in lockstep with the + library, the way Astryx ships `@astryxdesign/cli` beside + `@astryxdesign/core`. `@lablup/ui-common` no longer has a `bin` and no + longer depends on `@astryxdesign/cli`, `jscodeshift` or `postcss`: a + production install of an app on the library alone drops from 196 MB (132 + packages) to 31 MB (25). Run the 0.1 upgrade with + `pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1` (`@next` until + 0.2.0 is published: npm points a new package's `latest` at its first + prerelease); after it, + `@lablup/ui-common-cli` is a devDependency and `pnpm exec ui-common` works + as before. The CLI needs Node 22.13 or later, as `@astryxdesign/cli` does. +- Prereleases publish under the `next` dist-tag; only a plain version moves + `latest`. The 0.1 line's `alpha` tag stays where it is. The registry sets + a new package's `latest` on its first publish regardless, so until 0.2.0 + `@lablup/ui-common-cli`'s `latest` is its first alpha: name `@next`. +- `ui-common upgrade --from 0.1`: + - migrates elements a project imports through its own barrels (relative + imports and tsconfig `paths`), and lists local wrapper components around + a 0.1 component for review instead of rewriting their call sites; + - drops a local re-export of a removed type together with its import (it + used to refuse to write such a file); + - wires the 0.2 stylesheets into the app entry when the project never + imported `styles/base.css`, and places that import before any + `@lablup/ui-common` import; + - adds `` at a single clear root render and + reports 0.1 theme switches (`data-theme="orange-*"`, `[data-theme]` + selectors); + - searches the whole project for manual-review findings (tests, e2e, + scripts), not only `src/`; `--scan ` narrows it; + - narrows a library's `react` / `react-dom` peers to the React ui-common's + own peer starts at, alternative by alternative (`>=18 <21 || ^22` → + `>=19.2.0 <21 || ^22`), and reports a range with no such React; + - adds `allowBuilds` for `@astryxdesign/core` and `@astryxdesign/cli` to a + pnpm project's `pnpm-workspace.yaml`; + - adds `@lablup/ui-common-cli` as a devDependency; + - lists class names the project also defines and uses itself as lower + confidence. + ### Fixed +- **`ui-common` was the lowest cascade layer in consumer bundles.** Each + component module imports its own stylesheet, and each opened + `@layer ui-common{…}`. A product imports ui-common's modules before its + entry stylesheet, where the order statement lives, and a layer's position is + fixed by first appearance, so the bundle ranked `ui-common` below `reset` + and Astryx's `astryx-base` and `astryx-theme`: Astryx's rules beat + ui-common's composites (`StatCard`'s `overflow: hidden` lost to `Card`'s + `overflow: clip`). Every stylesheet the package ships (component sheets, + `ui-common.css`, `legacy-tokens.css`, `styles/`, the Lablup `theme.css` and + the Astryx `@import` mirrors) now opens with + `@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;`, + defined once in `scripts/layer-order.mjs` and prepended by the build. + Keep declaring it in the app's entry stylesheet too; repeating it is a + no-op. `check:pack` fails on a packed stylesheet without it, and CI's + fixture check fails when the consumer bundle establishes any other order. - **The `react` and `react-dom` peers are `^19.2.0`.** `Modal`, `NotificationStack`, `UnitGrid` and `Form` import `useEffectEvent`, stable since React 19.2; `^19.0.0` let 19.0 and 19.1 install and then fail. @@ -28,6 +84,70 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). leading spaces. For a library it adds the StyleX (and lab) peer even when a devDependency already names the package. +### Added + +- In development, a warning when a second copy of `@lablup/ui-common` is + loaded, naming each copy and saying whether they also run on separate + copies of `@astryxdesign/core`. Each copy registers under + `globalThis[Symbol.for("@lablup/ui-common/instance")]` on first use of its + string translator or modal stack; production builds drop it. + +### Documentation + +- README: `allowBuilds` for Astryx's postinstall scripts under pnpm 10 and + later (pnpm 11 fails with `ERR_PNPM_IGNORED_BUILDS` without it), the + shipped layer statement, why `` is required and + how dark mode works (``; 0.1's `data-theme="orange-dark"` + toggles no longer work), and Vitest's `server.deps.inline` for jsdom tests. +- The Astryx integration's agent docs and doc page name + `@lablup/ui-common/theme/lablup/built`, the theme that pairs with + `theme.css`, instead of the source theme. + +## Upgrading from 0.1 + +The 0.2 alphas below add up to these changes for a 0.1 consumer. Run +`pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run` (plain +`@lablup/ui-common-cli` once 0.2.0 is published) for the +mechanical part; +[`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every import, +prop, class and stylesheet change it reads. + +- **Dependencies.** Astryx (`@astryxdesign/core`, `theme-neutral`) is an + exact-pinned dependency (the CLI moved to `@lablup/ui-common-cli`); import it only through `@lablup/ui-common`. + `@stylexjs/stylex` ^0.19 is a new peer, `@astryxdesign/lab` an optional + exact peer (with an `overrides` entry for its core), and React 19.2 or + later is required. (0.2.0-alpha.0; 19.2 since the release after + 0.2.0-alpha.14) +- **The root barrel is Astryx's**, and `@lablup/ui-common/hooks` is Astryx's + hooks; `usePrefersReducedMotion` moved to the root. (0.2.0-alpha.0, + 0.2.0-alpha.2) +- **Removed components, each replaced by Astryx:** `Badge`, `BaseCard`, + `Button`, `DataTable`, `Drawer` (lab), `EmptyState`, `ProgressBar`, + `Select` (`Selector`), `Skeleton`, `StatusTag` (`StatusDot`), `Tabs` + (`TabList`) and `Tooltip`, with their props renamed to Astryx's + (`children` → `label`, `disabled` → `isDisabled`, …). (0.2.0-alpha.1) +- **Kept components keep their props** but render Astryx, style in + `@layer ui-common` and take `uic-` class names (`page-header` → + `uic-page-header`). CSS, tests and DOM queries on the old names need + updating. (0.2.0-alpha.1) +- **Dialogs:** Astryx `Dialog` and `AlertDialog` are hidden; use `Modal` and + `AlertModal`. An open `Modal` makes the rest of the page `inert`; an + overlay of the app's own that must stay usable over it needs + `data-uic-modal-live`. (0.2.0-alpha.1, 0.2.0-alpha.5, 0.2.0-alpha.7) +- **Stylesheets and theme:** `styles/base.css` becomes the layer statement + plus `reset.css`, `astryx.css`, `theme/lablup/theme.css`, `ui-common.css` + (and `legacy-tokens.css` while `--token-*` names are still read); drop + `styles/themes/*.css`. Wrap the app in `` from + `theme/lablup/built`. `--token-*`, `styles/base.css` and + `styles/themes/*.css` are deprecated and go in 0.3. (0.2.0-alpha.0) +- **Strings** resolve through Astryx's `InternationalizationProvider`; pass + `uiCommonMessages` from `@lablup/ui-common/i18n-catalog`. Shared keys are + `uic.common.*`. (0.2.0-alpha.0, 0.2.0-alpha.3) +- **Custom properties** follow Astryx's naming: the theme's info hue is + `--color-info`, component knobs are `---` + (DigitPopIn's are their 0.1 names again), and no `--uic-*` name remains. + (0.2.0-alpha.12) + ## [0.2.0-alpha.14] A paged selector moves in from a product, and `Drawer` hands Escape to the @@ -540,7 +660,7 @@ upgrade tool. The component layer moves onto Astryx: the 0.1 look-alikes are gone, the components Astryx has no counterpart for are rebuilt on it with their 0.1 props, and `Modal` takes the place of the hidden `Dialog`. -[`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) lists every change +[`migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every change below in the form `ui-common upgrade` reads. ### Removed @@ -1309,7 +1429,26 @@ mid-migration. validation, and a clean external React install fixture. - Apache-2.0 license and the initial public boundary rules. -[Unreleased]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.19...HEAD +[Unreleased]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.14...HEAD +[0.2.0-alpha.14]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.13...v0.2.0-alpha.14 +[0.2.0-alpha.13]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.12...v0.2.0-alpha.13 +[0.2.0-alpha.12]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.11...v0.2.0-alpha.12 +[0.2.0-alpha.11]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.10...v0.2.0-alpha.11 +[0.2.0-alpha.10]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.9...v0.2.0-alpha.10 +[0.2.0-alpha.9]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.8...v0.2.0-alpha.9 +[0.2.0-alpha.8]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.7...v0.2.0-alpha.8 +[0.2.0-alpha.7]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.6...v0.2.0-alpha.7 +[0.2.0-alpha.6]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.5...v0.2.0-alpha.6 +[0.2.0-alpha.5]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.4...v0.2.0-alpha.5 +[0.2.0-alpha.4]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.3...v0.2.0-alpha.4 +[0.2.0-alpha.3]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.2...v0.2.0-alpha.3 +[0.2.0-alpha.2]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.1...v0.2.0-alpha.2 +[0.2.0-alpha.1]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.0...v0.2.0-alpha.1 +[0.2.0-alpha.0]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.23...v0.2.0-alpha.0 +[0.1.0-alpha.23]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.22...v0.1.0-alpha.23 +[0.1.0-alpha.22]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.21...v0.1.0-alpha.22 +[0.1.0-alpha.21]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.20...v0.1.0-alpha.21 +[0.1.0-alpha.20]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.19...v0.1.0-alpha.20 [0.1.0-alpha.19]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.18...v0.1.0-alpha.19 [0.1.0-alpha.18]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.17...v0.1.0-alpha.18 [0.1.0-alpha.17]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.16...v0.1.0-alpha.17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47e7bba..ecaa7bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -393,20 +393,22 @@ pre-built CSS at runtime. ## Bumping Astryx `@astryxdesign/core`, `@astryxdesign/theme-neutral` and `@astryxdesign/cli` -move together, exact-pinned. `@astryxdesign/lab` is an exact canary pin, as +move together, exact-pinned. The Astryx CLI is pinned twice: as a dependency +of `packages/cli` (`@lablup/ui-common-cli`, which runs it for consumers) and as +a devDependency of the root (its `theme:*` and `check:integration` scripts). `@astryxdesign/lab` is an exact canary pin, as both a devDependency and an optional peer. `ui-common sync-astryx` does the bump: ``` -node bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run -node bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 ``` -It moves the pins, runs `pnpm install`, `pnpm run gen:exports` and +It moves the pins in both `package.json` files, runs `pnpm install`, `pnpm run gen:exports` and `pnpm run theme:build`, runs Astryx's own codemods on `src/` (a dry run, then applied), runs the tests, and records the Astryx codemods consumers need in -`codemods//upstream.json` (`--as ` picks the version; +`packages/cli/codemods//upstream.json` (`--as ` picks the version; release under that version). `ui-common upgrade` runs them for a consumer that crosses it, with the `@lablup/ui-common` specifiers swapped for Astryx's so Astryx's codemods recognise them. It runs Astryx's codemods before the tests, @@ -424,26 +426,26 @@ Then, by hand: ## The upgrade tool -`ui-common upgrade` runs the steps in `codemods/registry.mjs`, keyed by the +`ui-common upgrade` runs the steps in `packages/cli/codemods/registry.mjs`, keyed by the ui-common version that made the change, over a consumer's source. -- **0.1 → 0.2** (`codemods/0.2/`) takes all of its data from - [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json): replacement +- **0.1 → 0.2** (`packages/cli/codemods/0.2/`) takes all of its data from + [`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json): replacement imports, prop renames, value maps, required packages, stylesheet entry points, class renames and the manual notes its TODO markers quote. Change the map, not the codemods, when the migration changes. - `codemods/0.2/legacy-classes.json` lists the 0.1 class names; regenerate it - from a 0.1 checkout with `scripts/extract-legacy-classes.mjs`. -- **Upstream steps** are `codemods//upstream.json`, written by + `packages/cli/codemods/0.2/legacy-classes.json` lists the 0.1 class names; regenerate it + from a 0.1 checkout with `packages/cli/scripts/extract-legacy-classes.mjs`. +- **Upstream steps** are `packages/cli/codemods//upstream.json`, written by `sync-astryx`. A codemod that cannot prove a rewrite safe leaves the code as it was, with a -`TODO(ui-common-upgrade):` comment and a report entry. `test/upgrade/` runs +`TODO(ui-common-upgrade):` comment and a report entry. `packages/cli/test/upgrade/` runs every step over fixture projects and compares the result with `expected/`, report included. After an intended change: ``` -UPDATE_FIXTURES=1 pnpm vitest run test/upgrade +UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/upgrade ``` and read the diff. Fixtures are consumer code: keep them free of product diff --git a/README.md b/README.md index b37d51c..576f7da 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,33 @@ Peer dependencies: `@lablup/ui-common/lab`. It is pinned to the exact canary ui-common is built against, and it needs the override below. -Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`, -`@astryxdesign/cli`) comes in as ui-common's own dependencies, pinned exactly. +Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`) comes in +as ui-common's own dependencies, pinned exactly. The `ui-common` bin and the +Astryx CLI it wraps are a separate dev-time package, `@lablup/ui-common-cli` +([The ui-common CLI](#the-ui-common-cli)). `lucide-react` (the icon set Astryx's neutral theme already depends on) and `intl-messageformat` come in the same way. Do not add them to your project. ui-common owns the Astryx version. Two copies of Astryx means two copies of its React contexts, and components stop seeing the theme. +### pnpm 10 and later + +`@astryxdesign/core` and `@astryxdesign/cli` have `postinstall` scripts. pnpm +10 and later run no dependency's install scripts until the project decides +about each one: pnpm 10 installs and prints a warning, pnpm 11 fails +`pnpm install` with `ERR_PNPM_IGNORED_BUILDS`. The scripts only print an +`astryx init` hint, so decline them, in `pnpm-workspace.yaml`: + +```yaml +allowBuilds: + "@astryxdesign/cli": false + "@astryxdesign/core": false +``` + +This repository's own `pnpm-workspace.yaml` does the same. npm runs the +scripts, or asks about them, and needs nothing. + ### With `@lablup/ui-common/lab` The lab canary declares an exact peer on the core canary it was cut from, not @@ -103,8 +122,8 @@ Locally, use a personal access token with `read:packages`, in your user ## Set up -Declare the layer order once, first, in your app's entry stylesheet. Then load -the stylesheets: +Declare the layer order first in your app's entry stylesheet, then load the +stylesheets in this order: ```css @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @@ -117,6 +136,16 @@ the stylesheets: @import "@lablup/ui-common/lab/lab.css"; ``` +Every stylesheet ui-common ships starts with the same `@layer` statement too, +component sheets included. A layer's place is fixed by the first stylesheet +that names it, and a component's sheet (imported by its module) usually +reaches the page before your entry stylesheet does. Without the statement in +the component sheets, `ui-common` would be the lowest layer and Astryx's base +styles would beat ui-common's. Declaring it in your entry stylesheet as well +is still recommended: it documents the order, and it places your own +`components` and `utilities` layers wherever your sheets load. Repeating an +identical statement changes nothing. + Wrap the app in the theme: ```tsx @@ -128,6 +157,17 @@ import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"; ; ``` +`` is required. `theme.css` is scoped to +`[data-astryx-theme="lablup"]`, which only `` sets, so without it the +app renders Astryx's default palette. No error is raised. + +Dark mode is the `mode` prop: ``, +`"light"`, or `"system"` (the default, which follows the OS). The root +`` owns `html[data-theme]`: it sets `light` or `dark`, removes the +attribute for `system`, and removes it on unmount. A 0.1-style toggle that +writes its own value there, such as `data-theme="orange-dark"`, no longer +works. Switch `mode` instead. + `/theme/lablup/built` pairs with `theme.css` and injects nothing at runtime. `@lablup/ui-common/theme/lablup` is the same theme as source, for runtime injection or for extending it with `defineTheme`. Use one or the other. @@ -136,6 +176,31 @@ Astryx's neutral theme is mirrored the same way at `/theme/neutral`. The theme names its font family (Ubuntu Sans, then Pretendard Variable) but does not load it. Loading fonts is the app's job. +### Tests (Vitest with jsdom) + +ui-common's modules import their stylesheets, and Node cannot load a `.css` +import from `node_modules`. Vitest externalises dependencies by default, so a +test that imports ui-common fails with `Unknown file extension ".css"`. +Let Vitest process the package instead: + +```ts +// vitest.config.ts +export default defineConfig({ + test: { + environment: "jsdom", + server: { deps: { inline: [/@lablup\/ui-common/] } }, + }, +}); +``` + +### Two copies + +In development, ui-common warns in the console when a second copy of itself +is loaded, and says whether the copies also run on separate copies of +`@astryxdesign/core`. Two copies do not share the modal stack, and with two +Astryx cores the `Theme` and i18n providers stop reaching components. Dedupe +until `pnpm why @lablup/ui-common` lists one version. + ### Layers | Layer | Owner | @@ -375,26 +440,46 @@ Removed in 0.2, each replaced by Astryx: The kept components keep their 0.1 props. Their class names moved to `uic-` (`page-header` is `uic-page-header`), so CSS or tests that select the old -names need updating. [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) +names need updating. [`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every import, prop, class and stylesheet change in a form the upgrade tool reads. Before you start, read [docs/migrating-to-0.2.md](docs/migrating-to-0.2.md): the problems the first app hit when it moved onto 0.2, and a checklist. -Let the upgrade tool do the mechanical part. After bumping the dependency: +Let the upgrade tool do the mechanical part. It ships in +`@lablup/ui-common-cli`, so run it one-off from the project still on 0.1: ``` -pnpm exec ui-common upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report -pnpm exec ui-common upgrade --from 0.1 # applies it +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 # applies it ``` +(`npx @lablup/ui-common-cli@next upgrade --from 0.1` with npm.) Keep the +`@next` while 0.2 is in prerelease: the CLI has published only prereleases, +which go to the `next` dist-tag, and npm points `latest` at a package's first +publish, so a bare `@lablup/ui-common-cli` resolves to its first alpha. Drop +`@next` once 0.2.0 is published. It bumps +`@lablup/ui-common` in `package.json` and adds `@lablup/ui-common-cli` as a +devDependency at the same version; then run your install, and later upgrades +are `pnpm exec ui-common upgrade --from `. + It moves the imports, reshapes the props it can prove safe, rewrites the -`styles/base.css` import into the 0.2 stylesheet set, and updates -`package.json`. Everything else is a `TODO(ui-common-upgrade)` comment in the +`styles/base.css` import into the 0.2 stylesheet set (or, in an app that +never imported it, imports that set first in the app's entry script), wraps +the app's root render (`createRoot(…).render()`) in +`` when no module uses `` yet, and updates +`package.json`. Code that imports a moved component through a module of your +own that re-exports it (a barrel such as `@/components/common`, found through +relative paths and your tsconfig `paths`) gets the same rewrite. A component of +yours that wraps one and takes its props is listed in the report instead: its +props are yours to change. Everything else is a `TODO(ui-common-upgrade)` comment in the code and a line in `ui-common-upgrade-report.md`, together with the CSS, DOM -queries, tests and module mocks that still name 0.1 classes, and custom -properties of yours that Astryx declares too. +queries, tests and module mocks that still name 0.1 classes, custom +properties of yours that Astryx declares too, and code that switches 0.1 +themes through `data-theme`. Steps the app cannot work without (the +stylesheets or ``, where the upgrade could not add them) open the +report under "Action required". Deprecated in 0.2, removed in 0.3: @@ -417,8 +502,30 @@ Deprecated in 0.2, removed in 0.3: ## The ui-common CLI -ui-common ships a `ui-common` bin. It wraps the Astryx CLI that ui-common pins, -so a project needs no `@astryxdesign/*` dependency of its own to use it. +The `ui-common` bin is its own package, `@lablup/ui-common-cli`, released at +the same version as `@lablup/ui-common` and taking it as a peer. It wraps the +Astryx CLI it pins, so a project needs no `@astryxdesign/*` dependency of its +own to use it. Being separate keeps the Astryx CLI and the codemod toolchain +(jscodeshift, postcss) out of a production install, the way Astryx splits +`@astryxdesign/cli` from `@astryxdesign/core`. Keep it a devDependency pinned +to the same version as `@lablup/ui-common`, and bump the two together: + +``` +pnpm add -D @lablup/ui-common-cli@ +``` + +Under pnpm 11, allow or decline the Astryx packages' postinstall (it only +prints an `astryx init` nudge) in `pnpm-workspace.yaml`, or the install stops +with `ERR_PNPM_IGNORED_BUILDS` (`ui-common upgrade` adds the entries a pnpm +project does not decide yet): + +```yaml +allowBuilds: + "@astryxdesign/core": false + "@astryxdesign/cli": false +``` + +Then: ``` pnpm exec ui-common component Button # any Astryx command: component, search, @@ -427,19 +534,22 @@ pnpm exec ui-common agents --write AGENTS.md pnpm exec ui-common upgrade --from 0.1 --dry-run ``` -| Command | What it does | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ui-common …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/` is `@lablup/ui-common/`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | -| `ui-common astryx …` | The same, without rewriting. | -| `ui-common agents [--write ] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `` and ``, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | -| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). `--from` defaults to the version `package.json` declares, `--to` to the installed one. | -| `ui-common sync-astryx [--lab ] [--as ] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ui-common …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/` is `@lablup/ui-common/`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | +| `ui-common astryx …` | The same, without rewriting. | +| `ui-common agents [--write ] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `` and ``, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | +| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [--scan ]… [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). The report's manual-review findings come from the whole project (tests, e2e specs, scripts), or only from the `--scan` paths. `--from` defaults to the version `package.json` declares, `--to` to the CLI's own (the ui-common version it ships with). | +| `ui-common sync-astryx [--lab ] [--as ] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | Exit codes: a passed-through command exits with Astryx's code. ui-common's own commands exit 0 on success, 1 on a failed check or run, and 2 on bad arguments. `component`, `search` and the other lookups find `@astryxdesign/core` through -ui-common, so they work in a project that depends on ui-common alone. +the project's `@lablup/ui-common`, so they work in a project that depends on +ui-common (and the CLI) alone. Without the CLI installed, any command runs +one-off as `pnpm dlx @lablup/ui-common-cli@next ` (or `npx`; plain +`@lablup/ui-common-cli` once 0.2.0 is published). ui-common is also an Astryx CLI integration: `ui-common docs ui-common` (or `astryx docs ui-common`) explains the layer, and `ui-common component Modal` diff --git a/astryx.integration.mjs b/astryx.integration.mjs index 4647bdc..7fa7ceb 100644 --- a/astryx.integration.mjs +++ b/astryx.integration.mjs @@ -26,7 +26,7 @@ export default { append: [ "Import Astryx through @lablup/ui-common, never @astryxdesign/*: same subpaths (@lablup/ui-common/Button, /theme/tokens.stylex, /reset.css, /lab).", "Use Modal, not Dialog. ui-common hides Astryx Dialog (exports.exclude.json) so every product has one dialog surface.", - "Theme with from @lablup/ui-common/theme/lablup. Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", + "Theme with from @lablup/ui-common/theme/lablup/built (pairs with theme.css). Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", "ui-common strings resolve through Astryx InternationalizationProvider: pass uiCommonMessages from @lablup/ui-common/i18n-catalog in its messages.", "ComplexSelector, and lab's Drawer and Tour, come from ui-common: fixed copies of Astryx's, same API and import paths. ComplexSelector adds hasClear/onClear.", ], diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index bacc088..be64f9b 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -24,7 +24,7 @@ export const docs = { items: [ "Import Astryx components from @lablup/ui-common, at the root or at the same subpath Astryx uses.", "Import StyleX tokens from `@lablup/ui-common/theme/tokens.stylex`. The `.stylex` suffix is what the StyleX compiler looks for.", - "Wrap the app in `` from `@lablup/ui-common/theme/lablup`.", + "Wrap the app in `` from `@lablup/ui-common/theme/lablup/built`, which pairs with `theme/lablup/theme.css`.", ], }, { diff --git a/cli/paths.mjs b/cli/paths.mjs deleted file mode 100644 index 1554fb2..0000000 --- a/cli/paths.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Where things live, resolved from this package rather than from the working - * directory. A consumer depends on @lablup/ui-common only, so under pnpm's - * isolated layout @astryxdesign/* is reachable from here and not from the - * consumer's own directory. - */ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -/** Root of the installed @lablup/ui-common package. */ -export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -const require = createRequire(join(PACKAGE_ROOT, "package.json")); - -/** @param {string} file */ -export function readJson(file) { - return JSON.parse(readFileSync(file, "utf8")); -} - -/** @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record, devDependencies?: Record}} */ -export function ownPackageJson() { - return readJson(join(PACKAGE_ROOT, "package.json")); -} - -/** Walk up from a resolved file to the directory holding its package.json. */ -function packageDirOf(file, name) { - let dir = dirname(file); - for (;;) { - const pkg = join(dir, "package.json"); - if (existsSync(pkg)) { - try { - if (readJson(pkg).name === name) return dir; - } catch { - // keep walking - } - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * The directory of a dependency of ui-common, or null. Resolution goes through - * the package's main entry, because not every Astryx package exports its - * package.json. - * - * @param {string} name - */ -export function dependencyDir(name) { - try { - return packageDirOf(require.resolve(name), name); - } catch { - return null; - } -} - -/** Root of the exact-pinned @astryxdesign/cli. */ -export function astryxCliRoot() { - const dir = dependencyDir("@astryxdesign/cli"); - if (!dir) { - throw new Error( - "@astryxdesign/cli is not installed next to @lablup/ui-common. Reinstall @lablup/ui-common.", - ); - } - return dir; -} - -/** The `astryx` bin script of the pinned CLI. */ -export function astryxBin() { - const root = astryxCliRoot(); - const bin = readJson(join(root, "package.json")).bin?.astryx; - return join(root, bin ?? "clients/cli/bin/astryx.mjs"); -} - -/** - * Import a module file from inside the pinned Astryx CLI. These are internals, - * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the - * tests that use them on every bump. - * - * @param {string} relative path under the CLI root - */ -export async function importAstryxInternal(relative) { - const file = join(astryxCliRoot(), relative); - return import(pathToFileURL(file).href); -} - -/** Version of an installed dependency of ui-common, or null. */ -export function dependencyVersion(name) { - const dir = dependencyDir(name); - return dir ? readJson(join(dir, "package.json")).version : null; -} - -/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ -export function excludedExports() { - const file = join(PACKAGE_ROOT, "exports.exclude.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( - readJson(file) - ); -} - -/** `exports.customs.json`: ui-common's own exports. */ -export function customExports() { - const file = join(PACKAGE_ROOT, "exports.customs.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( - readJson(file) - ); -} - -/** - * ui-common's own copies of Astryx components (src/forks/): same names and - * import paths as Astryx's, which exports.exclude.json hides. `names` are the - * names a reader may meet it by; `from` is where a consumer imports it. - * - * @returns {Array<{name: string, from: string, names: string[], reason: string}>} - */ -export function forkedExports() { - const exclusions = excludedExports(); - return customExports() - .filter((c) => c.fork !== undefined) - .map((c) => { - const exclusion = exclusions.find((e) => e.replacedBy === c.name); - return { - name: c.name, - from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, - names: exclusion?.exports ?? [c.name], - reason: exclusion?.reason ?? "", - }; - }); -} - -/** - * The exclusions that hide an Astryx name behind a different one. A fork's - * exclusion is not one: the name stays, now ui-common's. - */ -export function hiddenExports() { - const forks = new Set(forkedExports().map((f) => f.name)); - return excludedExports().filter( - (e) => e.replacedBy === null || !forks.has(e.replacedBy), - ); -} - -/** - * The nearest directory at or above `start` holding a package.json. - * - * @param {string} start - */ -export function findProjectDir(start) { - let dir = resolve(start); - for (;;) { - if (existsSync(join(dir, "package.json"))) return dir; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * How this project runs a locally installed bin, from its lockfile. - * - * @param {string} projectDir - */ -export function binInvocation(projectDir, bin = "ui-common") { - let dir = resolve(projectDir); - for (;;) { - if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; - if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; - if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { - return `bunx ${bin}`; - } - if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; - const parent = dirname(dir); - if (parent === dir) return `npx ${bin}`; - dir = parent; - } -} diff --git a/cli/semver.mjs b/cli/semver.mjs deleted file mode 100644 index 1add0bd..0000000 --- a/cli/semver.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * The little semver the upgrade registry needs: parse, compare with - * prerelease precedence, and coerce the loose forms a person types - * (`0.1`, `v0.1.0-alpha.19`) or a package.json holds (`^0.1.0-alpha.7`). - */ - -const SEMVER = - /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; - -/** - * @param {string} input - * @returns {{major: number, minor: number, patch: number, pre: string[]} | null} - */ -export function parse(input) { - const match = SEMVER.exec(String(input).trim()); - if (!match) return null; - return { - major: Number(match[1]), - minor: Number(match[2] ?? 0), - patch: Number(match[3] ?? 0), - pre: match[4] ? match[4].split(".") : [], - }; -} - -/** @param {{major: number, minor: number, patch: number, pre: string[]}} v */ -export function format(v) { - return `${v.major}.${v.minor}.${v.patch}${v.pre.length ? `-${v.pre.join(".")}` : ""}`; -} - -/** - * The first version a dependency spec admits, for `^0.1.0-alpha.7`, - * `~0.1.0`, `0.1.0-alpha.19`, `>=0.1.0-alpha.0 <0.2.0`. Null for - * `workspace:*`, tags and URLs. - * - * @param {string} spec - */ -export function coerce(spec) { - const match = /(?:^|[\s^~>= b ? 1 : 0; -} - -/** - * Semver precedence. A version with a prerelease sorts before the same - * version without one. - * - * @param {string} a - * @param {string} b - */ -export function compare(a, b) { - const va = parse(a); - const vb = parse(b); - if (!va || !vb) throw new Error(`Not a version: ${!va ? a : b}`); - for (const key of /** @type {const} */ (["major", "minor", "patch"])) { - if (va[key] !== vb[key]) return va[key] - vb[key]; - } - if (va.pre.length === 0 || vb.pre.length === 0) { - return vb.pre.length - va.pre.length; - } - for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) { - const x = va.pre[i]; - const y = vb.pre[i]; - if (x === undefined) return -1; - if (y === undefined) return 1; - const c = compareIdentifiers(x, y); - if (c !== 0) return c; - } - return 0; -} - -/** - * The version after `version` for a release that carries a breaking change: - * the next prerelease number while in prerelease, else the next minor (pre-1.0 - * a breaking change bumps the minor). - * - * @param {string} version - */ -export function nextBreaking(version) { - const v = parse(version); - if (!v) throw new Error(`Not a version: ${version}`); - if (v.pre.length > 0) { - const last = v.pre[v.pre.length - 1]; - const pre = /^\d+$/.test(last) - ? [...v.pre.slice(0, -1), String(Number(last) + 1)] - : [...v.pre, "1"]; - return format({ ...v, pre }); - } - if (v.major === 0) return format({ major: 0, minor: v.minor + 1, patch: 0, pre: [] }); - return format({ major: v.major + 1, minor: 0, patch: 0, pre: [] }); -} diff --git a/codemods/0.2/package-json.mjs b/codemods/0.2/package-json.mjs deleted file mode 100644 index 9571c18..0000000 --- a/codemods/0.2/package-json.mjs +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 0.1 -> 0.2 package.json edits: - * - bump @lablup/ui-common to the target version (keeping `^`/`~`); - * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; - * - add @astryxdesign/lab, pinned to the canary ui-common is built against, - * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its - * core peer at ui-common's core with the project's package manager's - * override (../../cli/lab-peer.mjs). - */ -import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; -import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; - -const FIELDS = /** @type {const} */ ([ - "dependencies", - "devDependencies", - "peerDependencies", -]); - -/** @param {Record} object */ -function isSorted(object) { - const keys = Object.keys(object); - return keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0); -} - -/** - * @param {any} pkg - * @param {string} field - * @param {string} name - * @param {string} range - */ -function addDependency(pkg, field, name, range) { - const current = pkg[field] ?? {}; - const next = { ...current, [name]: range }; - pkg[field] = isSorted(current) - ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))) - : next; -} - -/** - * @param {string} spec - * @param {string} to - * @returns {{value: string, note?: string} | null} - */ -function bumpSpec(spec, to) { - if (/^(workspace:|link:|file:|npm:|git|https?:)/.test(spec)) return null; - const simple = /^([\^~]?)v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.exec(spec.trim()); - if (simple) return { value: `${simple[1]}${to}` }; - return { - value: `^${to}`, - note: `the range "${spec}" was replaced with "^${to}"; widen it again if this package must still accept 0.1.`, - }; -} - -/** - * Point the lab canary's core peer at ui-common's core: `overrides` in - * package.json for npm (the project's own, when it is the install root), - * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise. - * - * @param {any} pkg parsed package.json, edited in place - * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx - */ -function addLabOverride(pkg, ctx) { - const pin = ownPackageJson().dependencies?.[CORE]; - if (!pin || !ctx.projectDir || !ctx.editFile) return; - const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); - if (manager === "npm" && root !== ctx.projectDir) { - // npm reads overrides from the install root's package.json only. - const { note } = applyLabOverride({ manager: null, pin }); - ctx.note(`npm installs from ${root}, not this package: ${note}`); - return; - } - if (manager === "pnpm" && workspaceYaml) { - const yamlFile = workspaceYaml; - ctx.editFile(yamlFile, (current) => { - const edit = applyLabOverride({ manager, pin, workspaceYaml: current }); - if (edit.note) ctx.note(edit.note); - return edit.workspaceYaml; - }); - return; - } - const { note } = applyLabOverride({ manager, pin, pkg }); - ctx.note(note); -} - -/** - * @param {string} text package.json source - * @param {{to: string, flags: {packages: Map}, note: (message: string) => void}} ctx - */ -export function transformPackageJson(text, ctx) { - const pkg = JSON.parse(text); - const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; - const fields = FIELDS.filter((f) => pkg[f]?.[UIC] != null); - if (fields.length === 0) return undefined; - - for (const field of fields) { - const spec = pkg[field][UIC]; - const bumped = bumpSpec(spec, ctx.to); - if (!bumped) { - ctx.note(`${field}["${UIC}"] is "${spec}"; not a version, so it was left alone.`); - continue; - } - if (bumped.value !== spec) { - pkg[field][UIC] = bumped.value; - ctx.note(`${field}["${UIC}"]: "${spec}" → "${bumped.value}".`); - } - if (bumped.note) ctx.note(`${field}["${UIC}"]: ${bumped.note}`); - } - - const has = (/** @type {string} */ name) => - FIELDS.some((f) => pkg[f]?.[name] != null); - // A library that takes ui-common as a peer takes StyleX as a peer too; - // an application depends on it. - const library = fields.includes("peerDependencies"); - - /** - * Add `name` where this project needs it, and return the fields it went - * into. A library needs it as a peer and, when it develops against - * ui-common, as a devDependency: each is checked on its own, since one does - * not stand in for the other. An application needs it anywhere. - * - * @param {string} name - * @param {string} range - */ - const ensure = (name, range) => { - /** @type {string[]} */ - const wanted = library - ? [ - ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null - ? ["peerDependencies"] - : []), - ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null - ? ["devDependencies"] - : []), - ] - : has(name) - ? [] - : [fields.includes("dependencies") ? "dependencies" : fields[0]]; - for (const field of wanted) addDependency(pkg, field, name, range); - return wanted; - }; - - const stylex = stylexPeer(); - const stylexAdded = ensure(stylex.name, stylex.range); - if (stylexAdded.length > 0) - ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`); - - // Packages the map says a moved component needs (the lab Drawer). - for (const [lab, range] of ctx.flags.packages) { - const added = ensure(lab, range); - if (added.length === 0) continue; - const field = added.join(" and "); - ctx.note( - lab === LAB_PACKAGE - ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` - : `added ${lab} ${range} to ${field}.`, - ); - if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); - } - - const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; - return out === text ? undefined : out; -} diff --git a/docs/astryx.md b/docs/astryx.md index 8d5cf8e..de1d1ba 100644 --- a/docs/astryx.md +++ b/docs/astryx.md @@ -155,9 +155,12 @@ ui-common's own bin. ## The ui-common CLI -`bin/ui-common.mjs` wraps the pinned `@astryxdesign/cli`. It resolves that CLI -from ui-common's own install location, so a consumer needs no Astryx -dependency. +`@lablup/ui-common-cli` (`packages/cli`, released at ui-common's version and +taking it as a peer) wraps the pinned `@astryxdesign/cli`. It resolves that CLI +from its own install location, and the Astryx packages ui-common pins from the +project's `@lablup/ui-common`, so a consumer needs no Astryx dependency. Kept +apart from the library, the CLI toolchain stays out of production installs, as +`@astryxdesign/cli` stays apart from `@astryxdesign/core`. - **Passthrough.** Any Astryx command runs the pinned bin; its output is rewritten from `@astryxdesign/*` to `@lablup/ui-common/*` and from `astryx …` @@ -177,20 +180,20 @@ dependency. `agents` and the upstream codemods use Astryx CLI internals (`foundation/agent-docs`, `assets/codemods/registry.mjs`) that are not its -public API. The pin is exact, and `test/cli/` exercises both, so a bump that +public API. The pin is exact, and `packages/cli/test/cli/` exercises both, so a bump that moves them fails the tests. ## Checks -| Command | What it guards | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `src/exports.test.ts` | generated surface matches the installed Astryx | -| `pnpm run theme:check` | built Lablup theme matches its source | -| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | -| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | -| `src/migrationMap.test.ts` | `migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | -| `pnpm run check:pack` | every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored | -| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | -| `test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | -| `test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | -| CI `external-install` | the tarball installs, type-checks and builds in a clean project, with Astryx's sheets in the bundle | +| Command | What it guards | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/exports.test.ts` | generated surface matches the installed Astryx | +| `pnpm run theme:check` | built Lablup theme matches its source | +| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | +| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | +| `src/migrationMap.test.ts` | `packages/cli/migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | +| `pnpm run check:pack` | both tarballs: every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored; the library carries none of the CLI, the CLI none of `dist/`, at one version | +| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | +| `packages/cli/test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | +| `packages/cli/test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | +| CI `external-install` | both tarballs install with pnpm in a clean project, which type-checks and builds with Astryx's sheets in the bundle and runs the `ui-common` bin | diff --git a/eslint.config.js b/eslint.config.js index 20281ea..d32c0a5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ export default tseslint.config( // `astryx theme build` output, committed as generated. "src/theme/*/built/**", // Consumer code the upgrade codemods run on, and their expected output. - "test/upgrade/fixtures/**", + "packages/cli/test/upgrade/fixtures/**", // Agent worktrees: other checkouts of this repository, with their own dist. ".claude/**", ], @@ -77,9 +77,7 @@ export default tseslint.config( { files: [ "scripts/**/*.mjs", - "bin/**/*.mjs", - "cli/**/*.mjs", - "codemods/**/*.mjs", + "packages/cli/{bin,cli,codemods,scripts}/**/*.mjs", "*.config.{js,ts}", ], languageOptions: { globals: globals.node }, diff --git a/fixture/pnpm-workspace.yaml b/fixture/pnpm-workspace.yaml new file mode 100644 index 0000000..1f8eed6 --- /dev/null +++ b/fixture/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +# The fixture is a consumer, so it is its own pnpm workspace rather than a +# package of this repository's, and installs under a consumer's settings. +# +# This is the block a pnpm 11 consumer of @lablup/ui-common needs: Astryx's +# packages have a postinstall that only prints an `astryx init` nudge, and +# pnpm refuses to install (ERR_PNPM_IGNORED_BUILDS) until each one is either +# allowed or declined. Nothing needs to run, so both are declined. +allowBuilds: + # A dependency of @lablup/ui-common. + "@astryxdesign/core": false + # A dependency of @lablup/ui-common-cli (the ui-common bin). + "@astryxdesign/cli": false diff --git a/package.json b/package.json index 91c390b..6ec63c1 100644 --- a/package.json +++ b/package.json @@ -24,18 +24,11 @@ "engines": { "node": ">=20" }, - "bin": { - "ui-common": "./bin/ui-common.mjs" - }, "packageManager": "pnpm@11.19.0", "files": [ "dist", "astryx", "astryx.integration.mjs", - "bin", - "cli", - "codemods", - "migration", "exports.exclude.json", "exports.customs.json", "NOTICE" @@ -589,7 +582,6 @@ "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack && pnpm run check:integration" }, "dependencies": { - "@astryxdesign/cli": "0.6.2", "@astryxdesign/core": "0.6.2", "@astryxdesign/theme-neutral": "0.6.2", "@dnd-kit/core": "6.3.1", @@ -597,10 +589,7 @@ "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "intl-messageformat": "^11.2.9", - "jscodeshift": "^17.4.0", - "lucide-react": "^1.18.0", - "postcss": "^8.5.25", - "postcss-selector-parser": "^7.1.6" + "lucide-react": "^1.18.0" }, "peerDependencies": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", @@ -614,6 +603,7 @@ } }, "devDependencies": { + "@astryxdesign/cli": "0.6.2", "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", "@eslint/js": "^9.39.0", "@stylexjs/stylex": "0.19.0", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/cli/NOTICE b/packages/cli/NOTICE new file mode 100644 index 0000000..95c0116 --- /dev/null +++ b/packages/cli/NOTICE @@ -0,0 +1,8 @@ +@lablup/ui-common-cli +Copyright 2026 Lablup Inc. + +This product includes software developed at Lablup Inc. +(https://www.lablup.com/). + +It runs the Astryx CLI (@astryxdesign/cli, MIT-licensed by Meta Platforms, +Inc.), which it depends on and does not vendor. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..990efb4 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,28 @@ +# @lablup/ui-common-cli + +The `ui-common` command line for [`@lablup/ui-common`](https://www.npmjs.com/package/@lablup/ui-common): +the Astryx CLI that ui-common pins, with its output in `@lablup/ui-common` +terms, the agent block, and the upgrade codemods. + +It is a separate package so the CLI's toolchain (the Astryx CLI, jscodeshift, +postcss) stays out of a consumer's production install. It is released at the +same version as `@lablup/ui-common` and takes it as a peer. + +``` +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 # one-off (npx @lablup/ui-common-cli@next …) + +pnpm add -D @lablup/ui-common-cli@ # or keep it next to ui-common +pnpm exec ui-common --help +``` + +While 0.2 is in prerelease, name the `next` dist-tag (or an exact version): +only prereleases are published, and npm points `latest` at a package's first +publish, so a bare `@lablup/ui-common-cli` resolves to its first alpha. Plain +`@lablup/ui-common-cli` works once 0.2.0 is published. + +Documentation: [The ui-common CLI](https://github.com/lablup/ui-common#the-ui-common-cli) +in the repository README. + +## License + +[Apache-2.0](LICENSE). See [NOTICE](NOTICE). diff --git a/bin/ui-common.mjs b/packages/cli/bin/ui-common.mjs similarity index 100% rename from bin/ui-common.mjs rename to packages/cli/bin/ui-common.mjs diff --git a/cli/agents.mjs b/packages/cli/cli/agents.mjs similarity index 95% rename from cli/agents.mjs rename to packages/cli/cli/agents.mjs index 8986d47..49e58d1 100644 --- a/cli/agents.mjs +++ b/packages/cli/cli/agents.mjs @@ -19,7 +19,7 @@ import { forkedExports, hiddenExports, importAstryxInternal, - ownPackageJson, + uiCommonPackageJson, } from "./paths.mjs"; import { rewriteOutput } from "./rewrite.mjs"; @@ -99,7 +99,8 @@ function uiCommonSection({ version, astryxVersion, invocation }) { ); } lines.push( - `- After bumping @lablup/ui-common: \`${invocation} upgrade --from \`, then read ui-common-upgrade-report.md.`, + `- The \`ui-common\` bin is @lablup/ui-common-cli, a devDependency pinned to the same version as @lablup/ui-common; bump both together. Without it installed, \`pnpm dlx @lablup/ui-common-cli@next \` (or \`npx @lablup/ui-common-cli@next \`); drop \`@next\` once 0.2.0 is published.`, + `- After bumping @lablup/ui-common and @lablup/ui-common-cli: \`${invocation} upgrade --from \`, then read ui-common-upgrade-report.md.`, ); return lines; } @@ -178,7 +179,7 @@ export async function generateBlock(cwd) { const projectDir = findProjectDir(cwd) ?? cwd; const astryxBlock = await renderAstryxBlock(projectDir); return transformBlock(astryxBlock, { - version: ownPackageJson().version, + version: uiCommonPackageJson().version, astryxVersion: dependencyVersion("@astryxdesign/core") ?? "unknown", invocation: binInvocation(projectDir), componentCount: await coreComponentCount(), diff --git a/cli/diff.mjs b/packages/cli/cli/diff.mjs similarity index 100% rename from cli/diff.mjs rename to packages/cli/cli/diff.mjs diff --git a/cli/lab-peer.mjs b/packages/cli/cli/lab-peer.mjs similarity index 100% rename from cli/lab-peer.mjs rename to packages/cli/cli/lab-peer.mjs diff --git a/cli/main.mjs b/packages/cli/cli/main.mjs similarity index 66% rename from cli/main.mjs rename to packages/cli/cli/main.mjs index 1c3adef..8d050a0 100644 --- a/cli/main.mjs +++ b/packages/cli/cli/main.mjs @@ -4,15 +4,26 @@ */ import { AGENTS_HELP, agentsCommand } from "./agents.mjs"; import { passthrough } from "./passthrough.mjs"; -import { dependencyVersion, ownPackageJson } from "./paths.mjs"; +import { + cliDependencyVersion, + cliPackageJson, + uiCommonPackageJson, + uiCommonRoot, +} from "./paths.mjs"; import { ASTRYX_COMMANDS } from "./rewrite.mjs"; import { SYNC_HELP, syncAstryxCommand } from "./sync-astryx.mjs"; import { UPGRADE_HELP, upgradeCommand } from "./upgrade.mjs"; export const HELP = `Usage: ui-common [options] -@lablup/ui-common's CLI. Astryx commands run the Astryx CLI that ui-common -pins, with its output rewritten to ui-common import paths. +@lablup/ui-common's CLI (@lablup/ui-common-cli). Astryx commands run the +Astryx CLI it pins, with its output rewritten to ui-common import paths. + +One-off, before ui-common is installed or bumped (\`@next\` until 0.2.0 is +published; npm's \`latest\` for this package is its first alpha): + pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 (npx: npx @lablup/ui-common-cli@next …) +Installed as a devDependency next to @lablup/ui-common: + pnpm exec ui-common ui-common commands: agents [--write ] [--check] @@ -32,7 +43,9 @@ Astryx commands (rewritten to @lablup/ui-common paths): Options: -h, --help Show this help (\`ui-common --help\` for one command) - -V, --version Print the @lablup/ui-common version + -V, --version Print the @lablup/ui-common-cli version (the same as the + @lablup/ui-common it ships with); --verbose adds the + project's ui-common and the Astryx CLI Exit codes: a passed-through command exits with the Astryx CLI's code. ui-common commands exit 0 on success, 1 on a failed check or run, 2 on bad arguments. @@ -54,10 +67,17 @@ export async function main(argv) { return 0; case "-V": case "--version": - process.stdout.write(`${ownPackageJson().version}\n`); + process.stdout.write(`${cliPackageJson().version}\n`); if (rest.includes("--verbose")) { + let uiCommon = "not installed"; + try { + const root = uiCommonRoot(); + uiCommon = `${uiCommonPackageJson(root).version} (${root})`; + } catch { + // reported as not installed + } process.stdout.write( - `astryx ${dependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, + `@lablup/ui-common ${uiCommon}\nastryx ${cliDependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, ); } return 0; diff --git a/cli/passthrough.mjs b/packages/cli/cli/passthrough.mjs similarity index 100% rename from cli/passthrough.mjs rename to packages/cli/cli/passthrough.mjs diff --git a/packages/cli/cli/paths.mjs b/packages/cli/cli/paths.mjs new file mode 100644 index 0000000..a787f62 --- /dev/null +++ b/packages/cli/cli/paths.mjs @@ -0,0 +1,290 @@ +/** + * Where things live. Two packages are involved, and they are found apart: + * + * - The CLI's own package, @lablup/ui-common-cli: the Astryx CLI it pins, + * jscodeshift, the codemods and the migration map. + * - The @lablup/ui-common package the CLI works for: its version, its export + * lists (exports.exclude.json, exports.customs.json) and the Astryx packages + * it pins (core, theme-neutral, lab). A consumer depends on ui-common, so + * under pnpm's isolated layout those are reachable from ui-common's install + * location and not from the consumer's own directory. + * + * Nothing is resolved from the working directory except the project's own + * @lablup/ui-common. + */ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const UI_COMMON = "@lablup/ui-common"; + +/** Root of the installed @lablup/ui-common-cli package. */ +export const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** @param {string} file */ +export function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +/** @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record}} */ +export function cliPackageJson() { + return readJson(join(CLI_ROOT, "package.json")); +} + +/** + * The @lablup/ui-common package root `fromDir` resolves, or null. Its + * `exports` map lists `./package.json`, so this works from a consumer, from + * the CLI package (its peer) and, by self-reference, from this repository. + * + * @param {string} fromDir + */ +function resolveUiCommonFrom(fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return dirname(req.resolve(`${UI_COMMON}/package.json`)); + } catch { + return null; + } +} + +/** + * Root of the @lablup/ui-common a command works on: the project's own install + * first, so the CLI reads the ui-common the project actually has, then the one + * beside the CLI (its peer, which a `pnpm dlx` run installs). + * + * @param {string} [cwd] + */ +export function uiCommonRoot(cwd = process.cwd()) { + const project = findProjectDir(cwd); + const dir = + (project && resolveUiCommonFrom(project)) ?? resolveUiCommonFrom(CLI_ROOT); + if (!dir) { + throw new Error( + `${UI_COMMON} is not installed. Add it to the project (the CLI is @lablup/ui-common-cli, a devDependency next to it).`, + ); + } + return dir; +} + +/** + * Root of the @lablup/ui-common this CLI release ships with: its peer, at the + * same version. `upgrade` reads its target's pins from here, since the project + * may still be on the version it is upgrading from. Falls back to the + * project's when the peer is not reachable from the CLI. + * + * @param {string} [cwd] + */ +export function targetUiCommonRoot(cwd = process.cwd()) { + return resolveUiCommonFrom(CLI_ROOT) ?? uiCommonRoot(cwd); +} + +/** + * package.json of the @lablup/ui-common a command works on (see uiCommonRoot). + * + * @param {string} [root] + * @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record, devDependencies?: Record}} + */ +export function uiCommonPackageJson(root = uiCommonRoot()) { + return readJson(join(root, "package.json")); +} + +/** Walk up from a resolved file to the directory holding its package.json. */ +function packageDirOf(file, name) { + let dir = dirname(file); + for (;;) { + const pkg = join(dir, "package.json"); + if (existsSync(pkg)) { + try { + if (readJson(pkg).name === name) return dir; + } catch { + // keep walking + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * The directory of a package as `fromDir` resolves it, or null. Resolution + * goes through the package's main entry, because not every Astryx package + * exports its package.json. + * + * @param {string} name + * @param {string} fromDir + */ +function packageDirFrom(name, fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return packageDirOf(req.resolve(name), name); + } catch { + return null; + } +} + +/** + * The directory of a dependency of @lablup/ui-common (an Astryx package it + * pins), or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyDir(name, root) { + let from = root; + if (!from) { + try { + from = uiCommonRoot(); + } catch { + return null; + } + } + return packageDirFrom(name, from); +} + +/** + * The directory of a dependency of the CLI package itself, or null. + * + * @param {string} name + */ +export function cliDependencyDir(name) { + return packageDirFrom(name, CLI_ROOT); +} + +/** Root of the exact-pinned @astryxdesign/cli. */ +export function astryxCliRoot() { + const dir = cliDependencyDir("@astryxdesign/cli"); + if (!dir) { + throw new Error( + "@astryxdesign/cli is not installed next to @lablup/ui-common-cli. Reinstall @lablup/ui-common-cli.", + ); + } + return dir; +} + +/** The `astryx` bin script of the pinned CLI. */ +export function astryxBin() { + const root = astryxCliRoot(); + const bin = readJson(join(root, "package.json")).bin?.astryx; + return join(root, bin ?? "clients/cli/bin/astryx.mjs"); +} + +/** + * Import a module file from inside the pinned Astryx CLI. These are internals, + * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the + * tests that use them on every bump. + * + * @param {string} relative path under the CLI root + */ +export async function importAstryxInternal(relative) { + const file = join(astryxCliRoot(), relative); + return import(pathToFileURL(file).href); +} + +/** + * Version of an installed dependency of ui-common, or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyVersion(name, root) { + const dir = dependencyDir(name, root); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** + * Version of an installed dependency of the CLI package, or null. + * + * @param {string} name + */ +export function cliDependencyVersion(name) { + const dir = cliDependencyDir(name); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ +export function excludedExports(root = uiCommonRoot()) { + const file = join(root, "exports.exclude.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( + readJson(file) + ); +} + +/** `exports.customs.json`: ui-common's own exports. */ +export function customExports(root = uiCommonRoot()) { + const file = join(root, "exports.customs.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( + readJson(file) + ); +} + +/** + * ui-common's own copies of Astryx components (src/forks/): same names and + * import paths as Astryx's, which exports.exclude.json hides. `names` are the + * names a reader may meet it by; `from` is where a consumer imports it. + * + * @returns {Array<{name: string, from: string, names: string[], reason: string}>} + */ +export function forkedExports() { + const exclusions = excludedExports(); + return customExports() + .filter((c) => c.fork !== undefined) + .map((c) => { + const exclusion = exclusions.find((e) => e.replacedBy === c.name); + return { + name: c.name, + from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, + names: exclusion?.exports ?? [c.name], + reason: exclusion?.reason ?? "", + }; + }); +} + +/** + * The exclusions that hide an Astryx name behind a different one. A fork's + * exclusion is not one: the name stays, now ui-common's. + */ +export function hiddenExports() { + const forks = new Set(forkedExports().map((f) => f.name)); + return excludedExports().filter( + (e) => e.replacedBy === null || !forks.has(e.replacedBy), + ); +} + +/** + * The nearest directory at or above `start` holding a package.json. + * + * @param {string} start + */ +export function findProjectDir(start) { + let dir = resolve(start); + for (;;) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * How this project runs a locally installed bin, from its lockfile. + * + * @param {string} projectDir + */ +export function binInvocation(projectDir, bin = "ui-common") { + let dir = resolve(projectDir); + for (;;) { + if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; + if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; + if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { + return `bunx ${bin}`; + } + if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; + const parent = dirname(dir); + if (parent === dir) return `npx ${bin}`; + dir = parent; + } +} diff --git a/cli/report.mjs b/packages/cli/cli/report.mjs similarity index 85% rename from cli/report.mjs rename to packages/cli/cli/report.mjs index b767475..fe0974b 100644 --- a/cli/report.mjs +++ b/packages/cli/cli/report.mjs @@ -27,6 +27,8 @@ function escapeCell(text) { * @param {boolean} data.dryRun * @param {string[]} data.roots * @param {number} data.fileCount + * @param {string[]} [data.scanRoots] where the manual-review scan looked + * @param {number} [data.scanCount] * @param {Array<{version: string, title: string, notes: string[]}>} data.steps * @param {Array<{file: string, created: boolean, transforms: string[], added: number, removed: number}>} data.changed * @param {{changed: boolean, notes: string[]}} data.packageJson @@ -35,19 +37,29 @@ function escapeCell(text) { * @param {Record} data.categories * @param {Array<{file: string, transform: string, error: string}>} data.errors * @param {string[]} [data.notices] where the run did something other than the usual + * @param {string[]} [data.alerts] what has to be done before the app works; first in the report * @param {number} data.tokenReads */ export function renderReport(data) { const out = []; out.push(REPORT_HEADING, ""); out.push( - `\`ui-common upgrade\` ${data.from} → ${data.to} (installed @lablup/ui-common ${data.version})` + + `\`ui-common upgrade\` ${data.from} → ${data.to} (@lablup/ui-common-cli ${data.version})` + `${data.dryRun ? ", **dry run: nothing was written**" : ""}.`, "", - `Scanned ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}.`, + `Ran the codemods over ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}` + + (data.scanRoots + ? `; searched ${data.scanCount ?? 0} file${data.scanCount === 1 ? "" : "s"} under ${data.scanRoots.map((r) => (r === "." ? "the project root" : code(r))).join(", ")} for manual-review findings.` + : "."), "", ); + if (data.alerts && data.alerts.length > 0) { + out.push("## Action required", ""); + for (const alert of data.alerts) out.push(`- ${alert}`); + out.push(""); + } + const manual = data.findings.length; out.push("## Summary", ""); out.push("| | Count |", "|---|---:|"); diff --git a/packages/cli/cli/resolve.mjs b/packages/cli/cli/resolve.mjs new file mode 100644 index 0000000..813d80e --- /dev/null +++ b/packages/cli/cli/resolve.mjs @@ -0,0 +1,257 @@ +/** + * Resolve a project's own import specifiers to files, the way its bundler + * would for the common cases: relative paths, and the `paths` / `baseUrl` + * aliases its tsconfig declares (`@/components/common`). A specifier that + * resolves neither way and is not an installed package is recorded as an + * unresolved alias (a Vite `resolve.alias`, say), for the report. + * + * Only what `ui-common upgrade` needs: file-to-file resolution inside the + * project. Packages are never resolved. + */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { dirname, join, resolve } from "node:path"; + +const SCRIPT_EXTENSIONS = [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".mts", + ".mjs", + ".cts", + ".cjs", +]; + +/** + * JSON with comments and trailing commas, as tsconfig allows. + * + * @param {string} text + */ +export function parseJsonc(text) { + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + const start = i; + for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++; + out += text.slice(start, i + 1); + } else if (ch === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + out += "\n"; + } else if (ch === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + i = end === -1 ? text.length : end + 1; + } else out += ch; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +/** @param {string} path */ +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** + * The file an import of `base` (no extension, or a `.js` one standing for a + * `.ts` source) loads: `base` itself, `base.`, or `base/index.`. + * + * @param {string} base absolute + */ +export function resolveFile(base) { + if (isFile(base) && SCRIPT_EXTENSIONS.some((e) => base.endsWith(e))) return base; + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${base}${ext}`)) return `${base}${ext}`; + const js = /\.(m|c)?jsx?$/.exec(base); + if (js) { + const stem = base.slice(0, js.index); + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${stem}${ext}`)) return `${stem}${ext}`; + } + for (const ext of SCRIPT_EXTENSIONS) { + const index = join(base, `index${ext}`); + if (isFile(index)) return index; + } + return null; +} + +/** + * `compilerOptions` of a tsconfig, merged over what it extends (relative + * `extends` only), with `paths` and `baseUrl` made absolute. + * + * @param {string} file + * @param {Set} [seen] + * @returns {{baseUrl?: string, paths?: Record, pathsBase?: string}} + */ +function readTsconfig(file, seen = new Set()) { + if (seen.has(file) || !isFile(file)) return {}; + seen.add(file); + let json; + try { + json = parseJsonc(readFileSync(file, "utf8")); + } catch { + return {}; + } + const dir = dirname(file); + /** @type {{baseUrl?: string, paths?: Record, pathsBase?: string}} */ + let merged = {}; + const parents = Array.isArray(json.extends) ? json.extends : [json.extends]; + for (const parent of parents) { + if (typeof parent !== "string" || !parent.startsWith(".")) continue; + const target = resolve(dir, parent); + merged = { + ...merged, + ...readTsconfig(target.endsWith(".json") ? target : `${target}.json`, seen), + }; + } + const options = json.compilerOptions ?? {}; + if (typeof options.baseUrl === "string") + merged.baseUrl = resolve(dir, options.baseUrl); + if (options.paths && typeof options.paths === "object") { + merged.paths = options.paths; + merged.pathsBase = merged.baseUrl ?? dir; + } else if (merged.paths && typeof options.baseUrl === "string") { + merged.pathsBase = merged.baseUrl; + } + return merged; +} + +/** + * Every tsconfig of the project that may declare aliases: `tsconfig.json`, + * the configs it references, and the usual Vite split (`tsconfig.app.json`). + * + * @param {string} projectDir + */ +function projectTsconfigs(projectDir) { + const root = join(projectDir, "tsconfig.json"); + const files = [root, join(projectDir, "tsconfig.app.json")]; + try { + const json = parseJsonc(readFileSync(root, "utf8")); + for (const ref of json.references ?? []) { + if (typeof ref?.path !== "string") continue; + const target = resolve(projectDir, ref.path); + files.push(target.endsWith(".json") ? target : join(target, "tsconfig.json")); + } + } catch { + // No tsconfig, or one this cannot read: relative imports only. + } + return [...new Set(files)].filter(isFile); +} + +/** @param {string} specifier */ +function packageName(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +const BUILTINS = new Set(builtinModules); + +/** + * @param {string} projectDir + */ +export function createResolver(projectDir) { + const configs = projectTsconfigs(projectDir).map((f) => readTsconfig(f)); + /** @type {Array<{prefix: string, suffix: string, wildcard: boolean, targets: string[]}>} */ + const aliases = []; + /** @type {string[]} */ + const baseUrls = []; + for (const config of configs) { + if (config.baseUrl) baseUrls.push(config.baseUrl); + if (!config.paths || !config.pathsBase) continue; + for (const [pattern, targets] of Object.entries(config.paths)) { + if (!Array.isArray(targets)) continue; + const star = pattern.indexOf("*"); + aliases.push({ + prefix: star === -1 ? pattern : pattern.slice(0, star), + suffix: star === -1 ? "" : pattern.slice(star + 1), + wildcard: star !== -1, + targets: targets.map((t) => + resolve(/** @type {string} */ (config.pathsBase), t), + ), + }); + } + } + // Longest prefix first, as TypeScript matches. + aliases.sort((a, b) => b.prefix.length - a.prefix.length); + + /** @type {Map} */ + const installed = new Map(); + /** @param {string} name */ + const isInstalled = (name) => { + let hit = installed.get(name); + if (hit !== undefined) return hit; + hit = false; + for (let dir = projectDir; ;) { + if (existsSync(join(dir, "node_modules", name))) { + hit = true; + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + installed.set(name, hit); + return hit; + }; + + /** Alias-like specifiers nothing resolved, by their first segment. @type {Map} */ + const unresolved = new Map(); + + /** + * @param {string} fromFile absolute + * @param {string} specifier + * @param {{probe?: boolean}} [options] probe: a lookup that records no miss + * @returns {string | null} + */ + function resolveImport(fromFile, specifier, { probe = false } = {}) { + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return resolveFile(resolve(dirname(fromFile), specifier)); + } + if (specifier.includes(":") || BUILTINS.has(packageName(specifier))) return null; + let aliased = false; + for (const alias of aliases) { + let rest; + if (alias.wildcard) { + if (!specifier.startsWith(alias.prefix) || !specifier.endsWith(alias.suffix)) + continue; + rest = specifier.slice( + alias.prefix.length, + specifier.length - alias.suffix.length, + ); + } else if (specifier !== alias.prefix) continue; + aliased = true; + for (const target of alias.targets) { + const hit = resolveFile( + rest === undefined ? target : target.replace("*", rest), + ); + if (hit) return hit; + } + } + // A tsconfig alias to a stylesheet or JSON file: resolved, just not a script. + if (aliased) return null; + const name = packageName(specifier); + if (isInstalled(name)) return null; + for (const base of baseUrls) { + const hit = resolveFile(join(base, specifier)); + if (hit) return hit; + } + if (probe) return null; + // Only what looks like an alias is worth reporting: a bare name that is + // not installed is as likely a package this checkout has not installed. + const first = specifier.split("/")[0]; + if ( + (/^[@~#$]/.test(specifier) && !/^@[\w-]/.test(specifier)) || + existsSync(join(projectDir, first)) + ) { + const key = `${first}/`; + unresolved.set(key, (unresolved.get(key) ?? 0) + 1); + } + return null; + } + + return { resolveImport, unresolved }; +} diff --git a/cli/rewrite.mjs b/packages/cli/cli/rewrite.mjs similarity index 100% rename from cli/rewrite.mjs rename to packages/cli/cli/rewrite.mjs diff --git a/packages/cli/cli/semver.mjs b/packages/cli/cli/semver.mjs new file mode 100644 index 0000000..94dad9c --- /dev/null +++ b/packages/cli/cli/semver.mjs @@ -0,0 +1,278 @@ +/** + * The little semver the upgrade registry needs: parse, compare with + * prerelease precedence, and coerce the loose forms a person types + * (`0.1`, `v0.1.0-alpha.19`) or a package.json holds (`^0.1.0-alpha.7`). + */ + +const SEMVER = + /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +/** + * @param {string} input + * @returns {{major: number, minor: number, patch: number, pre: string[]} | null} + */ +export function parse(input) { + const match = SEMVER.exec(String(input).trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + pre: match[4] ? match[4].split(".") : [], + }; +} + +/** @param {{major: number, minor: number, patch: number, pre: string[]}} v */ +export function format(v) { + return `${v.major}.${v.minor}.${v.patch}${v.pre.length ? `-${v.pre.join(".")}` : ""}`; +} + +/** + * The first version a dependency spec admits, for `^0.1.0-alpha.7`, + * `~0.1.0`, `0.1.0-alpha.19`, `>=0.1.0-alpha.0 <0.2.0`. Null for + * `workspace:*`, tags and URLs. + * + * @param {string} spec + */ +export function coerce(spec) { + const match = /(?:^|[\s^~>= b ? 1 : 0; +} + +/** + * Semver precedence. A version with a prerelease sorts before the same + * version without one. + * + * @param {string} a + * @param {string} b + */ +export function compare(a, b) { + const va = parse(a); + const vb = parse(b); + if (!va || !vb) throw new Error(`Not a version: ${!va ? a : b}`); + for (const key of /** @type {const} */ (["major", "minor", "patch"])) { + if (va[key] !== vb[key]) return va[key] - vb[key]; + } + if (va.pre.length === 0 || vb.pre.length === 0) { + return vb.pre.length - va.pre.length; + } + for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) { + const x = va.pre[i]; + const y = vb.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const c = compareIdentifiers(x, y); + if (c !== 0) return c; + } + return 0; +} + +/** + * The version after `version` for a release that carries a breaking change: + * the next prerelease number while in prerelease, else the next minor (pre-1.0 + * a breaking change bumps the minor). + * + * @param {string} version + */ +export function nextBreaking(version) { + const v = parse(version); + if (!v) throw new Error(`Not a version: ${version}`); + if (v.pre.length > 0) { + const last = v.pre[v.pre.length - 1]; + const pre = /^\d+$/.test(last) + ? [...v.pre.slice(0, -1), String(Number(last) + 1)] + : [...v.pre, "1"]; + return format({ ...v, pre }); + } + if (v.major === 0) return format({ major: 0, minor: v.minor + 1, patch: 0, pre: [] }); + return format({ major: v.major + 1, minor: 0, patch: 0, pre: [] }); +} + +/** + * @typedef {{version: string, inclusive: boolean, text?: string}} Bound + * `text` is the comparator as written, kept when the bound survives. + */ + +/** + * A partial version (`19`, `19.x`, `19.2.*`) as its first admitted version + * and the version its x-range stops before. Null for anything else. + * + * @param {string} input + */ +function partial(input) { + const m = + /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + input, + ); + if (!m) return null; + const wild = (/** @type {string | undefined} */ s) => s == null || /^[xX*]$/.test(s); + if (wild(m[1])) return { min: null, end: null, full: false, parts: 0 }; + const major = Number(m[1]); + if (wild(m[2])) + return { min: `${major}.0.0`, end: `${major + 1}.0.0`, full: false, parts: 1 }; + const minor = Number(m[2]); + if (wild(m[3])) + return { + min: `${major}.${minor}.0`, + end: `${major}.${minor + 1}.0`, + full: false, + parts: 2, + }; + const version = `${major}.${minor}.${Number(m[3])}${m[4] ? `-${m[4]}` : ""}`; + return { min: version, end: null, full: true, parts: 3 }; +} + +/** + * One `||` alternative as a lower and an upper bound; null when it is not a + * range this reads (a tag, a URL). + * + * @param {string} alternative + * @returns {{lower: Bound | null, upper: Bound | null, hyphen?: string} | null} + */ +function bounds(alternative) { + const text = alternative.trim(); + if (text === "") return { lower: null, upper: null }; + const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text); + if (hyphen) { + const from = partial(hyphen[1]); + const to = partial(hyphen[2]); + if (!from || !to) return null; + return { + lower: from.min ? { version: from.min, inclusive: true } : null, + upper: to.full + ? { version: /** @type {string} */ (to.min), inclusive: true } + : to.end + ? { version: to.end, inclusive: false } + : null, + hyphen: hyphen[2], + }; + } + /** @type {Bound | null} */ + let lower = null; + /** @type {Bound | null} */ + let upper = null; + /** @param {Bound} b */ + const raise = (b) => { + if (!lower || compare(b.version, lower.version) > 0) lower = b; + }; + /** @param {Bound} b */ + const cap = (b) => { + const c = upper ? compare(b.version, upper.version) : -1; + if (c < 0 || (c === 0 && !b.inclusive)) upper = b; + }; + for (const token of text.replace(/(<=|>=|[<>=^~])\s+/g, "$1").split(/\s+/)) { + const m = /^(<=|>=|<|>|=|\^|~)?(.*)$/.exec(token); + const op = m?.[1] ?? ""; + const v = partial(m?.[2] ?? ""); + if (!v) return null; + if (!v.min) { + // `*`, `x`, `>=*`: no bound; `<*` admits nothing. + if (op === "<" || op === ">") + return { lower: null, upper: { version: "0.0.0", inclusive: false } }; + continue; + } + const at = /** @type {string} */ (v.min); + if (op === "^" || op === "~") { + const p = /** @type {{major: number, minor: number, patch: number}} */ ( + parse(at) + ); + raise({ version: at, inclusive: true }); + const end = + op === "~" + ? v.parts >= 2 + ? `${p.major}.${p.minor + 1}.0` + : `${p.major + 1}.0.0` + : p.major > 0 || v.parts === 1 + ? `${p.major + 1}.0.0` + : p.minor > 0 || v.parts === 2 + ? `0.${p.minor + 1}.0` + : `0.0.${p.patch + 1}`; + cap({ version: end, inclusive: false }); + } else if (op === ">=") raise({ version: at, inclusive: true, text: token }); + else if (op === ">") + raise( + v.full + ? { version: at, inclusive: false, text: token } + : { version: /** @type {string} */ (v.end), inclusive: true, text: token }, + ); + else if (op === "<") cap({ version: at, inclusive: false, text: token }); + else if (op === "<=") + cap( + v.full + ? { version: at, inclusive: true, text: token } + : { version: /** @type {string} */ (v.end), inclusive: false, text: token }, + ); + else { + raise({ version: at, inclusive: true }); + cap( + v.full + ? { version: at, inclusive: true } + : { version: /** @type {string} */ (v.end), inclusive: false }, + ); + } + } + return { lower, upper }; +} + +/** + * `range` narrowed to the versions at or above `floor`, alternative by + * alternative: `>=18 <21 || ^22` at 19.2.0 is `>=19.2.0 <21 || ^22`. An + * alternative already above the floor keeps its text; one wholly below it is + * dropped. Null when nothing is left; `range` itself when it is not a range + * this reads. + * + * @param {string} range + * @param {string} floor a full version + * @returns {string | null} + */ +export function narrowToFloor(range, floor) { + const f = parse(floor); + if (!f) throw new Error(`Not a version: ${floor}`); + const alternatives = range.split("||").map((a) => a.trim()); + const parsed = alternatives.map(bounds); + if (parsed.some((b) => b == null)) return range; + /** @type {string[]} */ + const kept = []; + for (const [i, b] of /** @type {NonNullable>[]} */ ( + parsed + ).entries()) { + const { lower, upper } = b; + if (lower && compare(lower.version, floor) >= 0) { + kept.push(alternatives[i]); + continue; + } + if (upper) { + const c = compare(upper.version, floor); + if (c < 0 || (c === 0 && !upper.inclusive)) continue; + } + let next; + if (!upper) next = `>=${floor}`; + else if (b.hyphen != null) next = `${floor} - ${b.hyphen}`; + else if (!upper.inclusive && upper.version === `${f.major + 1}.0.0` && f.major > 0) + next = `^${floor}`; + else if (upper.inclusive && upper.version === floor) next = floor; + else + next = `>=${floor} ${upper.text ?? `${upper.inclusive ? "<=" : "<"}${upper.version}`}`; + kept.push(next); + } + const unique = [...new Set(kept)]; + if (unique.length === 0) return null; + return unique.length === alternatives.length && + unique.every((a, i) => a === alternatives[i]) + ? range + : unique.join(" || "); +} diff --git a/cli/shadow.mjs b/packages/cli/cli/shadow.mjs similarity index 100% rename from cli/shadow.mjs rename to packages/cli/cli/shadow.mjs diff --git a/cli/sync-astryx.mjs b/packages/cli/cli/sync-astryx.mjs similarity index 81% rename from cli/sync-astryx.mjs rename to packages/cli/cli/sync-astryx.mjs index 9c2bf7e..fc84f6c 100644 --- a/cli/sync-astryx.mjs +++ b/packages/cli/cli/sync-astryx.mjs @@ -2,13 +2,14 @@ * `ui-common sync-astryx `: the maintainer's Astryx bump, inside the * ui-common repository. * - * 1. Move the exact pins (core, theme-neutral, cli; lab with --lab), and the + * 1. Move the exact pins (core, theme-neutral and the cli devDependency in the + * root package.json, the cli in packages/cli's; lab with --lab), and the * core version README and pnpm-workspace.yaml override lab's core peer to. * 2. Install, regenerate the export mirror and the built Lablup theme. * 3. Run Astryx's codemods on ui-common's own src/ (dry run, then applied). * 4. Run the tests, which include the export and theme drift checks. * 5. Record the Astryx codemods consumers need for this bump under the next - * ui-common version: codemods//upstream.json. `ui-common upgrade` + * ui-common version: packages/cli/codemods//upstream.json. `ui-common upgrade` * runs them for consumers, with the import specifiers swapped. * * `--dry-run` changes nothing: it prints the plan, runs Astryx's codemods on @@ -21,17 +22,22 @@ */ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { syncLabOverrideDocs } from "./lab-peer.mjs"; import { findProjectDir, readJson } from "./paths.mjs"; import { compare, nextBreaking, parse } from "./semver.mjs"; -const PINNED = [ - "@astryxdesign/core", - "@astryxdesign/theme-neutral", - "@astryxdesign/cli", -]; +/** Exact pins in the root package.json (@lablup/ui-common), by field. */ +const PINNED = /** @type {const} */ ([ + ["dependencies", "@astryxdesign/core"], + ["dependencies", "@astryxdesign/theme-neutral"], + // The root's own scripts (theme:build, check:integration) run it. + ["devDependencies", "@astryxdesign/cli"], +]); +/** The CLI package, which carries the Astryx CLI as a dependency. */ +export const CLI_PACKAGE_DIR = join("packages", "cli"); +const CLI_PINNED = /** @type {const} */ ([["dependencies", "@astryxdesign/cli"]]); const LAB = "@astryxdesign/lab"; export const SYNC_HELP = `Usage: ui-common sync-astryx [--lab ] [--as ] [--dry-run] @@ -39,7 +45,9 @@ export const SYNC_HELP = `Usage: ui-common sync-astryx [--lab , regenerates the export mirror and the Lablup theme, runs Astryx's codemods on src/, runs the tests, and records the Astryx codemods -consumers need under the next ui-common version (codemods//upstream.json). +consumers need under the next ui-common version +(packages/cli/codemods//upstream.json). Run it from the repository root +or anywhere under it. --lab Also move the @astryxdesign/lab canary pin (dev + peer). --as The ui-common version to record the codemods under. @@ -52,26 +60,37 @@ Exit codes: 0 done, 1 a step failed (the tree is left as it was at that step), `; /** + * The repository root at or above `cwd`: the @lablup/ui-common package with + * its scripts and the CLI package beside it. A directory inside the + * repository (packages/cli) finds it too. + * * @param {string} cwd * @returns {string | null} the repository root, or null outside it */ export function findUiCommonRepo(cwd) { - const dir = findProjectDir(cwd); - if (!dir) return null; + for (let dir = findProjectDir(cwd); dir;) { + if (isUiCommonRepo(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = findProjectDir(parent); + } + return null; +} + +/** @param {string} dir */ +function isUiCommonRepo(dir) { try { const pkg = readJson(join(dir, "package.json")); - if (pkg.name !== "@lablup/ui-common") return null; + if (pkg.name !== "@lablup/ui-common") return false; } catch { - return null; + return false; } // A consumer's node_modules copy has the name but not the repository. - if ( - !existsSync(join(dir, "scripts/gen-exports.mjs")) || - !existsSync(join(dir, "exports.exclude.json")) - ) { - return null; - } - return dir; + return ( + existsSync(join(dir, "scripts/gen-exports.mjs")) && + existsSync(join(dir, "exports.exclude.json")) && + existsSync(join(dir, CLI_PACKAGE_DIR, "package.json")) + ); } /** @@ -108,7 +127,7 @@ function listAstryxCodemods(repo, from, to) { * @param {{codemods: Array<{id: string, version: string, title: string}>, optional: string[]}} listed */ export function upstreamManifest(repo, version, astryx, listed) { - const file = join(repo, "codemods", version, "upstream.json"); + const file = join(repo, CLI_PACKAGE_DIR, "codemods", version, "upstream.json"); /** @type {any} */ let manifest = { $comment: @@ -214,8 +233,9 @@ export async function syncAstryxCommand(argv) { } const pkgFile = join(repo, "package.json"); - const pkgText = readFileSync(pkgFile, "utf8"); - const pkg = JSON.parse(pkgText); + const pkg = JSON.parse(readFileSync(pkgFile, "utf8")); + const cliPkgFile = join(repo, CLI_PACKAGE_DIR, "package.json"); + const cliPkg = JSON.parse(readFileSync(cliPkgFile, "utf8")); const current = pkg.dependencies?.["@astryxdesign/core"]; if (!current || !parse(current)) { process.stderr.write( @@ -231,9 +251,9 @@ export async function syncAstryxCommand(argv) { ); out(` repository: ${repo}`); const edits = []; - for (const name of PINNED) { - const from = pkg.dependencies?.[name]; - if (from !== target) edits.push(`dependencies["${name}"]: ${from} → ${target}`); + for (const [field, name] of PINNED) { + const from = pkg[field]?.[name]; + if (from !== target) edits.push(`${field}["${name}"]: ${from} → ${target}`); } if (lab) { for (const field of ["devDependencies", "peerDependencies"]) { @@ -246,6 +266,16 @@ export async function syncAstryxCommand(argv) { ? ` package.json: ${edits.join("; ")}` : " package.json: pins already at the target", ); + const cliEdits = []; + for (const [field, name] of CLI_PINNED) { + const from = cliPkg[field]?.[name]; + if (from !== target) cliEdits.push(`${field}["${name}"]: ${from} → ${target}`); + } + out( + cliEdits.length > 0 + ? ` ${CLI_PACKAGE_DIR}/package.json: ${cliEdits.join("; ")}` + : ` ${CLI_PACKAGE_DIR}/package.json: pins already at the target`, + ); for (const edit of labOverrideEdits(repo, target)) { out(` ${edit.name}: lab's core override → ${target}`); } @@ -307,15 +337,19 @@ export async function syncAstryxCommand(argv) { } // Pins. - for (const name of PINNED) { - pkg.dependencies[name] = target; + for (const [field, name] of PINNED) { + pkg[field] = { ...pkg[field], [name]: target }; + } + for (const [field, name] of CLI_PINNED) { + cliPkg[field] = { ...cliPkg[field], [name]: target }; } if (lab) { pkg.devDependencies = { ...pkg.devDependencies, [LAB]: lab }; pkg.peerDependencies = { ...pkg.peerDependencies, [LAB]: lab }; } writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); - out(" package.json written"); + writeFileSync(cliPkgFile, `${JSON.stringify(cliPkg, null, 2)}\n`); + out(` package.json and ${CLI_PACKAGE_DIR}/package.json written`); for (const edit of labOverrideEdits(repo, target)) { writeFileSync(edit.file, edit.after); out(` ${edit.name}: lab's core override moved to ${target}`); diff --git a/cli/upgrade.mjs b/packages/cli/cli/upgrade.mjs similarity index 65% rename from cli/upgrade.mjs rename to packages/cli/cli/upgrade.mjs index d9d4b6b..048c179 100644 --- a/cli/upgrade.mjs +++ b/packages/cli/cli/upgrade.mjs @@ -14,14 +14,17 @@ import { mkdirSync, readdirSync, readFileSync, + statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { registeredVersions, stepsBetween } from "../codemods/registry.mjs"; import { TODO_TAG } from "../codemods/lib/jsx.mjs"; import { diffStat, unifiedDiff } from "./diff.mjs"; -import { findProjectDir, ownPackageJson } from "./paths.mjs"; +import { cliPackageJson, findProjectDir } from "./paths.mjs"; +import { createResolver } from "./resolve.mjs"; import { renderReport, REPORT_HEADING } from "./report.mjs"; import { coerce, compare, parse } from "./semver.mjs"; @@ -35,8 +38,26 @@ const IGNORED_DIRS = new Set([ "coverage", ".turbo", ".cache", + // Only skipped by the project-wide finding scan (collectProjectFiles): + // build output and tool state that is never the project's own source. + "target", + ".venv", + "venv", + "__pycache__", + "storybook-static", + "playwright-report", + "test-results", + ".svelte-kit", + ".nuxt", + ".output", + ".vite", + ".yarn", + ".pnpm-store", ]); +/** Bigger than any hand-written source; a bundle or a generated file. */ +const MAX_SCAN_BYTES = 512 * 1024; + export const SOURCE_EXTENSIONS = new Set([ ".tsx", ".ts", @@ -99,10 +120,89 @@ function readdirSafe(p) { } } +/** + * The files the manual-review scan reads: every source-like file under + * `roots`, without build output, generated directories, bundles, and + * packages nested in the project (another package.json below it is another + * project). In a git checkout the list comes from git, so .gitignore'd files + * are skipped too. + * + * @param {string[]} roots absolute + * @param {string} projectDir + */ +export function collectProjectFiles(roots, projectDir) { + /** @type {string[]} */ + let listed = []; + const git = spawnSync( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], + { cwd: projectDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + if (git.status === 0 && git.stdout) { + listed = git.stdout + .split("\0") + .filter(Boolean) + .map((f) => join(projectDir, f)); + } else { + walk(projectDir, listed); + // walk keeps sources only; package.json files mark nested packages. + listed.push(...findPackageJsons(projectDir)); + } + const nested = new Set( + listed + .filter((f) => f.endsWith(`${sep}package.json`) && dirname(f) !== projectDir) + .map((f) => dirname(f) + sep), + ); + const inRoots = (/** @type {string} */ f) => + roots.some((r) => f === r || f.startsWith(r.endsWith(sep) ? r : r + sep)); + return [ + ...new Set( + listed.filter((f) => { + if (!inRoots(f)) return false; + if (!SOURCE_EXTENSIONS.has(extname(f)) || f.endsWith(".d.ts")) return false; + if (/\.min\.[cm]?[jt]s$|\.min\.css$/.test(f)) return false; + const parts = relative(projectDir, f).split(sep); + if (parts.slice(0, -1).some((d) => IGNORED_DIRS.has(d))) return false; + for (const dir of nested) if (f.startsWith(dir)) return false; + try { + return statSync(f).size <= MAX_SCAN_BYTES; + } catch { + return false; + } + }), + ), + ].sort(); +} + +/** @param {string} dir */ +function findPackageJsons(dir) { + /** @type {string[]} */ + const out = []; + const visit = (/** @type {string} */ d) => { + let entries; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = join(d, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) visit(full); + } else if (entry.name === "package.json") out.push(full); + } + }; + visit(dir); + return out; +} + /** * @typedef {object} UpgradeOptions * @property {string} cwd * @property {string[]} paths as given (relative to cwd) + * @property {string[]} [scan] where the manual-review scan looks (relative to + * cwd); default: the whole project * @property {string} [from] * @property {string} [to] * @property {boolean} [dryRun] @@ -123,7 +223,9 @@ export async function runUpgrade(options) { const pkgFile = join(projectDir, "package.json"); const pkgText = existsSync(pkgFile) ? readFileSync(pkgFile, "utf8") : null; const pkg = pkgText ? JSON.parse(pkgText) : {}; - const own = ownPackageJson(); + // The codemods ship with the CLI, which moves in lockstep with ui-common: + // its version is the ui-common version they upgrade to. + const own = cliPackageJson(); const declared = pkg.dependencies?.["@lablup/ui-common"] ?? @@ -167,6 +269,10 @@ export async function runUpgrade(options) { return { code: 2 }; } const files = collectFiles(roots); + const scanRoots = + options.scan && options.scan.length > 0 + ? options.scan.map((p) => resolve(cwd, p)) + : [projectDir]; // A dry run prints the report unless --report names a file for it. The // report replaces an earlier report, never anything else. @@ -205,12 +311,72 @@ export async function runUpgrade(options) { const errors = []; /** @type {string[]} */ const notices = []; + /** @type {string[]} */ + const alerts = []; + const resolver = createResolver(projectDir); + /** @type {Map} */ + const unscanned = new Map(); const ctx = { from, to, flags: { packages: new Map(), touched: new Set() }, projectDir, note: (/** @type {string} */ message) => packageNotes.push(message), + /** + * A project file as it was before this run: the scanned sources from + * memory, anything else from disk (read once). Null outside the project + * or when unreadable. + * + * @param {string} path absolute + */ + source: (path) => { + const known = state.get(path); + if (known) return known.created ? null : known.original; + if ( + !path.startsWith(projectDir + sep) || + path.split(sep).includes("node_modules") + ) + return null; + if (!unscanned.has(path)) { + let text = null; + try { + text = readFileSync(path, "utf8"); + } catch { + // left null + } + unscanned.set(path, text); + } + return unscanned.get(path) ?? null; + }, + /** The project file an import specifier names (relative or a tsconfig alias), or null. */ + resolveImport: resolver.resolveImport, + /** The project's package.json, parsed, as it was before this run. */ + pkg, + /** + * Something the person has to do before the upgraded app works: the + * report opens with these. + * + * @param {string} message + */ + alert: (message) => alerts.push(message), + /** @param {string} message where the run did something other than the usual */ + notice: (message) => notices.push(message), + /** Every file the finding scan would read (the whole project), absolute. */ + projectFiles: () => collectProjectFiles([projectDir], projectDir), + /** + * A file's content as this run has it now (edits included), or from disk. + * + * @param {string} path absolute + */ + current: (path) => { + const known = state.get(path); + if (known) return known.current; + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, /** * Edit a project file outside the scanned sources (pnpm-workspace.yaml). * `edit` gets its current text (null: absent) and returns the new text, @@ -219,8 +385,9 @@ export async function runUpgrade(options) { * * @param {string} path * @param {(current: string | null) => string | undefined} edit + * @param {string} [id] the transform named in the report */ - editFile: (path, edit) => { + editFile: (path, edit, id = "package-json") => { const known = state.get(path); const current = known ? known.current @@ -231,14 +398,13 @@ export async function runUpgrade(options) { if (next == null || next === current) return; if (known) { known.current = next; - if (!known.transforms.includes("package-json")) - known.transforms.push("package-json"); + if (!known.transforms.includes(id)) known.transforms.push(id); return; } state.set(path, { original: current ?? "", current: next, - transforms: ["package-json"], + transforms: [id], created: current == null, project: true, }); @@ -322,6 +488,20 @@ export async function runUpgrade(options) { } } + // Project-level edits that need every file transformed first. + for (const { step } of steps) { + if (!step.afterTransforms) continue; + try { + step.afterTransforms(ctx, { jscodeshift }); + } catch (err) { + errors.push({ + file: ".", + transform: "after-transforms", + error: /** @type {Error} */ (err).message, + }); + } + } + // package.json, after every source transform has set its flags. let pkgAfter = pkgText; if (pkgText) { @@ -344,13 +524,32 @@ export async function runUpgrade(options) { const findings = []; /** @type {Record} */ const categories = {}; + // The finding scan reads the whole project (or --scan), not only the + // transformed sources: tests, e2e specs and scripts name 0.1 classes and + // stylesheet paths too. Files outside the sources are read, never written. + /** @type {Array<[string, string]>} */ + const scanned = []; + const inScan = (/** @type {string} */ f) => + scanRoots.some((r) => f === r || f.startsWith(r + sep)); + for (const [file, entry] of state) { + if (entry.created || entry.project || !inScan(file)) continue; + scanned.push([file, entry.current]); + } + for (const file of collectProjectFiles(scanRoots, projectDir)) { + if (state.has(file)) continue; + const text = ctx.source(file); + if (text != null) scanned.push([file, text]); + } + scanned.sort((a, b) => a[0].localeCompare(b[0])); for (const { step } of steps) { Object.assign(categories, step.categories ?? {}); + if (step.findings) findings.push(...step.findings(ctx, rel)); if (!step.scan) continue; - for (const [file, entry] of state) { - if (entry.created || entry.project) continue; - findings.push(...step.scan(rel(file), entry.current)); - } + const context = step.prepareScan?.( + scanned.map(([file, text]) => [rel(file), text]), + ); + for (const [file, text] of scanned) + findings.push(...step.scan(rel(file), text, context)); } /** @type {Array<{file: string, line: number, text: string}>} */ const todos = []; @@ -402,6 +601,15 @@ export async function runUpgrade(options) { if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter); } + if (resolver.unresolved.size > 0) { + const list = [...resolver.unresolved.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([prefix, n]) => `\`${prefix}\` (${n})`); + notices.push( + `Imports through ${list.join(", ")} did not resolve: the upgrade reads relative imports and tsconfig \`paths\`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand.`, + ); + } + const report = renderReport({ from, to, @@ -409,6 +617,10 @@ export async function runUpgrade(options) { dryRun, roots: roots.map((r) => relative(projectDir, r).split(sep).join("/") || "."), fileCount: files.length, + scanRoots: scanRoots.map( + (r) => relative(projectDir, r).split(sep).join("/") || ".", + ), + scanCount: scanned.length, steps: steps.map(({ version, step }) => ({ version, title: step.title, @@ -426,6 +638,7 @@ export async function runUpgrade(options) { categories, errors, notices, + alerts, tokenReads, }); if (writeReport) { @@ -449,6 +662,7 @@ export async function runUpgrade(options) { log(unifiedDiff("package.json", pkgText, pkgAfter)); } for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`); + for (const alert of alerts) warn(` ACTION REQUIRED: ${alert}`); for (const notice of notices) warn(` note: ${notice}`); if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`); else log(`\n${report}`); @@ -470,22 +684,33 @@ export async function runUpgrade(options) { }; } -export const UPGRADE_HELP = `Usage: ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [paths…] +export const UPGRADE_HELP = `Usage: ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [--scan ]… [paths…] Run the codemods registered between two @lablup/ui-common versions over your source (TS/TSX/JS/JSX through jscodeshift, CSS through postcss), update -package.json, and write a manual-review report. +package.json, and write a manual-review report. From 0.1, package.json also +gains @lablup/ui-common-cli (this bin) as a devDependency at the new version. + +One-off, from a project still on 0.1 (\`@next\` until 0.2.0 is published): + pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run + (npx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run) --from The ui-common version the code is written against. Default: the version package.json declares. - --to Default: the installed @lablup/ui-common version. + --to Default: this CLI's version, which is the @lablup/ui-common + version it ships with. --dry-run Write nothing; list what would change and print the report. --diff With --dry-run, also print unified diffs. --report Where to write the report. Default: ui-common-upgrade-report.md, except in a dry run, which writes a report only to a path given here. An existing file is replaced only if it is an earlier report. - paths… Directories or files to scan. Default: src/ + --scan Where the report looks for 0.1 class names, stylesheet + paths, test queries and module mocks. Default: the whole + project (tests, e2e, scripts, …), without node_modules, + build output, git-ignored files and nested packages. + Repeat to narrow it: --scan src --scan e2e. + paths… Directories or files to transform. Default: src/ Exit codes: 0 done, 1 some files could not be transformed (see the report), 2 bad arguments or nothing to scan. @@ -514,6 +739,11 @@ export async function upgradeCommand(argv) { case "--report": options.report = value(); break; + case "--scan": { + const v = value(); + if (v) (options.scan ??= []).push(v); + break; + } case "--dry-run": options.dryRun = true; break; @@ -533,7 +763,7 @@ export async function upgradeCommand(argv) { } options.paths.push(arg); } - if (["--from", "--to", "--report"].includes(flag)) { + if (["--from", "--to", "--report", "--scan"].includes(flag)) { const v = /** @type {any} */ (options)[flag.slice(2)]; if (!v) { process.stderr.write(`ui-common upgrade: ${flag} needs a value\n`); diff --git a/codemods/0.2/components.mjs b/packages/cli/codemods/0.2/components.mjs similarity index 67% rename from codemods/0.2/components.mjs rename to packages/cli/codemods/0.2/components.mjs index 8d96035..493a726 100644 --- a/codemods/0.2/components.mjs +++ b/packages/cli/codemods/0.2/components.mjs @@ -15,9 +15,11 @@ import { identifierNames, renameElement, tagName, + TODO_TAG, } from "../lib/jsx.mjs"; import { addTodo, printSource } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; +import { importsLocalLegacy, localExports, registerWrapper } from "./local-modules.mjs"; import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; export const meta = { @@ -166,8 +168,14 @@ function renameReferences(j, root, from, to, programScope) { return; } if (parent?.type === "ExportSpecifier" && parent.local === p.node) { - parent.exported = j.identifier(parent.exported?.name ?? from); - parent.local = j.identifier(to); + // A fresh node: recast prints a parsed specifier from its original + // fields, so editing local/exported in place loses the public name. + const spec = j.exportSpecifier.from({ + local: j.identifier(to), + exported: j.identifier(parent.exported?.name ?? from), + }); + if (parent.exportKind) spec.exportKind = parent.exportKind; + p.parent.replace(spec); return; } if (!isReference(p)) return; @@ -270,14 +278,120 @@ function reuseNode(path, decls, kindKey) { } } +/** @param {any} entry a local-module export */ +const isRemovedType = (entry) => entry?.kind === "type" && entry.removed === true; + +/** + * Remove a top-level statement, handing its comments (TODOs included) to the + * statement after it, or before it when it was the last. + * + * @param {any} j + * @param {any} root + * @param {any} path + */ +function pruneKeepingComments(j, root, path) { + const node = path.node; + const comments = node.comments ?? []; + const body = root.find(j.Program).get().node.body; + const index = body.indexOf(node); + path.prune(); + if (comments.length === 0 || index === -1) return; + const next = body[index]; + if (next) { + next.comments = [...comments, ...(next.comments ?? [])]; + return; + } + const previous = body[index - 1]; + if (previous) { + previous.comments = [ + ...(previous.comments ?? []), + ...comments.map((/** @type {any} */ c) => ({ + ...c, + leading: false, + trailing: true, + })), + ]; + return; + } + keepCommentsInEmptyModule(j, root.find(j.Program).get().node, comments); +} + +/** + * A module the codemod emptied keeps its TODOs on an `export {};`, which also + * keeps it a module. + * + * @param {any} j + * @param {any} program + * @param {any[]} comments + */ +function keepCommentsInEmptyModule(j, program, comments) { + const decl = j.exportNamedDeclaration(null, []); + decl.comments = comments.map((/** @type {any} */ c) => ({ + ...c, + leading: true, + trailing: false, + })); + program.body.push(decl); +} + +/** + * A removed 0.1 type with no Astryx counterpart loses its import. A local + * re-export of it (`import type { X } from …; export type { X };`) goes too, + * the same way a re-export straight from ui-common does: left behind it names + * nothing, and the file no longer parses. No placeholder type is declared in + * its place: an `unknown` alias would keep every importer compiling against a + * type that no longer exists. The TODO goes on the export, or where it stood. + * + * @param {any} j + * @param {any} root + * @param {Map} dropped + * @param {any} programScope + */ +function dropLocalReexports(j, root, dropped, programScope) { + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + if (node.source || node.declaration) return; + const specifiers = node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.local?.name && + dropped.has(s.local.name) && + isModuleBinding(path, s.local.name, programScope), + ); + if (gone.length === 0) return; + const names = gone.map((/** @type {any} */ s) => s.exported?.name ?? s.local.name); + const components = [ + ...new Set( + gone.map((/** @type {any} */ s) => dropped.get(s.local.name)?.component), + ), + ]; + const message = `${names.join(", ")} (removed with ${components.join(", ")} in 0.2, no Astryx counterpart) ${names.length === 1 ? "is" : "are"} no longer re-exported from here; modules importing ${names.length === 1 ? "it" : "them"} from here need a type of their own.`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + node.specifiers = rest; + addTodo(j, path, message); + return; + } + node.comments = [ + ...(node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); + }); +} + /** * @param {{source: string, path: string}} file * @param {{jscodeshift: any}} api * @param {{flags: {packages: Map, touched: Set}}} ctx */ export default function transform(file, api, ctx) { - if (!file.source.includes(UIC)) return undefined; + const direct = file.source.includes(UIC); + const viaLocal = importsLocalLegacy(api.jscodeshift, ctx, file.path, file.source); + if (!direct && !viaLocal) return undefined; const j = api.jscodeshift; + // Registers the wrappers this module defines, for the report. + if (direct && ctx.source) localExports(j, ctx, file.path); const root = j(file.source); const isTS = /\.[cm]?tsx?$/.test(file.path); const taken = takenNames(j, root); @@ -302,6 +416,13 @@ export default function transform(file, api, ctx) { const plans = []; /** @type {any[]} */ const extra = []; + /** + * Local bindings of removed 0.1 types whose import was dropped: a local + * `export type { X }` of one goes with it (see dropLocalReexports). + * + * @type {Map} + */ + const droppedTypes = new Map(); /** * The local name to use for `name` from `@lablup/ui-common/`, @@ -328,10 +449,69 @@ export default function transform(file, api, ctx) { return local; }; + /** + * An import from a project module that hands 0.1 components on (a barrel + * the codemod rewrites under the 0.1 names): its components get the same + * element rewrite as a direct import, and the import itself stays. Its + * removed types go, as from ui-common. A wrapper's import is only counted. + * + * @param {any} path + * @param {string} source + */ + const localImport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const declIsType = path.node.importKind === "type"; + const kept = []; + for (const spec of path.node.specifiers ?? []) { + const name = + spec.type === "ImportSpecifier" + ? spec.imported.name + : spec.type === "ImportDefaultSpecifier" + ? "default" + : null; + const entry = name == null ? undefined : entries.get(name); + if (entry?.kind === "type" && entry.removed) { + touched = true; + addTodo( + j, + path, + `type ${entry.type} was removed with ${entry.component} in 0.2 and has no Astryx counterpart; ${source} no longer exports it.`, + ); + droppedTypes.set(spec.local.name, { + imported: entry.type, + component: entry.component, + }); + continue; + } + kept.push(spec); + if (entry?.kind === "wrapper") registerWrapper(ctx, entry, file.path); + if (entry?.kind !== "component") continue; + if (declIsType || spec.importKind === "type") continue; + touched = true; + ctx.flags.touched.add(entry.component); + bindings.set(spec.local.name, { + component: entry.component, + entry: REMOVED.get(entry.component), + local: spec.local.name, + jsxCount: 0, + valueRefs: 0, + add: null, + via: path, + }); + } + if (kept.length > 0) path.node.specifiers = kept; + else pruneKeepingComments(j, root, path); + }; + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localImport(path, source); + return; + } const specifiers = path.node.specifiers ?? []; if (specifiers.length === 0) return; const declIsType = path.node.importKind === "type"; @@ -413,6 +593,7 @@ export default function transform(file, api, ctx) { path, `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); + droppedTypes.set(local, { imported, component: resolved.component }); continue; } let finalLocal = local; @@ -430,16 +611,54 @@ export default function transform(file, api, ctx) { plans.push({ path, keep, adds }); }); + /** + * A type re-export from a project module (`export type { X }` from `./DataTable`) + * of a type that module no longer exports (its own import of X from ui-common + * was dropped): drop it too. + * + * @param {any} path + * @param {string} source + */ + const localReexport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const specifiers = path.node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.type === "ExportSpecifier" && isRemovedType(entries.get(s.local?.name)), + ); + if (gone.length === 0) return; + touched = true; + const names = gone.map((/** @type {any} */ s) => s.exported.name); + const message = `${names.join(", ")} ${names.length === 1 ? "is" : "are"} no longer re-exported: ${source} does not export ${names.length === 1 ? "it" : "them"} any more (removed in 0.2, no Astryx counterpart).`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + path.node.specifiers = rest; + addTodo(j, path, message); + } else { + path.node.comments = [ + ...(path.node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); + } + }; + // Re-exports: `export { Select } from "@lablup/ui-common"` keeps its name. root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localReexport(path, source); + return; + } const declIsType = path.node.exportKind === "type"; const keep = []; /** @type {Map} */ const moved = new Map(); let reexportedComponent = false; + let droppedSpecs = 0; for (const spec of path.node.specifiers ?? []) { const localName = spec.local?.name ?? spec.exported.name; const imported = @@ -467,6 +686,7 @@ export default function transform(file, api, ctx) { `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); touched = true; + droppedSpecs++; continue; } target = resolved.to; @@ -490,7 +710,13 @@ export default function transform(file, api, ctx) { ); moved.set(key, list); } - if (moved.size === 0) return; + if (moved.size === 0) { + // Only removed types: they go, and the TODO stays where they stood. + if (droppedSpecs === 0) return; + if (keep.length > 0) path.node.specifiers = keep; + else pruneKeepingComments(j, root, path); + return; + } const decls = [...moved.entries()].map(([key, specs]) => { const [kind, targetSource] = key.split("\u0000"); const decl = j.exportNamedDeclaration(null, specs, j.stringLiteral(targetSource)); @@ -500,7 +726,7 @@ export default function transform(file, api, ctx) { if (reexportedComponent) { decls[0].comments = [ j.commentLine( - " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration.", + " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props.", true, false, ), @@ -514,6 +740,8 @@ export default function transform(file, api, ctx) { } }); + if (droppedTypes.size > 0) dropLocalReexports(j, root, droppedTypes, programScope); + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; if (typeof source !== "string" || !classify(source)) return; @@ -521,7 +749,7 @@ export default function transform(file, api, ctx) { addTodo( j, path, - `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names; modules importing them from here still pass 0.1 props.`, + `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names. The upgrade migrated the elements of them in the modules it scanned that import them from here; any other importer still passes 0.1 props.`, ); }); @@ -598,6 +826,29 @@ export default function transform(file, api, ctx) { }); } + // A local-barrel import whose every element became another component + // (Button → IconButton) is unused now. + for (const [local, binding] of bindings) { + if (!binding.via || binding.via.pruned) continue; + let used = false; + root.findJSXElements(local).forEach((/** @type {any} */ p) => { + if (isModuleBinding(p, local, programScope)) used = true; + }); + root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ p) => { + if (p.node.type === "JSXIdentifier" || !isReference(p)) return; + if (isModuleBinding(p, local, programScope)) used = true; + }); + if (used) continue; + const node = binding.via.node; + node.specifiers = (node.specifiers ?? []).filter( + (/** @type {any} */ s) => s.local?.name !== local, + ); + if (node.specifiers.length === 0) { + pruneKeepingComments(j, root, binding.via); + binding.via.pruned = true; + } + } + for (const [from, to] of renames) renameReferences(j, root, from, to, programScope); // Imports: the planned moves, minus a Card import every BaseCard outgrew. @@ -605,6 +856,7 @@ export default function transform(file, api, ctx) { [...bindings.values()] .filter( (b) => + b.add != null && b.component === "BaseCard" && b.jsxCount === 0 && b.valueRefs === 0 && @@ -629,6 +881,7 @@ export default function transform(file, api, ctx) { const program = root.find(j.Program).get().node; const first = program.body[0]; if (first) first.comments = [...comments, ...(first.comments ?? [])]; + else keepCommentsInEmptyModule(j, program, comments); } } } diff --git a/codemods/0.2/elements.mjs b/packages/cli/codemods/0.2/elements.mjs similarity index 100% rename from codemods/0.2/elements.mjs rename to packages/cli/codemods/0.2/elements.mjs diff --git a/codemods/0.2/index.mjs b/packages/cli/codemods/0.2/index.mjs similarity index 86% rename from codemods/0.2/index.mjs rename to packages/cli/codemods/0.2/index.mjs index 51ea089..b7260eb 100644 --- a/codemods/0.2/index.mjs +++ b/packages/cli/codemods/0.2/index.mjs @@ -3,14 +3,17 @@ * `migration/0.1-to-0.2.json` (see ./map.mjs). */ import transformComponents, { meta as componentsMeta } from "./components.mjs"; +import { wrapperFindings } from "./local-modules.mjs"; +import { ensureTheme } from "./theme.mjs"; import { LAB_CSS, LAB_PACKAGE, REMOVED, UIC } from "./map.mjs"; import { transformPackageJson } from "./package-json.mjs"; -import { CATEGORIES, scanFile } from "./scan.mjs"; +import { CATEGORIES, prepareScan, scanFile } from "./scan.mjs"; import { cssMeta, jsMeta, transformScriptImports, transformStylesheet, + wireStylesheets, } from "./stylesheets.mjs"; /** @@ -52,8 +55,14 @@ export default { { ...jsMeta, run: transformScriptImports, parse: true }, { ...cssMeta, run: transformStylesheet, parse: false }, ], + afterTransforms: (ctx, api) => { + wireStylesheets(ctx, api); + ensureTheme(ctx, api); + }, packageJson: transformPackageJson, + prepareScan, scan: scanFile, + findings: wrapperFindings, categories: CATEGORIES, notes, }; diff --git a/codemods/0.2/legacy-classes.json b/packages/cli/codemods/0.2/legacy-classes.json similarity index 100% rename from codemods/0.2/legacy-classes.json rename to packages/cli/codemods/0.2/legacy-classes.json diff --git a/packages/cli/codemods/0.2/local-modules.mjs b/packages/cli/codemods/0.2/local-modules.mjs new file mode 100644 index 0000000..a13d0fb --- /dev/null +++ b/packages/cli/codemods/0.2/local-modules.mjs @@ -0,0 +1,510 @@ +/** + * The project's own modules that hand ui-common 0.1 components on: barrels + * (`export { Button } from "@lablup/ui-common/components/Button"`, + * `export * from …`, an import then `export { … }`, `export const X = Y`) and + * wrappers (an exported component that renders one). + * + * The components codemod rewrites such a barrel in place and keeps the 0.1 + * public name, so an import of `Select` from the barrel (`@/components/common`) now gets + * Astryx's Selector. Its call sites still pass 0.1 props: this module tells + * the codemod which local imports stand for which 0.1 component, so the same + * element rewrite runs on them. A wrapper is not a re-export: its props are + * its own, so its call sites are left alone and the wrapper is reported. + * + * Every module is read as it was before this run (the barrel itself is being + * rewritten), through the runner's `ctx.source` and `ctx.resolveImport`. + */ +import { REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; + +/** + * @typedef {{kind: 'component', component: string} + * | {kind: 'type', component: string, type: string, removed: boolean} + * | {kind: 'wrapper', components: string[], file: string, line: number, name: string}} LocalExport + * + * @typedef {object} LocalModuleContext + * @property {(path: string) => string | null} [source] a file's content before this run + * @property {(from: string, specifier: string, options?: {probe?: boolean}) => string | null} [resolveImport] + */ + +/** @type {WeakMap>, wrappers: Map}>}>} */ +const caches = new WeakMap(); + +/** @param {object} ctx */ +function cacheFor(ctx) { + let cache = caches.get(ctx); + if (!cache) { + cache = { modules: new Map(), wrappers: new Map() }; + caches.set(ctx, cache); + } + return cache; +} + +/** + * What a ui-common specifier's name stands for in 0.1. + * + * @param {string} source + * @param {string} name imported name (`default` for a default import) + * @returns {LocalExport | null} + */ +function uicName(source, name) { + let imported = name; + if (source !== UIC) { + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (!match) return null; + if (name === "default") imported = match[1]; + } + if (REMOVED.has(imported)) return { kind: "component", component: imported }; + const type = REMOVED_TYPES.get(imported); + if (type) + return { + kind: "type", + component: type.component, + type: imported, + removed: type.to == null, + }; + return null; +} + +/** + * Every removed name a ui-common specifier offers, for `export *`. + * + * @param {string} source + * @returns {Map} + */ +function uicStar(source) { + /** @type {Map} */ + const out = new Map(); + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (source !== UIC && !match) return out; + for (const [name, removed] of REMOVED) { + if (match && name !== match[1]) continue; + out.set(name, { kind: "component", component: name }); + for (const [type, to] of Object.entries(removed.types)) { + out.set(type, { kind: "type", component: name, type, removed: to == null }); + } + } + return out; +} + +/** @param {string} source */ +const isUic = (source) => source === UIC || source.startsWith(`${UIC}/components/`); + +/** + * Import sources a file names, without parsing it. + * + * @param {string} source + */ +function importSources(source) { + const out = new Set(); + for (const m of source.matchAll(/\b(?:from|import)\s*["']([^"']+)["']/g)) + out.add(m[1]); + return out; +} + +/** Strip parentheses and TS casts. @param {any} node */ +function unwrap(node) { + let n = node; + while ( + n && + (n.type === "TSAsExpression" || + n.type === "TSSatisfiesExpression" || + n.type === "TSNonNullExpression" || + n.type === "ParenthesizedExpression") + ) + n = n.expression; + return n; +} + +/** + * The function a component declaration defines: a function declaration, or + * the function a `const` holds, through `forwardRef(…)` / `memo(…)`, or the + * declaration `memo(Name)` names. + * + * @param {any} decl + * @param {Map} [declarations] the module's top-level declarations + * @param {number} [depth] + */ +function componentFunction(decl, declarations = new Map(), depth = 0) { + if (decl.type === "FunctionDeclaration") return decl; + let init = decl.type === "VariableDeclarator" ? unwrap(decl.init) : null; + while (init?.type === "CallExpression") { + init = unwrap( + init.arguments.find( + (/** @type {any} */ a) => + a.type === "ArrowFunctionExpression" || + a.type === "FunctionExpression" || + a.type === "CallExpression" || + a.type === "Identifier", + ), + ); + } + // `memo(EmptyStateComponent)`: the component is declared on its own. + if (init?.type === "Identifier" && depth < 3) { + const target = declarations.get(init.name); + return target ? componentFunction(target, declarations, depth + 1) : null; + } + return init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression" + ? init + : null; +} + +/** + * The names a component's props parameter binds: the props object, or each + * destructured prop and the rest. + * + * @param {any} param + * @returns {Set} + */ +function paramNames(param) { + const out = new Set(); + const p = param?.type === "AssignmentPattern" ? param.left : param; + if (p?.type === "Identifier") out.add(p.name); + else if (p?.type === "ObjectPattern") { + for (const prop of p.properties) { + if (prop.type === "RestElement" && prop.argument.type === "Identifier") + out.add(prop.argument.name); + else if (prop.value?.type === "Identifier") out.add(prop.value.name); + else if ( + prop.value?.type === "AssignmentPattern" && + prop.value.left.type === "Identifier" + ) + out.add(prop.value.left.name); + } + } + return out; +} + +/** + * The 0.1 components (and removed types) a project module exports, by + * exported name. Empty for a module that hands on none. + * + * @param {any} j jscodeshift + * @param {LocalModuleContext} ctx + * @param {string} file absolute + * @param {Set} [stack] modules being read, against cycles + * @returns {Map} + */ +export function localExports(j, ctx, file, stack = new Set()) { + const cache = cacheFor(ctx); + const hit = cache.modules.get(file); + if (hit) return hit; + /** @type {Map} */ + const out = new Map(); + const source = ctx.source?.(file); + if (source == null || stack.has(file) || !/\bexport\b/.test(source)) { + if (!stack.has(file)) cache.modules.set(file, out); + return out; + } + // Cheap bail-out: nothing from ui-common, directly or through a module the + // resolver finds (relative, tsconfig `paths` or `baseUrl` alike). A probe, + // since the text scan also matches specifiers in comments. + /** @type {Map} */ + const targets = new Map(); + let worthReading = false; + for (const s of importSources(source)) { + const target = isUic(s) ? null : ctx.resolveImport?.(file, s, { probe: true }); + if (target) targets.set(s, target); + if (target || isUic(s)) worthReading = true; + } + if (!worthReading) { + cache.modules.set(file, out); + return out; + } + stack.add(file); + let root; + try { + // Parse by the module's own extension, not the importer's. + const parser = /\.[cm]?tsx?$/.test(file) ? "tsx" : "babel"; + root = (typeof j.withParser === "function" ? j.withParser(parser) : j)(source); + } catch { + stack.delete(file); + cache.modules.set(file, out); + return out; + } + + /** @param {string} specifier */ + const moduleExports = (specifier) => { + const target = targets.get(specifier) ?? ctx.resolveImport?.(file, specifier); + return target ? localExports(j, ctx, target, stack) : new Map(); + }; + + // Local bindings that stand for a 0.1 component or type. + /** @type {Map} */ + const bindings = new Map(); + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source.value; + if (typeof from !== "string") return; + for (const spec of path.node.specifiers ?? []) { + if (spec.type === "ImportNamespaceSpecifier") continue; + const name = + spec.type === "ImportDefaultSpecifier" ? "default" : spec.imported.name; + const entry = isUic(from) ? uicName(from, name) : moduleExports(from).get(name); + if (entry) bindings.set(spec.local.name, entry); + } + }); + + // Top-level declarations, for `export { X }` of a local and for wrappers. + /** @type {Map} */ + const declarations = new Map(); + /** @type {Map} */ + const typeDeclarations = new Map(); + for (const statement of root.find(j.Program).get().node.body) { + const node = + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!node) continue; + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + node.id + ) { + declarations.set(node.id.name, node); + } else if (node.type === "VariableDeclaration") { + for (const d of node.declarations) { + if (d.id?.type === "Identifier") declarations.set(d.id.name, d); + } + } else if ( + (node.type === "TSInterfaceDeclaration" || + node.type === "TSTypeAliasDeclaration") && + node.id + ) { + typeDeclarations.set(node.id.name, node); + } + } + + /** + * The 0.1 components whose props type a props parameter's type is built + * on: `SelectProps`, `Omit, …>`, an intersection with + * one, or a local interface or type alias that extends or aliases one. + * + * @param {any} param + * @returns {Set} + */ + const propsTypeComponents = (param) => { + const out = new Set(); + const seen = new Set(); + const WRAPPING = new Set(["Omit", "Partial", "Pick", "Readonly", "Required"]); + /** + * @param {string} name + * @param {any} args type arguments + */ + const reference = (name, args) => { + const bound = bindings.get(name); + if (bound?.kind === "type" && /Props$/.test(bound.type)) out.add(bound.component); + if (WRAPPING.has(name)) visit(args?.params?.[0]); + const local = typeDeclarations.get(name); + if (local && !seen.has(name)) { + seen.add(name); + if (local.type === "TSTypeAliasDeclaration") visit(local.typeAnnotation); + for (const heritage of local.extends ?? []) { + const id = heritage.expression; + if (id?.type === "Identifier") + reference(id.name, heritage.typeParameters ?? heritage.typeArguments); + } + } + }; + /** Only what the props type is made of, not the types of its members. @param {any} t */ + const visit = (t) => { + if (!t) return; + switch (t.type) { + case "TSTypeAnnotation": + case "TSParenthesizedType": + visit(t.typeAnnotation); + break; + case "TSIntersectionType": + case "TSUnionType": + t.types.forEach(visit); + break; + case "TSTypeReference": + if (t.typeName?.type === "Identifier") + reference(t.typeName.name, t.typeParameters ?? t.typeArguments); + break; + default: + } + }; + const p = param?.type === "AssignmentPattern" ? param.left : param; + visit(p?.typeAnnotation); + return out; + }; + + /** + * What exporting local `name` hands on: an aliased 0.1 binding, or a + * wrapper that renders one. + * + * @param {string} name + * @param {string} exported + * @returns {LocalExport | null} + */ + const describeLocal = (name, exported) => { + const bound = bindings.get(name); + if (bound) return bound; + const decl = declarations.get(name); + if (!decl) return null; + if (decl.type === "VariableDeclarator" && decl.init?.type === "Identifier") { + return bindings.get(decl.init.name) ?? describeLocal(decl.init.name, exported); + } + const fn = componentFunction(decl, declarations); + if (!fn) return null; + const params = paramNames(fn.params[0]); + // Its callers pass 0.1 props when its props type is built on a 0.1 one… + const typed = propsTypeComponents(fn.params[0]); + /** @param {any} expression */ + const fromParams = (expression) => { + const e = unwrap(expression); + return e?.type === "Identifier" && params.has(e.name); + }; + const rendered = new Set(); + j(fn) + .find(j.JSXElement) + .forEach((/** @type {any} */ p) => { + const tag = p.node.openingElement.name; + if (tag.type !== "JSXIdentifier") return; + const entry = bindings.get(tag.name); + const components = + entry?.kind === "component" + ? [entry.component] + : entry?.kind === "wrapper" + ? entry.components + : []; + if (components.length === 0) return; + // …or when it spreads its own props (or their rest) into one. + const spreads = (p.node.openingElement.attributes ?? []).some( + (/** @type {any} */ a) => + a.type === "JSXSpreadAttribute" && fromParams(a.argument), + ); + if (spreads || components.some((c) => typed.has(c))) + for (const c of components) rendered.add(c); + }); + if (rendered.size === 0) return null; + return { + kind: "wrapper", + components: [...rendered].sort(), + file, + line: + decl.loc?.start.line ?? + (typeof decl.start === "number" + ? source.slice(0, decl.start).split("\n").length + : 0), + name: exported === "default" ? name : exported, + }; + }; + + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + const from = node.source?.value; + if (typeof from === "string") { + const theirs = isUic(from) ? null : moduleExports(from); + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const name = spec.local?.name ?? spec.exported.name; + const entry = theirs ? theirs.get(name) : uicName(from, name); + if (entry) out.set(spec.exported.name, entry); + } + return; + } + if (node.declaration) { + const decl = node.declaration; + const names = + decl.type === "VariableDeclaration" + ? decl.declarations + .filter((/** @type {any} */ d) => d.id?.type === "Identifier") + .map((/** @type {any} */ d) => d.id.name) + : decl.id + ? [decl.id.name] + : []; + for (const name of names) { + const entry = describeLocal(name, name); + if (entry) out.set(name, entry); + } + return; + } + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const entry = describeLocal(spec.local.name, spec.exported.name); + if (entry) out.set(spec.exported.name, entry); + } + }); + + root.find(j.ExportDefaultDeclaration).forEach((/** @type {any} */ path) => { + const decl = path.node.declaration; + let entry = null; + if (decl.type === "Identifier") entry = describeLocal(decl.name, "default"); + else if (decl.id?.name) entry = describeLocal(decl.id.name, "default"); + if (entry) out.set("default", entry); + }); + + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source?.value; + if (typeof from !== "string" || path.node.exported) return; + const theirs = isUic(from) ? uicStar(from) : moduleExports(from); + for (const [name, entry] of theirs) { + if (name !== "default" && !out.has(name)) out.set(name, entry); + } + }); + + stack.delete(file); + cache.modules.set(file, out); + for (const entry of out.values()) { + if (entry.kind === "wrapper") registerWrapper(ctx, entry); + } + return out; +} + +/** + * @param {object} ctx + * @param {LocalExport & {kind: 'wrapper'}} def + * @param {string} [importer] + */ +export function registerWrapper(ctx, def, importer) { + const { wrappers } = cacheFor(ctx); + const key = `${def.file}\u0000${def.name}`; + const known = wrappers.get(key) ?? { def, importers: new Set() }; + if (importer) known.importers.add(importer); + wrappers.set(key, known); +} + +/** + * Whether a file imports anything from a project module that hands on a 0.1 + * component or type, without parsing the file itself. + * + * @param {any} j + * @param {LocalModuleContext} ctx + * @param {string} file + * @param {string} source + */ +export function importsLocalLegacy(j, ctx, file, source) { + if (!ctx.resolveImport) return false; + for (const specifier of importSources(source)) { + if (specifier.startsWith(UIC)) continue; + const target = ctx.resolveImport(file, specifier); + if (target && localExports(j, ctx, target).size > 0) return true; + } + return false; +} + +/** + * The wrappers the run met, for the report: one finding per wrapper. + * + * @param {object} ctx + * @param {(file: string) => string} rel + */ +export function wrapperFindings(ctx, rel) { + const cache = caches.get(ctx); + if (!cache) return []; + return [...cache.wrappers.values()] + .sort((a, b) => a.def.file.localeCompare(b.def.file) || a.def.line - b.def.line) + .map(({ def, importers }) => { + const targets = def.components.map((c) => { + const to = REMOVED.get(c)?.to; + return to && to !== c ? `${c} (Astryx ${to})` : c; + }); + return { + category: "local-wrapper", + file: rel(def.file), + line: def.line, + text: def.name, + detail: `local wrapper around ${targets.join(", ")}: review its props.${importers.size > 0 ? ` Imported by ${importers.size} scanned module${importers.size === 1 ? "" : "s"}, whose props were not migrated.` : ""}`, + }; + }); +} diff --git a/codemods/0.2/map.mjs b/packages/cli/codemods/0.2/map.mjs similarity index 94% rename from codemods/0.2/map.mjs rename to packages/cli/codemods/0.2/map.mjs index d800c30..a754b7f 100644 --- a/codemods/0.2/map.mjs +++ b/packages/cli/codemods/0.2/map.mjs @@ -6,10 +6,15 @@ */ import { join } from "node:path"; -import { ownPackageJson, PACKAGE_ROOT, readJson } from "../../cli/paths.mjs"; +import { + CLI_ROOT, + readJson, + targetUiCommonRoot, + uiCommonPackageJson, +} from "../../cli/paths.mjs"; export const UIC = "@lablup/ui-common"; -export const MIGRATION_FILE = join(PACKAGE_ROOT, "migration", "0.1-to-0.2.json"); +export const MIGRATION_FILE = join(CLI_ROOT, "migration", "0.1-to-0.2.json"); /** * @typedef {object} PropRename @@ -113,11 +118,14 @@ export const STYLESHEETS = { export const LAB_PACKAGE = "@astryxdesign/lab"; export const LAB_CSS = `${UIC}/lab/lab.css`; -/** The StyleX peer range ui-common itself declares. */ +/** The StyleX peer range the target ui-common declares. */ export function stylexPeer() { return { name: "@stylexjs/stylex", - range: ownPackageJson().peerDependencies?.["@stylexjs/stylex"] ?? "^0.19.0", + range: + uiCommonPackageJson(targetUiCommonRoot()).peerDependencies?.[ + "@stylexjs/stylex" + ] ?? "^0.19.0", }; } diff --git a/packages/cli/codemods/0.2/package-json.mjs b/packages/cli/codemods/0.2/package-json.mjs new file mode 100644 index 0000000..45f3729 --- /dev/null +++ b/packages/cli/codemods/0.2/package-json.mjs @@ -0,0 +1,333 @@ +/** + * 0.1 -> 0.2 package.json edits: + * - bump @lablup/ui-common to the target version (keeping `^`/`~`); + * - add @lablup/ui-common-cli, the `ui-common` bin, to devDependencies at + * exactly the target version (0.1 shipped the bin inside ui-common); + * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; + * - in a library, drop React 18 from the react / react-dom peer ranges; + * - in a pnpm project, decline the Astryx postinstalls in `allowBuilds`; + * - add @astryxdesign/lab, pinned to the canary ui-common is built against, + * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its + * core peer at ui-common's core with the project's package manager's + * override (../../cli/lab-peer.mjs). + */ +import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; +import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs"; +import { coerce, narrowToFloor } from "../../cli/semver.mjs"; +import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; + +/** The CLI package, released in lockstep with ui-common. */ +export const CLI_PACKAGE = "@lablup/ui-common-cli"; + +const FIELDS = /** @type {const} */ ([ + "dependencies", + "devDependencies", + "peerDependencies", +]); + +/** @param {Record} object */ +function isSorted(object) { + const keys = Object.keys(object); + return keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0); +} + +/** + * @param {any} pkg + * @param {string} field + * @param {string} name + * @param {string} range + */ +function addDependency(pkg, field, name, range) { + const current = pkg[field] ?? {}; + const next = { ...current, [name]: range }; + pkg[field] = isSorted(current) + ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))) + : next; +} + +/** + * @param {string} spec + * @param {string} to + * @returns {{value: string, note?: string} | null} + */ +function bumpSpec(spec, to) { + if (/^(workspace:|link:|file:|npm:|git|https?:)/.test(spec)) return null; + const simple = /^([\^~]?)v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.exec(spec.trim()); + if (simple) return { value: `${simple[1]}${to}` }; + return { + value: `^${to}`, + note: `the range "${spec}" was replaced with "^${to}"; widen it again if this package must still accept 0.1.`, + }; +} + +/** Packages whose postinstall pnpm 11 must be told to run or skip. */ +export const DECLINED_BUILDS = ["@astryxdesign/core", "@astryxdesign/cli"]; + +/** + * `pnpm-workspace.yaml` with `allowBuilds` declining each of `names` it does + * not decide yet. An entry set to true or false is left as it is; one with + * any other value (pnpm writes "set this to true or false" when it stops an + * install) counts as undecided and is set to false. Comments and every other + * line are kept. + * + * @param {string | null} yaml current text, null when there is no file + * @param {string[]} names + * @returns {{yaml?: string, notes: string[]}} + */ +export function applyAllowBuilds(yaml, names) { + const line = (/** @type {string} */ name, indent = " ") => + `${indent}"${name}": false`; + const why = + "their postinstall only prints an `astryx init` nudge, and pnpm 11 stops the install (ERR_PNPM_IGNORED_BUILDS) until each is allowed or declined"; + if (yaml == null) { + return { + yaml: `allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`, + notes: [ + `wrote pnpm-workspace.yaml with allowBuilds declining ${names.join(", ")}: ${why}.`, + ], + }; + } + const block = /^allowBuilds:[ \t]*(#.*)?(\r?\n)/m.exec(yaml); + if (!block) { + if (/^allowBuilds\s*:/m.test(yaml)) { + return { + notes: [ + `pnpm-workspace.yaml has an allowBuilds entry this cannot edit; decline ${names.join(", ")} in it: ${why}.`, + ], + }; + } + const base = yaml.replace(/\s*$/, ""); + return { + yaml: `${base}${base ? "\n\n" : ""}allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`, + notes: [ + `added allowBuilds declining ${names.join(", ")} to pnpm-workspace.yaml: ${why}.`, + ], + }; + } + const at = block.index + block[0].length; + const after = yaml.slice(at); + const end = /^\S/m.exec(after)?.index ?? after.length; + let body = after.slice(0, end); + const indent = /^([ \t]+)\S/m.exec(body)?.[1] ?? " "; + const notes = []; + const added = []; + for (const name of names) { + const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const entry = new RegExp( + `^([ \\t]+["']?${esc}["']?[ \\t]*:[ \\t]*)([^\\r\\n#]*?)([ \\t]*(#.*)?)$`, + "m", + ); + const match = entry.exec(body); + if (!match) { + added.push(name); + continue; + } + const value = match[2].trim().replace(/^["']|["']$/g, ""); + if (value === "true" || value === "false") continue; + body = body.replace(entry, (_m, head, _v, tail) => `${head}false${tail}`); + notes.push( + `pnpm-workspace.yaml allowBuilds["${name}"] was "${match[2].trim()}", which pnpm does not accept; set it to false.`, + ); + } + if (added.length > 0) { + const trimmed = body.replace(/\s*$/, ""); + const rest = body.slice(trimmed.length); + body = `${trimmed}${trimmed ? "\n" : ""}${added.map((n) => line(n, indent)).join("\n")}${rest.includes("\n") ? rest : "\n"}`; + notes.push( + `added ${added.join(", ")} to allowBuilds in pnpm-workspace.yaml, declined: ${why}.`, + ); + } + const next = `${yaml.slice(0, at)}${body}${after.slice(end)}`; + return next === yaml ? { notes } : { yaml: next, notes }; +} + +/** React's floor when the target ui-common's peer cannot be read. */ +const REACT_FLOOR = "19.2.0"; + +/** + * A React range narrowed to `floor` and up (`>=18 <21 || ^22` → + * `>=19.2.0 <21 || ^22`); null when it admits no version that high, the + * range itself when it already starts there or is not a version range. + * + * @param {string} range + * @param {string} floor + * @returns {string | null} + */ +export function narrowReactRange(range, floor) { + if (/^(workspace:|link:|file:|npm:|catalog:)/.test(range.trim())) return range; + return narrowToFloor(range, floor); +} + +/** + * Point the lab canary's core peer at ui-common's core: `overrides` in + * package.json for npm (the project's own, when it is the install root), + * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise. + * + * @param {any} pkg parsed package.json, edited in place + * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx + */ +function addLabOverride(pkg, ctx) { + const pin = uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).dependencies?.[ + CORE + ]; + if (!pin || !ctx.projectDir || !ctx.editFile) return; + const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); + if (manager === "npm" && root !== ctx.projectDir) { + // npm reads overrides from the install root's package.json only. + const { note } = applyLabOverride({ manager: null, pin }); + ctx.note(`npm installs from ${root}, not this package: ${note}`); + return; + } + if (manager === "pnpm" && workspaceYaml) { + const yamlFile = workspaceYaml; + ctx.editFile(yamlFile, (current) => { + const edit = applyLabOverride({ manager, pin, workspaceYaml: current }); + if (edit.note) ctx.note(edit.note); + return edit.workspaceYaml; + }); + return; + } + const { note } = applyLabOverride({ manager, pin, pkg }); + ctx.note(note); +} + +/** + * @param {string} text package.json source + * @param {{to: string, flags: {packages: Map}, note: (message: string) => void}} ctx + */ +export function transformPackageJson(text, ctx) { + const pkg = JSON.parse(text); + const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; + const fields = FIELDS.filter((f) => pkg[f]?.[UIC] != null); + if (fields.length === 0) return undefined; + + for (const field of fields) { + const spec = pkg[field][UIC]; + const bumped = bumpSpec(spec, ctx.to); + if (!bumped) { + ctx.note(`${field}["${UIC}"] is "${spec}"; not a version, so it was left alone.`); + continue; + } + if (bumped.value !== spec) { + pkg[field][UIC] = bumped.value; + ctx.note(`${field}["${UIC}"]: "${spec}" → "${bumped.value}".`); + } + if (bumped.note) ctx.note(`${field}["${UIC}"]: ${bumped.note}`); + } + + const has = (/** @type {string} */ name) => + FIELDS.some((f) => pkg[f]?.[name] != null); + + // The bin moved out of ui-common into its own package. It is a dev-time + // tool, pinned exactly: it upgrades to and reads the ui-common of its own + // version. + const cliField = FIELDS.find((f) => pkg[f]?.[CLI_PACKAGE] != null); + if (!cliField) { + addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.to); + ctx.note( + `added ${CLI_PACKAGE} ${ctx.to} to devDependencies: the \`ui-common\` bin ships in its own package since 0.2, released at the same version as ${UIC}.`, + ); + } else { + const spec = pkg[cliField][CLI_PACKAGE]; + const bumped = bumpSpec(spec, ctx.to); + if (!bumped) { + ctx.note( + `${cliField}["${CLI_PACKAGE}"] is "${spec}"; not a version, so it was left alone.`, + ); + } else if (spec !== ctx.to) { + pkg[cliField][CLI_PACKAGE] = ctx.to; + ctx.note(`${cliField}["${CLI_PACKAGE}"]: "${spec}" → "${ctx.to}".`); + } + } + // A library that takes ui-common as a peer takes StyleX as a peer too; + // an application depends on it. + const library = fields.includes("peerDependencies"); + + /** + * Add `name` where this project needs it, and return the fields it went + * into. A library needs it as a peer and, when it develops against + * ui-common, as a devDependency: each is checked on its own, since one does + * not stand in for the other. An application needs it anywhere. + * + * @param {string} name + * @param {string} range + */ + const ensure = (name, range) => { + /** @type {string[]} */ + const wanted = library + ? [ + ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["peerDependencies"] + : []), + ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null + ? ["devDependencies"] + : []), + ] + : has(name) + ? [] + : [fields.includes("dependencies") ? "dependencies" : fields[0]]; + for (const field of wanted) addDependency(pkg, field, name, range); + return wanted; + }; + + const stylex = stylexPeer(); + const stylexAdded = ensure(stylex.name, stylex.range); + if (stylexAdded.length > 0) + ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`); + + // 0.2 needs React 19.2: a library that still accepts older React in its + // peers would install beside such an app and break there. + const reactPeers = + uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).peerDependencies ?? {}; + for (const name of ["react", "react-dom"]) { + const floor = coerce(reactPeers[name] ?? "") ?? REACT_FLOOR; + const spec = pkg.peerDependencies?.[name]; + if (library && typeof spec === "string") { + const next = narrowReactRange(spec, floor); + if (next == null) { + ctx.note( + `peerDependencies["${name}"] is "${spec}", which admits no React ${floor} or later; ${UIC} 0.2 needs it, so move the range up by hand.`, + ); + } else if (next !== spec) { + pkg.peerDependencies[name] = next; + ctx.note( + `peerDependencies["${name}"]: "${spec}" → "${next}": ${UIC} 0.2 needs React ${floor} or later.`, + ); + } + } + const own = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; + if (typeof own === "string" && narrowReactRange(own, floor) !== own) { + ctx.note( + `${pkg.dependencies?.[name] ? "dependencies" : "devDependencies"}["${name}"] is "${own}": ${UIC} 0.2 needs React ${floor} or later; upgrade React too.`, + ); + } + } + + // pnpm 11 refuses to install until the Astryx postinstalls are decided. + if (ctx.projectDir && ctx.editFile) { + const { manager, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); + if (manager === "pnpm" && workspaceYaml) { + ctx.editFile(workspaceYaml, (current) => { + const edit = applyAllowBuilds(current, DECLINED_BUILDS); + for (const note of edit.notes) ctx.note(note); + return edit.yaml; + }); + } + } + + // Packages the map says a moved component needs (the lab Drawer). + for (const [lab, range] of ctx.flags.packages) { + const added = ensure(lab, range); + if (added.length === 0) continue; + const field = added.join(" and "); + ctx.note( + lab === LAB_PACKAGE + ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` + : `added ${lab} ${range} to ${field}.`, + ); + if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); + } + + const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; + return out === text ? undefined : out; +} diff --git a/codemods/0.2/scan.mjs b/packages/cli/codemods/0.2/scan.mjs similarity index 68% rename from codemods/0.2/scan.mjs rename to packages/cli/codemods/0.2/scan.mjs index 70b52dd..cd247b6 100644 --- a/codemods/0.2/scan.mjs +++ b/packages/cli/codemods/0.2/scan.mjs @@ -16,8 +16,9 @@ import { fileURLToPath } from "node:url"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; -import { dependencyDir } from "../../cli/paths.mjs"; +import { dependencyDir, targetUiCommonRoot } from "../../cli/paths.mjs"; import { keptClassRename } from "./map.mjs"; +import { scanTheme } from "./theme.mjs"; const legacy = JSON.parse( readFileSync( @@ -68,9 +69,11 @@ export function astryxCustomProperties() { if (astryxProperties) return astryxProperties; const names = new Set(); const sheets = []; - const core = dependencyDir("@astryxdesign/core"); + // The Astryx the upgrade moves to: the project may still be on 0.1. + const root = targetUiCommonRoot(); + const core = dependencyDir("@astryxdesign/core", root); if (core) sheets.push(join(core, "dist/astryx.css")); - const neutral = dependencyDir("@astryxdesign/theme-neutral"); + const neutral = dependencyDir("@astryxdesign/theme-neutral", root); if (neutral) sheets.push(join(neutral, "dist/theme.css")); for (const sheet of sheets) { let text; @@ -116,7 +119,7 @@ function classesIn(selector) { } /** - * @typedef {{category: string, file: string, line: number, text: string, detail?: string}} Finding + * @typedef {{category: string, file: string, line: number, text: string, detail?: string, classes?: string[]}} Finding */ /** @@ -149,6 +152,7 @@ function scanStylesheet(file, source) { line: rule.source?.start?.line ?? 0, text: rule.selector.replace(/\s+/g, " "), detail: hits.map(describeClass).join(", "), + classes: hits, }); } }); @@ -191,14 +195,17 @@ function scanStylesheet(file, source) { line: i + 1, text: code.trim().replace(/\s*[{,]$/, ""), detail: [...new Set(hits)].map(describeClass).join(", "), + classes: hits, }); } }); return findings; } +// DOM APIs, and the selector-taking calls of Playwright, Cypress and +// Testing Library's container queries. const SELECTOR_CALL = - /\b(querySelector(?:All)?|closest|matches|webkitMatchesSelector)\(\s*(["'`])((?:(?!\2)[^\\]|\\.)*)\2/g; + /(?:\b(querySelector(?:All)?|closest|matches|webkitMatchesSelector|locator|waitForSelector)|(? 0) record(m, hits); } for (const m of source.matchAll(CLASS_CALL)) { @@ -254,18 +262,79 @@ function scanScript(file, source) { return findings; } +/** A selector that is one class, with pseudo-classes at most: `.tabs__tab:hover`. */ +const DEFINITION = /^\.(-?[_a-zA-Z][\w-]*)(?::{1,2}[\w-]+(?:\([^)]*\))?)*$/; + +/** + * The 0.1 class names the project owns: it defines each in a stylesheet of + * its own as a rule by itself (`.tabs__tab { … }`) and renders it in its own + * markup (`className="tabs__tab"`, outside tests). Such a project most likely + * has its own `.tabs__tab`, so findings on it are listed apart, as lower + * confidence. A definition alone is not enough: an override of ui-common's + * class (`.drawer__content { padding: 0 }`) looks the same. + * + * @param {Array<[string, string]>} files [relative path, content] + * @returns {{ownClasses: Set}} + */ +export function prepareScan(files) { + const defined = new Set(); + /** @param {string} selectors */ + const collect = (selectors) => { + for (const part of selectors.split(",")) { + const m = DEFINITION.exec(part.trim()); + if (m && isLegacyClass(m[1])) defined.add(m[1]); + } + }; + for (const [file, source] of files) { + if (file.endsWith(".css")) { + try { + postcss + .parse(source, { from: file }) + .walkRules((rule) => collect(rule.selector)); + } catch { + // unparseable: nothing defined + } + } else if (/\.(scss|sass|less)$/.test(file)) { + for (const line of source.split("\n")) { + const m = /^\s*([^{}/@]+?)\s*\{\s*$/.exec(line); + if (m) collect(m[1]); + } + } + } + const own = new Set(); + if (defined.size === 0) return { ownClasses: own }; + const escape = (/** @type {string} */ c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(?:^|[\\s"'\`])(${[...defined].map(escape).join("|")})(?=[\\s"'\`]|$)`, + "gm", + ); + for (const [file, source] of files) { + if (/\.(css|scss|sass|less)$/.test(file) || isTestFile(file)) continue; + for (const m of source.matchAll(pattern)) own.add(m[1]); + } + return { ownClasses: own }; +} + +const ORIGIN = { + "css-selector": "CSS selector", + "dom-hook": "DOM hook", + "test-query": "test query", +}; + /** * @param {string} file relative path * @param {string} source final content + * @param {{ownClasses?: Set}} [context] from prepareScan * @returns {Finding[]} */ -export function scanFile(file, source) { +export function scanFile(file, source, context) { /** @type {Finding[]} */ const findings = []; if (/\.(css|scss|sass|less)$/.test(file)) findings.push(...scanStylesheet(file, source)); else findings.push(...scanScript(file, source)); + findings.push(...scanTheme(file, source)); source.split("\n").forEach((line, i) => { if (/@lablup\/ui-common\/styles\//.test(line)) { findings.push({ @@ -277,6 +346,14 @@ export function scanFile(file, source) { }); } }); + const own = context?.ownClasses; + for (const f of findings) { + if (f.classes && own && f.classes.every((c) => own.has(c))) { + f.detail = `${/** @type {Record} */ (ORIGIN)[f.category]}: ${f.detail}`; + f.category = "own-class"; + } + delete f.classes; + } return findings; } @@ -301,6 +378,18 @@ export const CATEGORIES = { title: "Custom properties that collide with Astryx tokens", help: "Astryx declares the same name. Whichever rule wins the cascade now restyles both your CSS and Astryx's components. Rename yours, or set it through a theme (`defineTheme`) on purpose.", }, + theme: { + title: "0.1 theme switches and theme selectors", + help: '0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match.', + }, + "own-class": { + title: "0.1 class names your own CSS also defines (lower confidence)", + help: "The same names as above, but your own stylesheets define each of them as a rule of its own (`.tabs__tab { … }`), so they most likely belong to markup you render, not to ui-common's. Skim them; most need nothing.", + }, + "local-wrapper": { + title: "Local wrappers around 0.1 components", + help: "Your own component renders a 0.1 component and hands its props on, so it now renders the Astryx one. Its call sites pass the wrapper's props, which the upgrade does not rewrite: check the wrapper's props type and what it passes on against the Astryx component. A pure re-export (`export { Button } from …`) is not listed: its call sites were migrated.", + }, "stylesheet-path": { title: "0.1 stylesheet paths left in place", help: "Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them.", diff --git a/codemods/0.2/stylesheets.mjs b/packages/cli/codemods/0.2/stylesheets.mjs similarity index 60% rename from codemods/0.2/stylesheets.mjs rename to packages/cli/codemods/0.2/stylesheets.mjs index 8af972c..da8f0d2 100644 --- a/codemods/0.2/stylesheets.mjs +++ b/packages/cli/codemods/0.2/stylesheets.mjs @@ -13,13 +13,14 @@ * - `styles/themes/orange-{light,dark}.css` imports are dropped: the Lablup * theme covers both colour schemes. */ -import { basename, dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import postcss from "postcss"; import { TODO_TAG } from "../lib/jsx.mjs"; import { addTodo, printSource } from "../lib/todo.mjs"; -import { LAB_CSS, LAB_PACKAGE, STYLESHEETS } from "./map.mjs"; +import { LAB_CSS, LAB_PACKAGE, STYLESHEETS, UIC } from "./map.mjs"; const { base, layerOrder, imports: replacement, entryFile } = STYLESHEETS; const DROPPED = STYLESHEETS.dropped; @@ -236,6 +237,71 @@ export function transformStylesheet(file, _api, ctx) { return transformPreprocessed(file.source, file.path, ctx); } +const STYLESHEET = /\.(css|scss|sass|less)(\?.*)?$/; + +/** + * Whether an import has to come after the stylesheet entry: a stylesheet + * (the entry's `@layer` statement must be the first one the page sees), a + * @lablup/ui-common module (its components' styles), or a module of the + * project's own, which loads both. + * + * @param {string} source + */ +function loadsStyles(source) { + return ( + STYLESHEET.test(source) || + source === UIC || + source.startsWith(`${UIC}/`) || + source.startsWith(".") || + source.startsWith("/") + ); +} + +/** + * Put the import of `specifier` before every import that loads styles, or + * add it there: after the last import that does not, else first. Vite + * injects stylesheets in import order, and the `@layer` order statement only + * holds if it comes first. + * + * @param {any} j + * @param {any} root + * @param {string} specifier + * @returns {boolean} whether anything moved or was added + */ +export function placeEntryImport(j, root, specifier) { + const program = root.find(j.Program).get().node; + const body = program.body; + const isImport = (/** @type {any} */ n) => n.type === "ImportDeclaration"; + const existing = body.find( + (/** @type {any} */ n) => isImport(n) && n.source.value === specifier, + ); + const first = body.find( + (/** @type {any} */ n) => + isImport(n) && n !== existing && loadsStyles(String(n.source.value)), + ); + if (existing) { + if (!first || body.indexOf(existing) < body.indexOf(first)) return false; + body.splice(body.indexOf(existing), 1); + } + const decl = existing ?? j.importDeclaration([], j.stringLiteral(specifier)); + if (first) { + const at = body.indexOf(first); + // A header comment on the first import stays on top. + if (at === 0 && first.comments?.length) { + decl.comments = [...first.comments, ...(decl.comments ?? [])]; + first.comments = []; + } + body.splice(at, 0, decl); + return true; + } + let last = -1; + body.forEach((/** @type {any} */ n, /** @type {number} */ i) => { + if (isImport(n)) last = i; + }); + body.splice(last + 1, 0, decl); + return true; +} + export const jsMeta = { id: "script-stylesheet-imports", title: @@ -256,6 +322,8 @@ export function transformScriptImports(file, api, ctx) { const entrySpecifier = `./${entryFile}`; const alreadyImportsEntry = root.find(j.ImportDeclaration, { source: { value: entrySpecifier } }).size() > 0; + /** @type {string[]} */ + const entryImports = []; root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source.value; @@ -281,7 +349,9 @@ export function transformScriptImports(file, api, ctx) { // which is a numbered sibling when the project has its own entry there. const entry = ctx.createFile(join(dirname(file.path), entryFile), entryCss(ctx)); path.node.source = j.stringLiteral(`./${basename(entry)}`); + entryImports.push(`./${basename(entry)}`); }); + for (const specifier of entryImports) placeEntryImport(j, root, specifier); root.find(j.CallExpression).forEach((/** @type {any} */ path) => { const node = path.node; @@ -303,3 +373,107 @@ export function transformScriptImports(file, api, ctx) { }); return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; } + +/** What loading the 0.2 stylesheets looks like, in a script or a stylesheet. */ +const WIRED = new RegExp( + [...replacement, `${UIC}/styles/`, entryFile.replace(/\.css$/, "")] + .map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")) + .join("|"), +); + +/** + * The app's entry script: the module script `index.html` loads, else the + * package's `main`, else the usual `src/main.*` / `src/index.*`. + * + * @param {string} projectDir + * @param {any} pkg + * @returns {{file: string, how: string} | null} + */ +export function findEntryScript(projectDir, pkg) { + const html = join(projectDir, "index.html"); + if (existsSync(html)) { + const text = readFileSync(html, "utf8"); + for (const tag of text.matchAll(/]*>/gi)) { + const src = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag[0])?.[1]; + if (!src || /^[a-z]+:|^\/\//i.test(src)) continue; + if (!/type\s*=\s*["']module["']/i.test(tag[0]) && !/\.[cm]?[jt]sx?$/.test(src)) + continue; + const file = src.startsWith("/") + ? join(projectDir, src.replace(/^\/+/, "")) + : resolve(projectDir, src); + if (existsSync(file)) return { file, how: "the module script index.html loads" }; + } + } + const main = typeof pkg?.main === "string" ? resolve(projectDir, pkg.main) : null; + if ( + main && + /\.[cm]?[jt]sx?$/.test(main) && + existsSync(main) && + !relative(projectDir, main) + .split(sep) + .some((d) => ["dist", "build", "out"].includes(d)) + ) + return { file: main, how: "package.json main" }; + for (const name of ["main", "index"]) { + for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { + const file = join(projectDir, "src", `${name}${ext}`); + if (existsSync(file)) return { file, how: `the conventional entry` }; + } + } + return null; +} + +/** + * A 0.1 app that never imported styles/base.css relied on each component + * loading its own CSS; 0.2 components load none, so after the upgrade + * nothing would load Astryx's stylesheets or the theme. When no file of the + * project loads them, write the same ui-common-entry.css the base.css + * rewrite writes beside the app's entry script and import it there, first. + * With no entry to be found, the report opens with the manual step. + * Libraries are left alone: the app that uses them loads the stylesheets. + * + * @param {any} ctx + * @param {{jscodeshift: any}} api + */ +export function wireStylesheets(ctx, api) { + const pkg = ctx.pkg ?? {}; + if (pkg.peerDependencies?.[UIC] != null) return; + const declared = ["dependencies", "devDependencies"].some( + (f) => pkg[f]?.[UIC] != null, + ); + if (!declared) return; + for (const file of ctx.projectFiles()) { + const text = ctx.current(file); + if (text && WIRED.test(text)) return; + } + const rel = (/** @type {string} */ f) => + relative(ctx.projectDir, f).split(sep).join("/"); + const entry = findEntryScript(ctx.projectDir, pkg); + if (!entry) { + ctx.alert( + `**Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json \`main\`, or \`src/main.*\` / \`src/index.*\`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: \`${[layerOrder, ...replacement.map((r) => `@import "${r}";`)].join(" ")}\``, + ); + return; + } + const j = api.jscodeshift.withParser( + /\.[cm]?tsx?$/.test(entry.file) ? "tsx" : "babel", + ); + const cssFile = ctx.createFile(join(dirname(entry.file), entryFile), entryCss(ctx)); + const specifier = `./${basename(cssFile)}`; + ctx.editFile( + entry.file, + (/** @type {string | null} */ current) => { + if (current == null) return undefined; + const root = j(current); + if (!placeEntryImport(j, root, specifier)) return undefined; + const out = root.toSource({ + quote: current.includes("from '") ? "single" : "double", + }); + return current.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; + }, + "stylesheet-entry", + ); + ctx.notice( + `No file loaded @lablup/ui-common's stylesheets (0.1 components loaded their own CSS; 0.2's load none), so the upgrade wrote ${rel(cssFile)} and imported it first in ${rel(entry.file)} (${entry.how}). Move the import if your app loads its stylesheets elsewhere.`, + ); +} diff --git a/packages/cli/codemods/0.2/theme.mjs b/packages/cli/codemods/0.2/theme.mjs new file mode 100644 index 0000000..82b3585 --- /dev/null +++ b/packages/cli/codemods/0.2/theme.mjs @@ -0,0 +1,325 @@ +/** + * 0.1 -> 0.2 theming. 0.1 themed the page by stylesheet and attribute + * (`styles/themes/orange-dark.css`, `html[data-theme="orange-dark"]`); 0.2 + * themes it with ``, which owns + * `html[data-theme]` and sets it to `light` or `dark` itself. + * + * - scanTheme reports what fights that: scripts that write `data-theme`, + * 0.1 theme names, and selectors on any other `data-theme` value. + * - ensureTheme wraps the app's root render in `` when no module uses + * one and the root render is unambiguous (`createRoot(…).render()`); + * otherwise the report opens with the step. + */ +import { relative, sep } from "node:path"; + +import selectorParser from "postcss-selector-parser"; + +import { UIC } from "./map.mjs"; +import { isTestFile } from "./scan.mjs"; +import { findEntryScript } from "./stylesheets.mjs"; + +const MODES = new Set(["light", "dark"]); +export const THEME_IMPORT = { name: "Theme", source: UIC }; +export const LABLUP_THEME_IMPORT = { + name: "lablupTheme", + source: `${UIC}/theme/lablup/built`, +}; + +const SCRIPT_ADVICE = + '`` sets `html[data-theme]` to "light" or "dark" itself: pass the mode as `` instead of writing the attribute.'; +const SELECTOR_ADVICE = + '`` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme.'; + +/** @param {string} text @param {number} index */ +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/** + * @param {string} file relative path + * @param {string} source + * @returns {Array<{category: string, file: string, line: number, text: string, detail: string}>} + */ +export function scanTheme(file, source) { + const findings = []; + const lines = source.split("\n"); + /** @param {number} index @param {string} detail */ + const add = (index, detail) => { + const line = lineAt(source, index); + if (findings.some((f) => f.line === line)) return; + findings.push({ + category: "theme", + file, + line, + text: (lines[line - 1] ?? "").trim(), + detail, + }); + }; + + if (/\.(css|scss|sass|less)$/.test(file)) { + // Comments out, keeping offsets, so a line number still points at the rule. + const code = source + .replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " ")) + .replace(/(^|[^:])\/\/[^\n]*/g, (c, head) => + file.endsWith(".css") ? c : `${head}${" ".repeat(c.length - head.length)}`, + ); + // One finding per value and file: a theme sheet repeats its selector on + // every rule. + /** @type {Map} */ + const seen = new Map(); + for (const m of code.matchAll( + /\[\s*data-theme\s*([~|^$*]?=)\s*(["']?)([^\]"']*)\2\s*\]/g, + )) { + if (m[1] === "=" && MODES.has(m[3])) continue; + let ok = false; + try { + selectorParser((s) => { + s.walkAttributes((a) => { + if (a.attribute === "data-theme") ok = true; + }); + }).processSync(m[0]); + } catch { + ok = true; + } + if (!ok) continue; + const key = `${m[1]}${m[3]}`; + const known = seen.get(key); + if (known) known.count++; + else + seen.set(key, { + index: m.index ?? 0, + count: 1, + detail: `data-theme ${m[1]} "${m[3]}"`, + }); + } + for (const { index, count, detail } of seen.values()) { + add( + index, + `${detail}${count > 1 ? ` (${count} selectors in this file)` : ""}: ${SELECTOR_ADVICE}`, + ); + } + return findings; + } + + const writes = [ + /\bdataset\s*(?:\.\s*theme|\[\s*["'`]theme["'`]\s*\])\s*=(?!=)\s*([^;\n]*)/g, + /\bsetAttribute\(\s*["'`]data-theme["'`]\s*,\s*([^)\n]*)/g, + ]; + for (const pattern of writes) { + for (const m of source.matchAll(pattern)) { + const value = m[1].trim(); + const literal = /^(["'`])([\w-]*)\1$/.exec(value); + if (literal && MODES.has(literal[2])) continue; + add( + m.index ?? 0, + `writes data-theme ${literal ? `"${literal[2]}"` : "from an expression"}. ${SCRIPT_ADVICE}`, + ); + } + } + for (const m of source.matchAll(/(["'`])(orange-(?:light|dark))\1/g)) { + add(m.index ?? 0, `0.1 theme name "${m[2]}". ${SCRIPT_ADVICE}`); + } + return findings; +} + +/** + * Whether a module imports `Theme` from ui-common (or Astryx). + * + * @param {string} source + */ +function usesTheme(source) { + return /import\s*\{[^}]*\bTheme\b[^}]*\}\s*from\s*["'](@lablup\/ui-common|@astryxdesign\/core)[^"']*["']/.test( + source, + ); +} + +/** + * The `root.render()` calls of a module: `createRoot(el).render(…)`, + * `ReactDOM.createRoot(el).render(…)`, or `.render(…)` on a variable + * initialised with one of those. + * + * @param {any} j + * @param {any} root + */ +function rootRenders(j, root) { + /** @param {any} node */ + const isCreateRoot = (node) => { + if (node?.type !== "CallExpression") return false; + const callee = node.callee; + const name = + callee.type === "Identifier" + ? callee.name + : callee.type === "MemberExpression" && callee.property.type === "Identifier" + ? callee.property.name + : null; + return name === "createRoot"; + }; + const roots = new Set(); + root.find(j.VariableDeclarator).forEach((/** @type {any} */ p) => { + if (p.node.id.type === "Identifier" && isCreateRoot(p.node.init)) + roots.add(p.node.id.name); + }); + const calls = []; + root.find(j.CallExpression).forEach((/** @type {any} */ p) => { + const callee = p.node.callee; + if ( + callee.type !== "MemberExpression" || + callee.property.type !== "Identifier" || + callee.property.name !== "render" + ) + return; + const object = callee.object; + if ( + isCreateRoot(object) || + (object.type === "Identifier" && roots.has(object.name)) + ) + calls.push(p); + }); + return calls; +} + +/** + * @param {any} el + */ +function isStrictMode(el) { + const name = el.openingElement.name; + return ( + (name.type === "JSXIdentifier" && name.name === "StrictMode") || + (name.type === "JSXMemberExpression" && name.property.name === "StrictMode") + ); +} + +/** + * Wrap the app's root render in ``, once, when + * no module of the project uses `` and exactly one module renders a + * root with a JSX element. Anything less clear-cut goes to the report. + * + * @param {any} ctx + * @param {{jscodeshift: any}} api + */ +export function ensureTheme(ctx, api) { + const pkg = ctx.pkg ?? {}; + if (pkg.peerDependencies?.[UIC] != null) return; + if (!["dependencies", "devDependencies"].some((f) => pkg[f]?.[UIC] != null)) return; + const rel = (/** @type {string} */ f) => + relative(ctx.projectDir, f).split(sep).join("/"); + // Tests render roots of their own; they are not the app's. + const scripts = ctx + .projectFiles() + .filter( + (/** @type {string} */ f) => /\.[cm]?[jt]sx?$/.test(f) && !isTestFile(rel(f)), + ); + /** @type {Array<{file: string, text: string}>} */ + const candidates = []; + for (const file of scripts) { + const text = ctx.current(file); + if (!text) continue; + if (usesTheme(text)) return; + if (/\bcreateRoot\b/.test(text) && /\.render\s*\(/.test(text)) + candidates.push({ file, text }); + } + const how = `\`\` (\`import { Theme } from "${THEME_IMPORT.source}"\`, \`import { lablupTheme } from "${LABLUP_THEME_IMPORT.source}"\`)`; + const manual = (/** @type {string} */ why) => + ctx.alert( + `**Wrap the app in ${how}.** ${why} Without it Astryx components get no theme; pass \`mode\` ("light" | "dark" | "system", the default) where the app switches colour schemes.`, + ); + // Several roots (a second page, a verification harness): the one the + // entry index.html loads is the app's. + const entry = findEntryScript(ctx.projectDir, pkg)?.file; + /** @type {string[]} */ + const others = []; + if (candidates.length > 1 && entry && candidates.some((c) => c.file === entry)) { + others.push(...candidates.filter((c) => c.file !== entry).map((c) => rel(c.file))); + candidates.splice( + 0, + candidates.length, + ...candidates.filter((c) => c.file === entry), + ); + } + if (candidates.length !== 1) { + manual( + candidates.length === 0 + ? "No module uses ``, and the upgrade found no `createRoot(…).render(…)` to wrap." + : `No module uses \`\`, and ${candidates.length} modules render a root (${candidates.map((c) => rel(c.file)).join(", ")}), so the upgrade did not pick one.`, + ); + return; + } + const [{ file, text }] = candidates; + const j = api.jscodeshift.withParser(/\.[cm]?tsx?$/.test(file) ? "tsx" : "babel"); + const root = j(text); + const renders = rootRenders(j, root); + const arg = renders.length === 1 ? renders[0].node.arguments[0] : null; + if (!arg || arg.type !== "JSXElement") { + manual( + `${rel(file)} renders a root, but not as a single \`render()\` the upgrade can wrap safely.`, + ); + return; + } + const bound = new Set(); + root.find(j.Identifier).forEach((/** @type {any} */ p) => bound.add(p.node.name)); + root.find(j.JSXIdentifier).forEach((/** @type {any} */ p) => bound.add(p.node.name)); + if (bound.has(THEME_IMPORT.name) || bound.has(LABLUP_THEME_IMPORT.name)) { + manual( + `${rel(file)} already uses the name Theme or lablupTheme for something else.`, + ); + return; + } + + // Edit the text at the parsed positions, so the rest of the file keeps + // its formatting. + const q = text.includes("from '") && !text.includes('from "') ? "'" : '"'; + /** @type {Array<[number, number, string]>} */ + const edits = []; + /** @param {number} at */ + const indentAt = (at) => + /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", at - 1) + 1))?.[0] ?? ""; + /** @param {string} block @param {string} pad */ + const indent = (block, pad) => + block + .split("\n") + .map((l) => (l.trim() === "" ? l : `${pad}${l}`)) + .join("\n"); + if (isStrictMode(arg) && arg.closingElement) { + const open = arg.openingElement.end; + const close = arg.closingElement.start; + const inner = text.slice(open, close); + const first = inner.split("\n").find((l) => l.trim() !== "") ?? ""; + const pad = /^[ \t]*/.exec(first)?.[0] ?? ""; + const body = inner + .trim() + .split("\n") + .map((l, i) => (i === 0 ? l : l.replace(new RegExp(`^${pad}`), ""))) + .join("\n"); + edits.push([ + open, + close, + `\n${pad}\n${indent(body, `${pad} `)}\n${pad}\n${indentAt(close)}`, + ]); + } else { + const pad = indentAt(arg.start); + const body = text.slice(arg.start, arg.end); + edits.push([ + arg.start, + arg.end, + `\n${indent(body, `${pad} `)}\n${pad}`, + ]); + } + const imports = root.find(j.ImportDeclaration).nodes(); + const after = imports.length > 0 ? imports[imports.length - 1].end : 0; + const lines = [ + `import { ${THEME_IMPORT.name} } from ${q}${THEME_IMPORT.source}${q};`, + `import { ${LABLUP_THEME_IMPORT.name} } from ${q}${LABLUP_THEME_IMPORT.source}${q};`, + ]; + edits.push([ + after, + after, + after === 0 ? `${lines.join("\n")}\n` : `\n${lines.join("\n")}`, + ]); + let out = text; + for (const [from, to, insert] of edits.sort((a, b) => b[0] - a[0])) + out = `${out.slice(0, from)}${insert}${out.slice(to)}`; + ctx.editFile(file, () => out, "theme"); + ctx.notice( + `No module used \`\`, so the upgrade wrapped the root render in ${rel(file)}${entry === file ? "" : " (not the entry index.html loads: check it is the app's root)"} in \`\`.${others.length > 0 ? ` ${others.join(", ")} also render${others.length === 1 ? "s" : ""} a root; wrap ${others.length === 1 ? "it" : "them"} too if ${others.length === 1 ? "it renders" : "they render"} ui-common components.` : ""} Its mode defaults to "system"; pass \`mode="light" | "dark"\` where the app switches colour schemes (see "0.1 theme switches and theme selectors").`, + ); +} diff --git a/codemods/lib/jsx.mjs b/packages/cli/codemods/lib/jsx.mjs similarity index 100% rename from codemods/lib/jsx.mjs rename to packages/cli/codemods/lib/jsx.mjs diff --git a/codemods/lib/todo.mjs b/packages/cli/codemods/lib/todo.mjs similarity index 100% rename from codemods/lib/todo.mjs rename to packages/cli/codemods/lib/todo.mjs diff --git a/codemods/registry.mjs b/packages/cli/codemods/registry.mjs similarity index 83% rename from codemods/registry.mjs rename to packages/cli/codemods/registry.mjs index b07f73d..dbaa72c 100644 --- a/codemods/registry.mjs +++ b/packages/cli/codemods/registry.mjs @@ -27,8 +27,11 @@ import { compare, parse } from "../cli/semver.mjs"; * @typedef {object} Step * @property {string} title * @property {Transform[]} transforms + * @property {(ctx: any, api: {jscodeshift: any}) => void} [afterTransforms] project-level edits once every file is transformed * @property {(text: string, ctx: any) => string | undefined} [packageJson] - * @property {(file: string, source: string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan] + * @property {(files: Array<[string, string]>) => any} [prepareScan] a context for `scan`, from every file it will read ([relative path, content]) + * @property {(file: string, source: string, context?: any) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan] + * @property {(ctx: any, rel: (file: string) => string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [findings] report findings the transforms collected, after they ran * @property {Record} [categories] * @property {string[] | ((ctx: any) => string[])} [notes] report notes, or a function of the run */ diff --git a/codemods/upstream.mjs b/packages/cli/codemods/upstream.mjs similarity index 100% rename from codemods/upstream.mjs rename to packages/cli/codemods/upstream.mjs diff --git a/migration/0.1-to-0.2.json b/packages/cli/migration/0.1-to-0.2.json similarity index 100% rename from migration/0.1-to-0.2.json rename to packages/cli/migration/0.1-to-0.2.json diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..b14d471 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@lablup/ui-common-cli", + "version": "0.2.0-alpha.14", + "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", + "license": "Apache-2.0", + "author": "Lablup Inc.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lablup/ui-common.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/lablup/ui-common#the-ui-common-cli", + "bugs": { + "url": "https://github.com/lablup/ui-common/issues" + }, + "publishConfig": { + "access": "public", + "provenance": false + }, + "engines": { + "node": ">=22.13.0" + }, + "bin": { + "ui-common": "./bin/ui-common.mjs" + }, + "files": [ + "bin", + "cli", + "codemods", + "migration", + "README.md", + "NOTICE" + ], + "exports": { + "./package.json": "./package.json" + }, + "dependencies": { + "@astryxdesign/cli": "0.6.2", + "jscodeshift": "^17.4.0", + "postcss": "^8.5.25", + "postcss-selector-parser": "^7.1.6" + }, + "peerDependencies": { + "@lablup/ui-common": "workspace:*" + }, + "devDependencies": { + "@lablup/ui-common": "workspace:*" + } +} diff --git a/scripts/extract-legacy-classes.mjs b/packages/cli/scripts/extract-legacy-classes.mjs similarity index 94% rename from scripts/extract-legacy-classes.mjs rename to packages/cli/scripts/extract-legacy-classes.mjs index 0665c28..fe812e2 100644 --- a/scripts/extract-legacy-classes.mjs +++ b/packages/cli/scripts/extract-legacy-classes.mjs @@ -7,7 +7,7 @@ * checkout of the last 0.1 release: * * git archive v0.1.0-alpha.23 src | tar -x -C /tmp/uc-0.1 - * node scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 + * node packages/cli/scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 * * `ui-common upgrade` reports consumer selectors, DOM queries and tests that * target these names. Astryx does not render them. @@ -26,6 +26,7 @@ if (!checkout) { process.exit(2); } +// The CLI package root (packages/cli). const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const files = globSync("src/components/**/*.css", { cwd: checkout, absolute: true }); diff --git a/test/cli/cli.test.ts b/packages/cli/test/cli/cli.test.ts similarity index 97% rename from test/cli/cli.test.ts rename to packages/cli/test/cli/cli.test.ts index 5376dc0..53f259a 100644 --- a/test/cli/cli.test.ts +++ b/packages/cli/test/cli/cli.test.ts @@ -36,8 +36,11 @@ import { import { registeredVersions, stepsBetween } from "../../codemods/registry.mjs"; import { toAstryxSpecifiers, upstreamStep } from "../../codemods/upstream.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const bin = join(root, "bin/ui-common.mjs"); +// The CLI package (packages/cli) and the repository root, which is the +// @lablup/ui-common package the CLI works for. +const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const root = resolve(cliRoot, "../.."); +const bin = join(cliRoot, "bin/ui-common.mjs"); const temps: string[] = []; afterEach(() => { @@ -177,6 +180,9 @@ describe("output rewriting", { timeout: 60_000 }, () => { it("prints its version and help", () => { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const cliPkg = JSON.parse(readFileSync(join(cliRoot, "package.json"), "utf8")); + // Lockstep: the CLI is released at the version of the ui-common it serves. + expect(cliPkg.version).toBe(pkg.version); expect(run(["--version"]).stdout.trim()).toBe(pkg.version); const help = run(["--help"]); expect(help.code).toBe(0); diff --git a/test/cli/lab-peer.test.ts b/packages/cli/test/cli/lab-peer.test.ts similarity index 96% rename from test/cli/lab-peer.test.ts rename to packages/cli/test/cli/lab-peer.test.ts index 9fbf458..5c7940c 100644 --- a/test/cli/lab-peer.test.ts +++ b/packages/cli/test/cli/lab-peer.test.ts @@ -18,12 +18,13 @@ import { labOverrideProblems, syncLabOverrideDocs, } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; +import { uiCommonPackageJson } from "../../cli/paths.mjs"; import { labOverrideEdits } from "../../cli/sync-astryx.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +// The repository root, where README and the ui-common package.json live. +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const readme = readFileSync(join(root, "README.md"), "utf8"); -const corePin = ownPackageJson().dependencies["@astryxdesign/core"] as string; +const corePin = uiCommonPackageJson().dependencies?.["@astryxdesign/core"] as string; describe("the lab core override", () => { it("is documented in README at the core pin whenever lab's core peer differs", () => { diff --git a/test/upgrade/codemods.test.ts b/packages/cli/test/upgrade/codemods.test.ts similarity index 76% rename from test/upgrade/codemods.test.ts rename to packages/cli/test/upgrade/codemods.test.ts index 7d9d0b7..1e0e7e1 100644 --- a/test/upgrade/codemods.test.ts +++ b/packages/cli/test/upgrade/codemods.test.ts @@ -8,6 +8,7 @@ import jscodeshift from "jscodeshift"; import { describe, expect, it, vi } from "vitest"; import transformComponents from "../../codemods/0.2/components.mjs"; +import { narrowReactRange } from "../../codemods/0.2/package-json.mjs"; const j = jscodeshift.withParser("tsx"); @@ -180,6 +181,82 @@ export function D({ StatusTag }: { StatusTag: any }) { }); }); +describe("removed types that a module re-exports", () => { + it("drops the local re-export with the import, so the file still parses", () => { + const out = upgrade( + `import { DataTable as BaseDataTable } from "@lablup/ui-common/components/DataTable"; +import type { + DataTableProps as BaseProps, + DataTablePersistedState, + SortDirection, +} from "@lablup/ui-common/components/DataTable"; + +export type { DataTablePersistedState, SortDirection }; + +export function DataTable(props: BaseProps) { + return ; +} +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).not.toMatch(/export type \{[^}]*DataTablePersistedState/); + expect(out).toContain( + "// TODO(ui-common-upgrade): DataTablePersistedState, SortDirection (removed with DataTable in 0.2, no Astryx counterpart) are no longer re-exported from here;", + ); + // The TODO lands above the statement that followed the re-export. + expect(out).toMatch(/no longer re-exported[^\n]*\nexport function DataTable/); + }); + + it("keeps the other names of a mixed re-export, and an alias", () => { + const out = upgrade( + `import type { StatusKind } from "@lablup/ui-common/components/StatusTag"; +type Local = string; +export type { Local, StatusKind as Kind }; +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).toContain("export type { Local };"); + expect(out).toContain( + "TODO(ui-common-upgrade): Kind (removed with StatusTag in 0.2, no Astryx counterpart) is no longer re-exported from here", + ); + }); + + it("leaves a re-export of a local type with the same name alone", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export type SortDirection = "asc" | "desc"; +export const B = () => x; +`, + ); + expect(out).toContain('export type SortDirection = "asc" | "desc";'); + }); +}); + +describe("React peers of a library", () => { + it.each([ + ["^18.2.0 || ^19.0.0", "^19.2.0"], + [">=18 <21 || ^22", ">=19.2.0 <21 || ^22"], + ["^19.0.0", "^19.2.0"], + ["*", ">=19.2.0"], + [">=19.3", ">=19.3"], + ["^18 || ^19.1 || ^20", "^19.2.0 || ^20"], + [">=18.2.0 <20", "^19.2.0"], + ["18 - 20", "19.2.0 - 20"], + ["^19.2.0", "^19.2.0"], + ["workspace:*", "workspace:*"], + ["latest", "latest"], + ])("%s -> %s", (range, expected) => { + expect(narrowReactRange(range, "19.2.0")).toBe(expected); + }); + + it.each(["^17", "~19.1", ">=18 <=19.1"])( + "%s admits no React 19.2: reported, not emptied", + (range) => { + expect(narrowReactRange(range, "19.2.0")).toBeNull(); + }, + ); +}); + /** The output with each JSX TODO marker's message elided. */ const todos = (out: string) => out.replace(/\{\/\* TODO\(ui-common-upgrade\): [^*]*\*\/\}/g, "{/* TODO */}"); diff --git a/test/upgrade/fixtures/adapter/expected/package.json b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json similarity index 76% rename from test/upgrade/fixtures/adapter/expected/package.json rename to packages/cli/test/upgrade/fixtures/adapter/expected/package.json index b438c73..31640bd 100644 --- a/test/upgrade/fixtures/adapter/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json @@ -7,5 +7,8 @@ "@lablup/ui-common": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css diff --git a/test/upgrade/fixtures/adapter/expected/src/index.scss b/packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/index.scss rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss diff --git a/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx new file mode 100644 index 0000000..a11c0c5 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import "./ui-common-entry.css"; +import "./design-system/common-components.css"; +import { App } from "./App"; +import { Theme } from "@lablup/ui-common"; +import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"; + +createRoot(document.getElementById("root")!).render( + +); diff --git a/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md similarity index 88% rename from test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index 5cf1f13..c6ddbdc 100644 --- a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -1,8 +1,8 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). -Scanned 4 files under `src`. +Ran the codemods over 4 files under `src`; searched 4 files under the project root for manual-review findings. ## Summary @@ -22,12 +22,13 @@ Scanned 4 files under `src`. - `src/design-system/common-adapters.tsx`: +39 −23, components - `src/design-system/common-components.css`: +0 −2, stylesheet-entry - `src/index.scss`: +8 −1, stylesheet-entry -- `src/main.tsx`: +1 −1, script-stylesheet-imports +- `src/main.tsx`: +6 −2, script-stylesheet-imports, theme - `src/ui-common-entry.css` (new): +15 −0, stylesheet-entry ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.19" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. - no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. @@ -74,12 +75,25 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both |---|---|---| | `src/design-system/common-components.css:6` | `:root { --color-error: #d4380d }` | --color-error | +### 0.1 theme switches and theme selectors (0) + +None. + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. ## Notes +- No module used ``, so the upgrade wrapped the root render in src/main.tsx in ``. Its mode defaults to "system"; pass `mode="light" | "dark"` where the app switches colour schemes (see "0.1 theme switches and theme selectors"). - Button → Button (@lablup/ui-common/Button); or IconButton (@lablup/ui-common/IconButton) when iconOnly is set; the accessible name moves from ariaLabel to label. `label` is required. A non-string child needs `label` for the accessible name and the node as children. variant="success" has no Button variant; use primary. iconPosition="right" becomes `endContent` (an Icon or Badge element only). shape="circle", inline and active have no counterpart. The .button / .button--* classes are gone; Astryx's stable class is .astryx-button. - Drawer → Drawer (@lablup/ui-common/lab). lab Drawer renders no header: render the title, subtitle and footer inside children. closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart. preventDismiss and onDismissAttempt: decline the close in onOpenChange. The .drawer classes are gone. - EmptyState → EmptyState (@lablup/ui-common/EmptyState). primaryAction and secondaryAction become `actions`, a node: + + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json new file mode 100644 index 0000000..542a54c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json @@ -0,0 +1,10 @@ +{ + // Feature code imports shared UI as "@/components/common". + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + }, + }, + "include": ["src"], +} diff --git a/test/upgrade/fixtures/root-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json similarity index 75% rename from test/upgrade/fixtures/root-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json index b672d4e..f6611e0 100644 --- a/test/upgrade/fixtures/root-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json @@ -7,5 +7,8 @@ "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0", "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css diff --git a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md similarity index 76% rename from test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md index 608a87b..bcb56c5 100644 --- a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -1,8 +1,13 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). -Scanned 4 files under `src`. +Ran the codemods over 4 files under `src`; searched 4 files under the project root for manual-review findings. + +## Action required + +- **Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json `main`, or `src/main.*` / `src/index.*`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: `@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @import "@lablup/ui-common/reset.css"; @import "@lablup/ui-common/astryx.css"; @import "@lablup/ui-common/theme/lablup/theme.css"; @import "@lablup/ui-common/ui-common.css"; @import "@lablup/ui-common/legacy-tokens.css";` +- **Wrap the app in `` (`import { Theme } from "@lablup/ui-common"`, `import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"`).** No module uses ``, and the upgrade found no `createRoot(…).render(…)` to wrap. Without it Astryx components get no theme; pass `mode` ("light" | "dark" | "system", the default) where the app switches colour schemes. ## Summary @@ -11,7 +16,7 @@ Scanned 4 files under `src`. | Files changed | 1 | | package.json changed | yes | | TODO markers left in code | 10 | -| Manual-review findings | 9 | +| Manual-review findings | 10 | ## Steps @@ -24,6 +29,7 @@ Scanned 4 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.7" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. ## Manual review @@ -87,6 +93,22 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both | `src/themes/violet.css:3` | `[data-theme="violet-light"] { --color-border: #e0dcf5 }` | --color-border | | `src/themes/violet.css:4` | `[data-theme="violet-light"] { --color-text-primary: #1b1535 }` | --color-text-primary | +### 0.1 theme switches and theme selectors (1) + +0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match. + +| Where | What | Detail | +|---|---|---| +| `src/themes/violet.css:1` | `[data-theme="violet-light"] {` | data-theme = "violet-light" (2 selectors in this file): `` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme. | + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/test/upgrade/fixtures/root-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/input/package.json similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/input/package.json diff --git a/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json similarity index 86% rename from test/upgrade/fixtures/subpath-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json index 73f1af1..ff94cfb 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json @@ -9,6 +9,7 @@ "react": "^19.2.0" }, "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0", "vitest": "^4.0.0" } } diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx similarity index 70% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx index 2442573..c6ae01b 100644 --- a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx @@ -2,6 +2,7 @@ import { render } from "@testing-library/react"; import { Select } from "./index"; it("opens", () => { + // TODO(ui-common-upgrade): `label` is required and is a string; a node label needs a string for the accessible name. const { container } = render(