diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..0f7f1b5 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +This folder contains release notes generated by `@changesets/cli`. + +Run `pnpm changeset` when a PR changes the published `react-use-hook-kit` package. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..624a253 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "p-iknow/react-hook-kit" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.changeset/initial-publication.md b/.changeset/initial-publication.md new file mode 100644 index 0000000..ac68882 --- /dev/null +++ b/.changeset/initial-publication.md @@ -0,0 +1,4 @@ +--- +--- + +Published the package as `react-use-hook-kit` and updated the workspace, docs, and CI release checks to use the new npm package name. The CI workflow now syncs injected workspace dependencies after building so React compatibility fixtures can resolve the freshly built package on clean installs. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..df24003 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @p-iknow diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md index d31312c..8ca68c7 100644 --- a/.github/ISSUE_TEMPLATE/bug.md +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -1,6 +1,6 @@ --- name: Bug report -about: Report a bug in the template +about: Report a bug in react-use-hook-kit labels: bug --- @@ -27,4 +27,6 @@ labels: bug - Node version: - pnpm version: - OS: -- Framework overlay (none/tanstack-start/next): +- React version: +- react-use-hook-kit version: +- Runtime/framework: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 09193a7..88fb2e2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,16 +1,37 @@ -## Summary +## Description - + + +**Related Issue:** Fixes # + +## Changes + + + +## Motivation and Context + + ## Test Plan + + - [ ] Unit tests pass (`pnpm test`) - [ ] Type checks pass (`pnpm typecheck`) -- [ ] Lint passes (`pnpm lint`) -- [ ] Format check passes (`pnpm format:check`) -- [ ] Template CI matrix passes (`both-apps`, `tanstack-only`, `next-only`) +- [ ] Package build passes (`pnpm --filter react-use-hook-kit run build`) +- [ ] Package publish checks pass (`pnpm --filter react-use-hook-kit run test:attw`, `pnpm --filter react-use-hook-kit run test:publint`) + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update +- [ ] Maintenance ## Checklist -- [ ] Tokens remain as `repo`, `{{app-name}}`, `sample` (not resolved) +- [ ] I have performed a self-review. +- [ ] I have added or updated tests where needed. +- [ ] I have added a changeset for published package changes (`pnpm changeset`). - [ ] No new dependencies added without updating `pnpm-workspace.yaml` catalog diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml new file mode 100644 index 0000000..cc5839b --- /dev/null +++ b/.github/workflows/changeset-check.yml @@ -0,0 +1,39 @@ +name: Changeset Check + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + changeset-check: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for changeset + run: | + CHANGED=$(git diff --name-only origin/main...HEAD) + + PACKAGE_CHANGED=$(echo "$CHANGED" | grep -Ec '^(packages/react-hook-kit/src/|packages/react-hook-kit/package\.json|packages/react-hook-kit/tsdown\.config\.ts)' || true) + + if [ "$PACKAGE_CHANGED" -gt 0 ]; then + CHANGESET_FILES=$(echo "$CHANGED" | grep -Ec '^\.changeset/.*\.md$' || true) + + if [ "$CHANGESET_FILES" -eq 0 ]; then + echo "::error::This PR changes the published react-use-hook-kit package but does not include a changeset." + echo "" + echo "Add one with:" + echo " pnpm changeset" + exit 1 + fi + + echo "Changeset found." + else + echo "No published package changes detected." + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b22b6b7..8ba397d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,39 @@ name: CI on: push: - branches: - - main + branches: [main] pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: actions: read contents: read jobs: - main: + ci: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + command: + [ + 'pnpm lint', + 'pnpm format:check', + 'pnpm typecheck', + 'pnpm test', + 'pnpm sheriff', + 'pnpm knip', + 'pnpm --filter react-use-hook-kit run build', + 'pnpm --filter react-use-hook-kit run test:attw', + 'pnpm --filter react-use-hook-kit run test:publint', + 'pnpm --filter @repo/compat-react-17 run test', + 'pnpm --filter @repo/compat-react-18 run test', + 'pnpm --filter @repo/compat-react-19 run test', + ] steps: - uses: actions/checkout@v4 with: @@ -20,15 +42,19 @@ jobs: fetch-depth: 0 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: - node-version: 24 + node-version-file: '.nvmrc' cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: pnpm check - - run: pnpm build + - name: Build Package + run: pnpm --filter react-use-hook-kit run build + + - name: Sync injected workspace dependencies + run: pnpm install --frozen-lockfile --offline + + - name: Run command + run: ${{ matrix.command }} diff --git a/.github/workflows/publish-comment.yml b/.github/workflows/publish-comment.yml new file mode 100644 index 0000000..cd0e5c6 --- /dev/null +++ b/.github/workflows/publish-comment.yml @@ -0,0 +1,80 @@ +name: Publish by PR Comment + +on: + issue_comment: + types: + - created + +jobs: + publish: + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/publish') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + + steps: + - name: Get PR branch + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('sha', pr.data.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.ref }} + fetch-depth: 0 + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Build Package + run: pnpm --filter react-use-hook-kit run build + + - name: Set Variables + id: vars + run: | + echo "base_version=$(node -p "require('./packages/react-hook-kit/package.json').version")" >> $GITHUB_OUTPUT + + - name: Publish to npm + id: publish + run: | + VERSION=${{ steps.vars.outputs.base_version }}-pr${{ github.event.issue.number }} + echo "version=$VERSION" >> $GITHUB_OUTPUT + cd packages/react-hook-kit + npm version $VERSION --no-git-tag-version + npm publish --access public --tag pr${{ github.event.issue.number }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.publish.outputs.version }}'; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Published \`react-use-hook-kit@${version}\` to npm.\n\nInstall with:\n\`\`\`bash\npnpm add react-use-hook-kit@${version}\n\`\`\`` + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..89ed13b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Build Package + run: pnpm --filter react-use-hook-kit run build + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + title: 'chore: version packages' + commit: 'chore: version packages' + version: pnpm changeset:version + publish: pnpm changeset:publish + env: + GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create v-prefixed git tag + if: steps.changesets.outputs.published == 'true' + run: | + VERSION=$(node -p "require('./packages/react-hook-kit/package.json').version") + if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + echo "Tag v${VERSION} already exists. Skipping." + else + git tag "v${VERSION}" + git push origin "v${VERSION}" + fi diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml deleted file mode 100644 index 6403e95..0000000 --- a/.github/workflows/template-ci.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Template CI -on: - push: - branches: [main] - pull_request: - -permissions: - actions: read - contents: read - -jobs: - validate: - name: 'validate (${{ matrix.scenario }})' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - scenario: [both-apps, tanstack-only, next-only] - steps: - - uses: actions/checkout@v4 - with: - filter: tree:0 - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: 'pnpm' - - name: Remove next-sample (tanstack-only scenario) - if: matrix.scenario == 'tanstack-only' - run: | - pnpm install --no-frozen-lockfile - node scripts/remove-app.mts next-sample - - name: Remove tanstack-sample (next-only scenario) - if: matrix.scenario == 'next-only' - run: | - pnpm install --no-frozen-lockfile - node scripts/remove-app.mts tanstack-sample - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - name: Run checks - run: pnpm check - - name: Run tests - run: pnpm test - - name: Build - run: pnpm build diff --git a/.gitignore b/.gitignore index cb78985..64c0d41 100644 --- a/.gitignore +++ b/.gitignore @@ -4,21 +4,10 @@ node_modules # Build outputs dist .output +.astro tmp *.tsbuildinfo -# TanStack Start -# routeTree.gen.ts is checked in so `typecheck` works without a prior build; -# `vite dev`/`vite build` regenerates it on every run. -.tanstack -.vinxi -.nitro - -# Next.js -.next/ -next-env.d.ts -!apps/*/next-env.d.ts - # Environment & secrets .env .env.* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..248216a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.12.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b17e7b9..3fbca39 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,6 +13,8 @@ "dist", ".output", ".next", + ".astro", + "**/.astro", ".omc", "pnpm-lock.yaml", "*.md", diff --git a/.oxlintrc.json b/.oxlintrc.json index 897ce8e..8329a69 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,6 +17,12 @@ "rules": { "no-console": "off" } + }, + { + "files": ["**/*.astro"], + "rules": { + "import/no-unassigned-import": "off" + } } ], "env": { @@ -29,6 +35,8 @@ "dist", ".output", ".next", + ".astro", + "**/.astro", ".omc", "*.gen.ts", "routeTree.gen.ts" diff --git a/README.md b/README.md index 62eaa11..f83c5cb 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,76 @@ -# turborepo-pnpm-typescript-mono-template +# react-use-hook-kit -Lean Turborepo + pnpm + TypeScript monorepo with a TanStack Start app and a Next.js 16 app pre-wired, plus a shared `sample` package. Default scope: `@repo`. +Small, typed React hooks for everyday UI state. The package is built as an +ESM-first npm library with CommonJS compatibility and subpath exports for +tree-shaking. -## Quick start +## Install ```bash -git clone my-monorepo -cd my-monorepo -node scripts/init.mts # removes template-only docs/ -pnpm install -pnpm check -pnpm --filter @repo/tanstack-sample dev # port 3001 -pnpm --filter @repo/next-sample dev # port 3002 +pnpm add react-use-hook-kit ``` -## What's included +`react-use-hook-kit` has peer dependencies on `react >=17 <20` and +`react-dom >=17 <20`. -| Tool | Version | Purpose | -| --------------- | ------- | ----------------------------------------- | -| Turborepo | ^2.9 | Task runner and cache | -| pnpm workspaces | v10 | `hoist=false`, catalog protocol | -| TypeScript | 6.x | Strict, ESNext, Bundler resolution | -| Vitest | ^4.1 | Unit testing | -| oxlint | ^1.60 | Linting (drop-in ESLint replacement) | -| oxfmt | ^0.45 | Formatting (drop-in Prettier replacement) | -| Sheriff | ^0.19 | Import boundary enforcement | -| Knip | ^6.4 | Dead code / unused dep detection | -| TanStack Start | ^1.168 | `apps/tanstack-sample` (React 19 + Vite) | -| Next.js | ^16.0 | `apps/next-sample` (App Router) | +## Hooks -## Structure +```ts +import { + useBoolean, + useCounter, + useDebounce, + useLocalStorage, + useToggle, +} from 'react-use-hook-kit' +``` + +Each hook also has a subpath export: + +```ts +import { useBoolean } from 'react-use-hook-kit/use-boolean' +import { useCounter } from 'react-use-hook-kit/use-counter' +import { useDebounce } from 'react-use-hook-kit/use-debounce' +import { useLocalStorage } from 'react-use-hook-kit/use-local-storage' +import { useToggle } from 'react-use-hook-kit/use-toggle' +``` + +## Workspace ```text apps/ - tanstack-sample/ # TanStack Start app (port 3001) - next-sample/ # Next.js 16 app (port 3002) + docs/ Astro documentation app with interactive demos + compat-react-17/ React 17 compatibility fixture + compat-react-18/ React 18 compatibility fixture + compat-react-19/ React 19 compatibility fixture packages/ - sample/ # shared library, source-level export -scripts/ - init.mts # one-shot: deletes template-only docs/ - remove-app.mts # removes an app + prunes all references (accepts `all`) - remove-all.mts # wipes every app + package, resets configs to a bare shell + react-use-hook-kit/ Publishable library package ``` ## Commands ```bash +pnpm build # tsdown JS bundles + TypeScript declarations +pnpm dev # Astro docs app +pnpm test # library tests + React compatibility fixtures +pnpm typecheck # package, docs, fixtures, and scripts pnpm lint # oxlint -pnpm format # oxfmt (write) -pnpm format:check # oxfmt --check -pnpm typecheck # turbo run typecheck -pnpm test # turbo run test -pnpm sheriff # architectural boundary check -pnpm knip # unused exports / deps -pnpm check # lint + format:check + typecheck + test + sheriff + knip -pnpm build # turbo run build -pnpm dev # turbo run dev +pnpm format # oxfmt write +pnpm format:check # oxfmt check +pnpm sheriff # import boundary check +pnpm knip # unused files/dependencies check +pnpm check # full validation pipeline ``` -## Live Types - -`@repo/sample` exports `./src/index.ts` directly, so apps consume the package source without a package build step. TypeScript uses `moduleResolution: "Bundler"` and `customConditions: ["@repo/source"]`; Vite also registers `@repo/source`. Next.js uses `transpilePackages: ['@repo/sample']` so workspace TypeScript under `node_modules` is compiled by Next. - -See [docs/](docs/README.md) for the setup guide: pnpm workspace, TypeScript module resolution, Live Types, and Turborepo + tsconfig references. +## Build -## Customizing scope / package name +The library builds from `packages/react-use-hook-kit` with tsdown: -The template ships with `@repo/*`. To rename: +- ESM output: `dist/*.js` +- CommonJS output: `dist/*.cjs` +- Type declarations: `dist/*.d.ts` -```bash -# Bulk rename via your editor's find-and-replace: -# @repo/ -> @yourscope/ -# sample -> yourpkg (rename packages/sample/ dir too) -``` - -## Removing an unused app - -```bash -node scripts/remove-app.mts tanstack-sample -node scripts/remove-app.mts next-sample -node scripts/remove-app.mts all -node scripts/remove-all.mts -pnpm install -pnpm check -``` +React and React DOM remain external peer dependencies. ## License diff --git a/apps/compat-react-17/package.json b/apps/compat-react-17/package.json new file mode 100644 index 0000000..ea61cd3 --- /dev/null +++ b/apps/compat-react-17/package.json @@ -0,0 +1,27 @@ +{ + "name": "@repo/compat-react-17", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "17.0.2", + "react-dom": "17.0.2", + "react-use-hook-kit": "workspace:*" + }, + "devDependencies": { + "@testing-library/react-hooks": "catalog:", + "@types/react": "^17.0.89", + "@types/react-dom": "^17.0.26", + "jsdom": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependenciesMeta": { + "react-use-hook-kit": { + "injected": true + } + } +} diff --git a/apps/compat-react-17/src/compat.test.ts b/apps/compat-react-17/src/compat.test.ts new file mode 100644 index 0000000..cca227b --- /dev/null +++ b/apps/compat-react-17/src/compat.test.ts @@ -0,0 +1,24 @@ +import { act, renderHook } from '@testing-library/react-hooks' +import { describe, expect, it } from 'vitest' +import { useCounter } from 'react-use-hook-kit' +import { useBoolean } from 'react-use-hook-kit/use-boolean' + +describe('React 17 compatibility', () => { + it('runs root and subpath hook exports', () => { + const counter = renderHook(() => useCounter()) + + act(() => { + counter.result.current.increment() + }) + + expect(counter.result.current.count).toBe(1) + + const boolean = renderHook(() => useBoolean()) + + act(() => { + boolean.result.current.setTrue() + }) + + expect(boolean.result.current.value).toBe(true) + }) +}) diff --git a/apps/compat-react-17/tsconfig.json b/apps/compat-react-17/tsconfig.json new file mode 100644 index 0000000..99b6aeb --- /dev/null +++ b/apps/compat-react-17/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/apps/compat-react-17/vitest.config.ts b/apps/compat-react-17/vitest.config.ts new file mode 100644 index 0000000..dc6cbe6 --- /dev/null +++ b/apps/compat-react-17/vitest.config.ts @@ -0,0 +1,18 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +const root = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + react: resolve(root, 'node_modules/react'), + 'react-dom': resolve(root, 'node_modules/react-dom'), + }, + preserveSymlinks: true, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/apps/compat-react-18/package.json b/apps/compat-react-18/package.json new file mode 100644 index 0000000..33297e2 --- /dev/null +++ b/apps/compat-react-18/package.json @@ -0,0 +1,28 @@ +{ + "name": "@repo/compat-react-18", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", + "react-use-hook-kit": "workspace:*" + }, + "devDependencies": { + "@testing-library/react": "catalog:", + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "catalog:", + "jsdom": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependenciesMeta": { + "react-use-hook-kit": { + "injected": true + } + } +} diff --git a/apps/compat-react-18/src/compat.test.ts b/apps/compat-react-18/src/compat.test.ts new file mode 100644 index 0000000..e590bf4 --- /dev/null +++ b/apps/compat-react-18/src/compat.test.ts @@ -0,0 +1,22 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useLocalStorage, useToggle } from 'react-use-hook-kit' +import { useDebounce } from 'react-use-hook-kit/use-debounce' + +describe('React 18 compatibility', () => { + it('runs root and subpath hook exports', () => { + const toggle = renderHook(() => useToggle()) + + act(() => { + toggle.result.current.toggle() + }) + + expect(toggle.result.current.value).toBe(true) + + const debounce = renderHook(() => useDebounce('ready', 0)) + expect(debounce.result.current).toBe('ready') + + const storage = renderHook(() => useLocalStorage('compat-18', 'ok')) + expect(storage.result.current.value).toBe('ok') + }) +}) diff --git a/apps/compat-react-18/tsconfig.json b/apps/compat-react-18/tsconfig.json new file mode 100644 index 0000000..99b6aeb --- /dev/null +++ b/apps/compat-react-18/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/apps/compat-react-18/vitest.config.ts b/apps/compat-react-18/vitest.config.ts new file mode 100644 index 0000000..b772fc3 --- /dev/null +++ b/apps/compat-react-18/vitest.config.ts @@ -0,0 +1,20 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const root = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + react: resolve(root, 'node_modules/react'), + 'react-dom': resolve(root, 'node_modules/react-dom'), + }, + preserveSymlinks: true, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/apps/compat-react-19/package.json b/apps/compat-react-19/package.json new file mode 100644 index 0000000..b2dd233 --- /dev/null +++ b/apps/compat-react-19/package.json @@ -0,0 +1,28 @@ +{ + "name": "@repo/compat-react-19", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:", + "react-use-hook-kit": "workspace:*" + }, + "devDependencies": { + "@testing-library/react": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "jsdom": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependenciesMeta": { + "react-use-hook-kit": { + "injected": true + } + } +} diff --git a/apps/compat-react-19/src/compat.test.ts b/apps/compat-react-19/src/compat.test.ts new file mode 100644 index 0000000..eacadbd --- /dev/null +++ b/apps/compat-react-19/src/compat.test.ts @@ -0,0 +1,27 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useBoolean, useCounter } from 'react-use-hook-kit' +import { useToggle } from 'react-use-hook-kit/use-toggle' + +describe('React 19 compatibility', () => { + it('runs root and subpath hook exports', () => { + const boolean = renderHook(() => useBoolean()) + + act(() => { + boolean.result.current.setTrue() + }) + + expect(boolean.result.current.value).toBe(true) + + const counter = renderHook(() => useCounter(2)) + + act(() => { + counter.result.current.decrement() + }) + + expect(counter.result.current.count).toBe(1) + + const toggle = renderHook(() => useToggle()) + expect(toggle.result.current.value).toBe(false) + }) +}) diff --git a/apps/compat-react-19/tsconfig.json b/apps/compat-react-19/tsconfig.json new file mode 100644 index 0000000..99b6aeb --- /dev/null +++ b/apps/compat-react-19/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/apps/compat-react-19/vitest.config.ts b/apps/compat-react-19/vitest.config.ts new file mode 100644 index 0000000..b772fc3 --- /dev/null +++ b/apps/compat-react-19/vitest.config.ts @@ -0,0 +1,20 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const root = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + react: resolve(root, 'node_modules/react'), + 'react-dom': resolve(root, 'node_modules/react-dom'), + }, + preserveSymlinks: true, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs new file mode 100644 index 0000000..3b11e9f --- /dev/null +++ b/apps/docs/astro.config.mjs @@ -0,0 +1,6 @@ +import react from '@astrojs/react' +import { defineConfig } from 'astro/config' + +export default defineConfig({ + integrations: [react()], +}) diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..5dd4af7 --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,24 @@ +{ + "name": "@repo/docs", + "private": true, + "type": "module", + "scripts": { + "build": "astro build", + "dev": "astro dev --host 0.0.0.0 --port 3000", + "preview": "astro preview --host 0.0.0.0 --port 3000", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/react": "catalog:", + "astro": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-use-hook-kit": "workspace:*" + }, + "devDependencies": { + "@astrojs/check": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/docs/src/components/HookDemos.tsx b/apps/docs/src/components/HookDemos.tsx new file mode 100644 index 0000000..5b5ef9e --- /dev/null +++ b/apps/docs/src/components/HookDemos.tsx @@ -0,0 +1,83 @@ +import { useBoolean, useCounter, useDebounce, useLocalStorage, useToggle } from 'react-use-hook-kit' +import { useState } from 'react' + +export function HookDemos() { + const boolean = useBoolean() + const toggle = useToggle(true) + const counter = useCounter(0, { max: 10, min: 0 }) + const [query, setQuery] = useState('') + const debouncedQuery = useDebounce(query, 350) + const storage = useLocalStorage('react-use-hook-kit-docs-theme', 'system') + + return ( +
+
+

useBoolean

+

{boolean.value ? 'Enabled' : 'Disabled'}

+
+ + + +
+
+ +
+

useToggle

+

{toggle.value ? 'Open' : 'Closed'}

+ +
+ +
+

useCounter

+

{counter.count}

+
+ + + +
+
+ +
+

useDebounce

+ +

Debounced: {debouncedQuery || 'empty'}

+
+ +
+

useLocalStorage

+ + +
+
+ ) +} diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro new file mode 100644 index 0000000..a5b3c8e --- /dev/null +++ b/apps/docs/src/pages/index.astro @@ -0,0 +1,91 @@ +--- +import { HookDemos } from '../components/HookDemos' +import '../styles/global.css' + +const hooks = [ + { + name: 'useBoolean', + importPath: 'react-use-hook-kit/use-boolean', + signature: 'useBoolean(initialValue?: boolean)', + description: 'Boolean state with setTrue, setFalse, toggle, and direct setter helpers.', + }, + { + name: 'useToggle', + importPath: 'react-use-hook-kit/use-toggle', + signature: 'useToggle(initialValue?: boolean)', + description: 'Minimal boolean toggle state for disclosure and switch-like controls.', + }, + { + name: 'useCounter', + importPath: 'react-use-hook-kit/use-counter', + signature: 'useCounter(initialValue?: number, options?: { min?: number; max?: number })', + description: 'Numeric state with increment, decrement, reset, direct setter, and optional bounds.', + }, + { + name: 'useDebounce', + importPath: 'react-use-hook-kit/use-debounce', + signature: 'useDebounce(value: T, delay: number)', + description: 'Returns a delayed value that updates after the configured quiet period.', + }, + { + name: 'useLocalStorage', + importPath: 'react-use-hook-kit/use-local-storage', + signature: 'useLocalStorage(key: string, initialValue: T | () => T, options?)', + description: 'React state synchronized to localStorage with SSR-safe initialization.', + }, +] +--- + + + + + + react-use-hook-kit + + +
+
+
+

React 17, 18, and 19

+

react-use-hook-kit

+

+ Small, typed React hooks with ESM-first package exports and focused + behavior for everyday UI state. +

+
pnpm add react-use-hook-kit
+
+
+ +
+
+

API

+

Hooks

+
+
+ { + hooks.map((hook) => ( +
+
+

{hook.name}

+

{hook.description}

+
+
+ {hook.signature} + {hook.importPath} +
+
+ )) + } +
+
+ +
+
+

Demos

+

Interactive Examples

+
+ +
+
+ + diff --git a/apps/docs/src/styles/global.css b/apps/docs/src/styles/global.css new file mode 100644 index 0000000..81df206 --- /dev/null +++ b/apps/docs/src/styles/global.css @@ -0,0 +1,216 @@ +:root { + color: #16211d; + background: #f7f6f2; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +main { + min-height: 100vh; +} + +.hero, +.section { + padding: 56px max(24px, calc((100vw - 1120px) / 2)); +} + +.hero { + align-items: end; + background: + linear-gradient(120deg, rgb(247 246 242 / 84%), rgb(247 246 242 / 24%)), + url('https://images.unsplash.com/photo-1555066931-4365d14bab8c?auto=format&fit=crop&w=1800&q=80'); + background-position: center; + background-size: cover; + display: grid; + min-height: 62vh; +} + +.hero > div { + max-width: 720px; +} + +.eyebrow { + color: #0f6b58; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + margin: 0 0 12px; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + font-size: clamp(3.5rem, 12vw, 7.25rem); + line-height: 0.9; + margin-bottom: 24px; +} + +h2 { + font-size: 2.2rem; + margin-bottom: 0; +} + +h3 { + font-size: 1.08rem; + margin-bottom: 8px; +} + +.lede { + font-size: 1.25rem; + line-height: 1.6; + max-width: 640px; +} + +pre, +code { + background: #10201c; + border-radius: 6px; + color: #eef8f4; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 0.9rem; +} + +pre { + display: inline-block; + margin: 8px 0 0; + padding: 14px 16px; +} + +code { + display: block; + overflow-wrap: anywhere; + padding: 8px 10px; +} + +.section { + background: #f7f6f2; +} + +.section + .section { + border-top: 1px solid #dad7cb; +} + +.section-heading { + align-items: end; + display: flex; + justify-content: space-between; + margin-bottom: 24px; +} + +.api-list { + border-top: 1px solid #dad7cb; +} + +.api-item { + align-items: start; + border-bottom: 1px solid #dad7cb; + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.9fr); + padding: 22px 0; +} + +.api-item p { + color: #51615b; + line-height: 1.55; + margin-bottom: 0; +} + +.api-item code + code { + margin-top: 8px; +} + +.demo-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.demo-card { + background: #ffffff; + border: 1px solid #dad7cb; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 220px; + padding: 18px; +} + +.demo-card p { + color: #51615b; + margin-bottom: 0; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +button, +input, +select { + border: 1px solid #8c978f; + border-radius: 6px; + font: inherit; + min-height: 40px; +} + +button { + background: #173f36; + color: #ffffff; + cursor: pointer; + padding: 0 14px; +} + +label { + color: #51615b; + display: grid; + gap: 8px; +} + +input, +select { + background: #ffffff; + color: #16211d; + padding: 0 10px; + width: 100%; +} + +@media (max-width: 720px) { + .hero, + .section { + padding-left: 18px; + padding-right: 18px; + } + + .section-heading { + align-items: start; + display: block; + } + + .api-item { + grid-template-columns: 1fr; + } +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 0000000..6394563 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["astro/client"] + }, + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/apps/next-sample/next-env.d.ts b/apps/next-sample/next-env.d.ts deleted file mode 100644 index 3dfcfa3..0000000 --- a/apps/next-sample/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -// oxlint-disable-next-line import/no-unassigned-import -- includes Next.js generated route types -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/next-sample/next.config.ts b/apps/next-sample/next.config.ts deleted file mode 100644 index 0760261..0000000 --- a/apps/next-sample/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = { - transpilePackages: ['@repo/sample'], -} - -export default nextConfig diff --git a/apps/next-sample/package.json b/apps/next-sample/package.json deleted file mode 100644 index 0b583bb..0000000 --- a/apps/next-sample/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@repo/next-sample", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "next dev -p 3002", - "build": "next build", - "start": "next start -p 3002", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@repo/sample": "workspace:*", - "next": "catalog:", - "react": "catalog:", - "react-dom": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/apps/next-sample/src/app/layout.tsx b/apps/next-sample/src/app/layout.tsx deleted file mode 100644 index 345f925..0000000 --- a/apps/next-sample/src/app/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Metadata } from 'next' -import type { ReactNode } from 'react' - -export const metadata: Metadata = { - title: 'next-sample', -} - -export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - return ( - - - {children} - - - ) -} diff --git a/apps/next-sample/src/app/page.tsx b/apps/next-sample/src/app/page.tsx deleted file mode 100644 index e4f9680..0000000 --- a/apps/next-sample/src/app/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { greet } from '@repo/sample' - -export default function Page() { - return ( -
-
-

next-sample

-

{greet('Next.js')}

-
-
- ) -} diff --git a/apps/next-sample/tsconfig.json b/apps/next-sample/tsconfig.json deleted file mode 100644 index 14572b4..0000000 --- a/apps/next-sample/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "preserve", - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "plugins": [{ "name": "next" }], - "paths": { - "~/*": ["./src/*"], - "@/*": ["./src/*"] - }, - "incremental": true - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"], - "references": [{ "path": "../../packages/sample" }] -} diff --git a/apps/tanstack-sample/package.json b/apps/tanstack-sample/package.json deleted file mode 100644 index 980d6c7..0000000 --- a/apps/tanstack-sample/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@repo/tanstack-sample", - "version": "0.0.0", - "private": true, - "type": "module", - "sideEffects": false, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "start": "node .output/server/index.mjs", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@repo/sample": "workspace:*", - "@tanstack/react-router": "catalog:", - "@tanstack/react-router-devtools": "catalog:", - "@tanstack/react-start": "catalog:", - "react": "catalog:", - "react-dom": "catalog:" - }, - "devDependencies": { - "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "catalog:", - "@testing-library/react": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "@vitejs/plugin-react": "catalog:", - "jsdom": "catalog:", - "tailwindcss": "catalog:", - "typescript": "catalog:", - "vite": "catalog:", - "vite-tsconfig-paths": "catalog:", - "vitest": "catalog:" - } -} diff --git a/apps/tanstack-sample/src/routeTree.gen.ts b/apps/tanstack-sample/src/routeTree.gen.ts deleted file mode 100644 index dceedff..0000000 --- a/apps/tanstack-sample/src/routeTree.gen.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - -// This file was automatically generated by TanStack Router. -// You should NOT make any changes in this file as it will be overwritten. -// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. - -import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' - -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) - -export interface FileRoutesByFullPath { - '/': typeof IndexRoute -} -export interface FileRoutesByTo { - '/': typeof IndexRoute -} -export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute -} -export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' - fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' - fileRoutesById: FileRoutesById -} -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute -} - -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - } -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/apps/tanstack-sample/src/router.tsx b/apps/tanstack-sample/src/router.tsx deleted file mode 100644 index 6544aaf..0000000 --- a/apps/tanstack-sample/src/router.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createRouter } from '@tanstack/react-router' -import { routeTree } from './routeTree.gen' - -export function getRouter() { - const router = createRouter({ - routeTree, - defaultPreload: 'intent', - scrollRestoration: true, - }) - - return router -} - -declare module '@tanstack/react-router' { - interface Register { - router: ReturnType - } -} diff --git a/apps/tanstack-sample/src/routes/__root.tsx b/apps/tanstack-sample/src/routes/__root.tsx deleted file mode 100644 index bc68344..0000000 --- a/apps/tanstack-sample/src/routes/__root.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/// -import type { ReactNode } from 'react' -import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router' -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' -import appCss from '~/styles/app.css?url' - -export const Route = createRootRoute({ - head: () => ({ - meta: [ - { charSet: 'utf-8' }, - { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { title: 'tanstack-sample' }, - ], - links: [{ rel: 'stylesheet', href: appCss }], - }), - component: RootComponent, -}) - -function RootComponent() { - return ( - - - - ) -} - -function RootDocument({ children }: Readonly<{ children: ReactNode }>) { - return ( - - - - - - {children} - - - - - ) -} diff --git a/apps/tanstack-sample/src/routes/index.tsx b/apps/tanstack-sample/src/routes/index.tsx deleted file mode 100644 index 3a1a0cd..0000000 --- a/apps/tanstack-sample/src/routes/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { greet } from '@repo/sample' - -export const Route = createFileRoute('/')({ - component: Home, -}) - -function Home() { - return ( -
-
-

tanstack-sample

-

{greet('TanStack Start')}

-
-
- ) -} diff --git a/apps/tanstack-sample/src/styles/app.css b/apps/tanstack-sample/src/styles/app.css deleted file mode 100644 index d4b5078..0000000 --- a/apps/tanstack-sample/src/styles/app.css +++ /dev/null @@ -1 +0,0 @@ -@import 'tailwindcss'; diff --git a/apps/tanstack-sample/tsconfig.json b/apps/tanstack-sample/tsconfig.json deleted file mode 100644 index f210e05..0000000 --- a/apps/tanstack-sample/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["**/*.ts", "**/*.tsx"], - "compilerOptions": { - "jsx": "react-jsx", - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "paths": { - "~/*": ["./src/*"] - } - }, - "references": [{ "path": "../../packages/sample" }] -} diff --git a/apps/tanstack-sample/vite.config.ts b/apps/tanstack-sample/vite.config.ts deleted file mode 100644 index 7be316c..0000000 --- a/apps/tanstack-sample/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite' -import { tanstackStart } from '@tanstack/react-start/plugin/vite' -import viteReact from '@vitejs/plugin-react' -import tsConfigPaths from 'vite-tsconfig-paths' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - server: { - port: 3001, - }, - resolve: { - conditions: ['@repo/source'], - }, - plugins: [tailwindcss(), tsConfigPaths(), tanstackStart(), viteReact()], -}) diff --git a/apps/tanstack-sample/vitest.config.ts b/apps/tanstack-sample/vitest.config.ts deleted file mode 100644 index a55c7d4..0000000 --- a/apps/tanstack-sample/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from 'vitest/config' -import viteReact from '@vitejs/plugin-react' -import tsConfigPaths from 'vite-tsconfig-paths' - -export default defineConfig({ - plugins: [tsConfigPaths(), viteReact()], - test: { - environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], - globals: true, - }, -}) diff --git a/apps/tanstack-sample/vitest.setup.ts b/apps/tanstack-sample/vitest.setup.ts deleted file mode 100644 index 1973856..0000000 --- a/apps/tanstack-sample/vitest.setup.ts +++ /dev/null @@ -1,2 +0,0 @@ -// oxlint-disable-next-line import/no-unassigned-import -- registers jest-dom matchers for Vitest -import '@testing-library/jest-dom/vitest' diff --git a/docs/01-pnpm-workspace.md b/docs/01-pnpm-workspace.md deleted file mode 100644 index 327e940..0000000 --- a/docs/01-pnpm-workspace.md +++ /dev/null @@ -1,125 +0,0 @@ -# 01. pnpm 워크스페이스 - -이 템플릿은 모노레포 도구로 `pnpm` 워크스페이스를 사용한다. Turborepo도 함께 쓰지만 Turbo는 **태스크 러너 / 캐시**일 뿐, 패키지의 위치와 의존 관계는 전적으로 `pnpm`이 관리한다 (Turbo의 역할은 [04 문서](./04-turbo-and-tsconfig-references.md) 참조). - -## 워크스페이스 선언 - -```yaml -# pnpm-workspace.yaml -packages: - - 'apps/*' - - 'packages/*' - -catalog: - typescript: ~6.0.3 - vitest: ^4.1.4 - react: ^19.2.5 - react-dom: ^19.2.5 - next: ^16.0.0 - # … -``` - -`packages` 글롭이 매칭하는 모든 디렉토리(현재는 `apps/tanstack-sample`, `apps/next-sample`, `packages/sample`)가 워크스페이스 멤버가 된다. 각 디렉토리의 `package.json#name`이 그 워크스페이스의 식별자다. - -## `workspace:*` 프로토콜과 심볼릭 링크 - -워크스페이스 내부에서 다른 멤버를 의존성으로 쓸 때는 `workspace:*`로 선언한다. - -```jsonc -// apps/tanstack-sample/package.json -{ - "dependencies": { - "@repo/sample": "workspace:*" - } -} -``` - -`pnpm install` 시점에 pnpm은 이 선언을 보고 다음과 같은 **심볼릭 링크**를 만든다. - -``` -apps/tanstack-sample/node_modules/@repo/sample - → ../../packages/sample -``` - -링크가 생기면 TypeScript도, Vite도, Next.js도 `@repo/sample`을 일반 npm 패키지처럼 취급한다. `node_modules/@repo/sample/package.json`을 읽고 `exports` 필드를 평가한다 — 패키지 해석 알고리즘이 워크스페이스 멤버라는 사실을 따로 알 필요가 없다. - -## `.npmrc` — 왜 `hoist=false`인가 - -```ini -# .npmrc -shamefully-hoist=false -strict-peer-dependencies=false -auto-install-peers=true -hoist=false -``` - -`hoist=false`가 핵심이다. pnpm의 기본은 의존성을 워크스페이스 루트의 `node_modules`로 부분 호이스팅하지만, 이 옵션을 끄면 **각 워크스페이스가 자기 `node_modules`만 본다**. - -``` -apps/tanstack-sample/node_modules/ - @repo/sample → symlink - @tanstack/react-router → 실제 패키지 (자신의 dependencies에 선언했음) - react → 실제 패키지 - -apps/next-sample/node_modules/ - @repo/sample → symlink - next → 실제 패키지 - react → 실제 패키지 (별도 인스턴스) -``` - -이 구조가 보장하는 것: - -- **Phantom dependency 차단** — `apps/next-sample`이 `package.json`에 적지 않은 `@tanstack/react-router`를 코드에서 우연히 import해도, 자기 `node_modules`에 없으므로 즉시 깨진다. 다른 워크스페이스 덕분에 잠깐 굴러가는 import가 생기지 않는다. -- **모듈 해석의 단일 진실 공급원** — 각 워크스페이스의 `package.json` + 자기 `node_modules`만 보면 된다. 상위 디렉토리를 추적할 일이 없다. - -`auto-install-peers=true`는 peer 의존성을 자동 설치하도록 두는 보조 설정이다. `strict-peer-dependencies=false`는 peer 충돌을 에러가 아닌 경고로 낮춰 워크스페이스의 일상 작업을 막지 않게 한다. - -## `catalog` — 버전 단일화 - -`pnpm-workspace.yaml`의 `catalog:` 블록에 버전을 한 번 적어두고, 각 워크스페이스의 `package.json`에서는 `catalog:` 키워드로 참조한다. - -```jsonc -// apps/tanstack-sample/package.json -{ - "dependencies": { - "react": "catalog:", - "react-dom": "catalog:" - }, - "devDependencies": { - "vite": "catalog:", - "typescript": "catalog:" - } -} -``` - -`pnpm install`이 `catalog:`를 카탈로그 선언으로 치환하므로, 모든 워크스페이스가 같은 React/TypeScript/Vitest 버전을 쓴다는 것이 구조적으로 보장된다. 버전 업그레이드는 `pnpm-workspace.yaml` 한 곳만 수정하면 된다. - -카탈로그는 모듈 해석 규칙에는 직접 영향을 주지 않는다. 하지만 모든 워크스페이스가 같은 TypeScript 버전을 쓴다는 보장이 있어야 [02 문서](./02-module-resolution.md)에서 다루는 `moduleResolution: "Bundler"`와 `customConditions` 같은 설정이 일관되게 동작한다. - -## `pnpm.onlyBuiltDependencies` — 설치 시 postinstall 허용 목록 - -```jsonc -// package.json (root) -{ - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "turbo" - ] - } -} -``` - -pnpm 10부터는 보안상 기본값으로 모든 의존성의 `postinstall` 스크립트 실행을 차단한다. 네이티브 바이너리를 받아야 하는 `esbuild`(Vite·Next 빌드에 필요)와 `turbo`(태스크 러너 바이너리)만 명시적으로 허용한다. 새로운 의존성이 빌드 단계에서 native binary를 필요로 한다면 이 목록에 추가해야 한다. - -## 정리 - -| 설정 | 보장하는 것 | -|------|-------------| -| `pnpm-workspace.yaml` `packages` | 어느 디렉토리가 워크스페이스 멤버인지 | -| `workspace:*` | 내부 패키지가 심볼릭 링크로 연결됨 → 일반 npm 패키지처럼 해석 가능 | -| `hoist=false` | 각 워크스페이스의 `node_modules`가 모듈 해석의 유일한 출처 → phantom dependency 차단 | -| `catalog:` | 모든 워크스페이스의 공통 의존성 버전이 한 곳에서 관리됨 | -| `onlyBuiltDependencies` | 신뢰하는 패키지만 설치 시 스크립트 실행 | - -다음: [02. TypeScript 모듈 해석](./02-module-resolution.md) diff --git a/docs/02-module-resolution.md b/docs/02-module-resolution.md deleted file mode 100644 index 3ec0edf..0000000 --- a/docs/02-module-resolution.md +++ /dev/null @@ -1,170 +0,0 @@ -# 02. TypeScript 모듈 해석 - -`import { greet } from "@repo/sample"`라고 썼을 때, TypeScript와 런타임(Vite, Next.js)이 각각 어떤 알고리즘으로 실제 파일을 찾는지 정리한다. - -## 이 템플릿의 설정 - -```jsonc -// tsconfig.base.json -{ - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "customConditions": ["@repo/source"], - "noEmit": true, - // … - } -} -``` - -세 가지가 핵심이다. - -- `module: "ESNext"` — 컴파일러가 ES 모듈 문법(import/export)을 그대로 출력. 번들러 환경의 표준값. -- `moduleResolution: "Bundler"` — 번들러(Vite, Next.js, esbuild 등)의 해석 규칙을 그대로 따라간다. TypeScript 5.0에서 도입. -- `customConditions: ["@repo/source"]` — `package.json#exports`의 커스텀 조건을 인식하게 한다. 본격적인 사용 사례는 [03 문서](./03-live-types.md)에서 다룬다. - -## 왜 `moduleResolution: "Bundler"`인가 - -이 템플릿의 두 앱은 모두 번들러를 거친다. - -- `apps/tanstack-sample` — Vite (dev 서버, 빌드, 프로덕션 SSR 모두 Vite) -- `apps/next-sample` — Next.js (내부적으로 Turbopack/webpack) - -번들러는 Node.js의 ESM보다 훨씬 관대하게 모듈을 해석한다. - -| 동작 | Node16 / NodeNext | Bundler | -|------|-------------------|---------| -| 확장자 없는 상대 import | ❌ `ERR_MODULE_NOT_FOUND` | ✅ `.ts`, `.tsx`, `index.ts` 자동 탐색 | -| `package.json#exports` 인식 | ✅ | ✅ | -| `main` / `types` fallback | ❌ (`exports`가 있으면 무시) | ✅ | -| `customConditions` | ✅ | ✅ | - -**판단 기준**: "런타임에 번들러를 거치는가?" 가 yes면 Bundler. 이 템플릿의 답은 yes다. (Node.js 서버를 번들러 없이 직접 실행하는 패키지가 추가된다면 그 패키지만 별도로 `Node16`을 쓰면 된다.) - -`Node16`을 쓰지 않는 부수 효과로, **소스에 `.js` 확장자를 적지 않아도 된다**. - -```typescript -// Bundler 모드 — 이 템플릿 -import { greet } from './utils' // ✅ - -// Node16 모드라면 -import { greet } from './utils.js' // 소스에는 .ts지만 .js로 적어야 함 -``` - -## `package.json#exports` 한 줄 요약 - -`exports`는 패키지의 **공개 API 경계**를 선언하는 필드다. 이 필드가 있으면 외부에서 import할 수 있는 경로가 거기에 적힌 것으로 한정된다. - -```jsonc -// packages/sample/package.json -{ - "name": "@repo/sample", - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - } - } -} -``` - -해석되는 모습: - -| import 문 | 해석 결과 | -|-----------|-----------| -| `import { greet } from "@repo/sample"` | `./packages/sample/src/index.ts` | -| `import { greet } from "@repo/sample/src/index"` | ❌ `exports`에 없음 | - -`type: "module"`은 이 패키지가 ESM이라는 선언이다. `.js` 산출물이 생기는 시점에 Node.js와 번들러가 그 파일을 ESM으로 해석하게 한다. - -## 조건(condition)의 우선순위 - -`exports` 안의 객체 키는 **위에서 아래로 순회하며 첫 매칭을 사용**한다. Bundler 모드의 TypeScript가 인식하는 기본 조건은 다음과 같다. - -| 조건 | 의미 | -|------|------| -| `types` | TypeScript가 타입 해석할 때 — 항상 첫 번째에 둬야 한다 | -| `import` | ESM `import` 문 | -| `require` | CJS `require()` | -| `default` | 매칭되는 것이 없을 때의 폴백 | - -여기에 `customConditions`로 사용자가 정의한 키를 추가할 수 있다. 이 템플릿은 `@repo/source` 한 가지를 추가했다 ([03 문서](./03-live-types.md)). - -### 순서 규칙: types를 가장 위에 - -```jsonc -// ✅ 올바른 순서 -{ - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "default": "./dist/index.js" -} - -// ❌ types가 뒤에 있으면 import가 먼저 매칭되어 .d.ts를 못 찾을 수 있음 -{ - "import": "./dist/index.mjs", - "types": "./dist/index.d.ts" -} -``` - -커스텀 조건을 추가할 때는 그 조건을 **`types`보다도 위**에 둔다. 그래야 로컬 환경에서 커스텀 조건이 가장 먼저 매칭된다. - -```jsonc -// 03 문서에서 다룰 패턴 -{ - "@repo/source": "./src/index.ts", // 1순위 - "types": "./dist/index.d.ts", // 2순위 - "default": "./dist/index.js" // 3순위 -} -``` - -## `tsconfig.json#paths` — 이 템플릿에서의 위치 - -`paths`는 TypeScript 6에서 `baseUrl`이 deprecated된 이후로 **각 워크스페이스 내부의 별칭** 용도에만 쓰는 것이 권장 패턴이다. 이 템플릿도 그렇게 쓴다. - -```jsonc -// apps/tanstack-sample/tsconfig.json -{ - "compilerOptions": { - "paths": { "~/*": ["./src/*"] } - } -} - -// apps/next-sample/tsconfig.json -{ - "compilerOptions": { - "paths": { "~/*": ["./src/*"], "@/*": ["./src/*"] } - } -} - -// packages/sample/tsconfig.json -{ - "compilerOptions": { - "paths": { "@/*": ["./src/*"] } - } -} -``` - -**이 템플릿은 `baseUrl`을 쓰지 않는다.** TypeScript 6 기준 `paths`는 `baseUrl` 없이 동작하므로, `paths` 값에 직접 `./src/*`처럼 적으면 된다. - -워크스페이스 **간** 경로(`@repo/sample` 등)는 절대로 `paths`로 매핑하지 않는다. 그건 `pnpm`의 `workspace:*`와 `package.json#exports`가 표현하는 영역이다 — `paths`로 끌어오면 TypeScript는 통과하지만 런타임이 못 찾는 불일치가 생긴다. - -런타임 측 매핑은 다음 두 곳이 처리한다. - -- Vite: `apps/tanstack-sample/vite.config.ts`의 `vite-tsconfig-paths` 플러그인이 `tsconfig`의 `paths`를 그대로 읽는다. -- Next.js: 내장 webpack/Turbopack이 `tsconfig`의 `paths`를 자동으로 인식한다. - -그래서 `~/components/Header`처럼 쓴 import가 TypeScript와 런타임 모두에서 같은 파일로 해석된다. - -## 정리 - -| 결정 | 이 템플릿의 선택 | -|------|------------------| -| `moduleResolution` | `"Bundler"` — 두 앱이 모두 번들러 기반 | -| `module` | `"ESNext"` | -| 워크스페이스 간 import 표현 | `package.json#exports` (커스텀 조건은 03에서) | -| 워크스페이스 내부 별칭 | `tsconfig#paths` + `vite-tsconfig-paths` (Vite) / Next.js 자동 인식 | -| `baseUrl` | 사용하지 않음 | - -다음: [03. Live Types — 소스 직접 참조와 빌드 산출물의 분기](./03-live-types.md) diff --git a/docs/03-live-types.md b/docs/03-live-types.md deleted file mode 100644 index e6489b2..0000000 --- a/docs/03-live-types.md +++ /dev/null @@ -1,219 +0,0 @@ -# 03. Live Types — 소스 직접 참조와 빌드 산출물의 분기 - -이 템플릿의 핵심 결정. **`packages/sample`의 코드를 수정하면 `apps/*`의 타입과 런타임이 빌드 단계 없이 즉시 반영된다.** 이 문서는 그 메커니즘을 정리하고, 향후 패키지에 빌드 산출물(`dist/`)이 필요해졌을 때 어떻게 자연스럽게 확장할 수 있는지를 보여준다. - -## 두 시점, 하나의 `exports` - -이 문서는 [README](./README.md#핵심-용어)에서 정의한 두 시점을 일관되게 사용한다. - -| 용어 | 의미 | -|------|------| -| **로컬 실행** | 워크스페이스 안에서 `pnpm dev`, `tsc -b`, 에디터가 동작하는 시점. 워크스페이스끼리 `.ts` 소스를 직접 본다. | -| **번들 산출물** | 패키지가 `dist/`로 빌드되어 외부(npm 소비자, 다른 도구)에서 쓰이는 시점. | - -목표: **하나의 `package.json#exports`로 두 시점을 동시에 만족**시킨다. 로컬에서는 소스를, 외부에서는 산출물을 가리키게 한다. 도구는 **커스텀 export 조건**이다. - -## 지금: source-level exports만 사용 - -`@repo/sample`은 npm에 배포하지 않는 internal 패키지(`"private": true`)다. 외부 소비자가 없으므로 산출물도 필요 없다. 그래서 `exports`가 `.ts` 소스를 그대로 가리킨다. - -```jsonc -// packages/sample/package.json -{ - "name": "@repo/sample", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - } - } -} -``` - -해석 흐름은 단순하다. - -``` -import { greet } from "@repo/sample" - -→ TypeScript (Bundler 모드) - → exports["."] 에서 "types" 매칭 - → packages/sample/src/index.ts 의 타입을 읽음 - -→ Vite (apps/tanstack-sample) - → exports["."] 에서 "default" 매칭 - → packages/sample/src/index.ts 를 직접 트랜스파일 - -→ Next.js (apps/next-sample) - → next.config.ts 의 transpilePackages: ["@repo/sample"] 설정으로 - 워크스페이스 .ts 소스를 자체 번들에 포함 - → packages/sample/src/index.ts 를 직접 컴파일 -``` - -`packages/sample/src/index.ts`를 수정하면, 위 세 도구가 모두 그 파일을 다시 읽는다. **`packages/sample`을 빌드하는 단계 자체가 존재하지 않는다.** - -### Next.js의 `transpilePackages` - -```typescript -// apps/next-sample/next.config.ts -const nextConfig: NextConfig = { - transpilePackages: ['@repo/sample'], -} -``` - -Next.js는 기본적으로 `node_modules` 안의 패키지를 **이미 빌드된 `.js`라고 가정**한다. 워크스페이스 심볼릭 링크 너머가 `.ts` 소스라는 것을 알리려면 `transpilePackages`에 명시적으로 추가해야 한다. 새로운 source-level 패키지를 만들 때마다 이 배열에 추가한다. - -Vite는 별도 설정이 필요 없다. `.ts` 소스를 만나면 자체 esbuild 파이프라인이 처리한다. - -## 미리 깔린 무대장치: `@repo/source` 커스텀 조건 - -`packages/sample`은 지금 커스텀 조건을 사용하지 않는다. 그런데 이 템플릿은 두 곳에 이미 `@repo/source`를 등록해 두었다. - -```jsonc -// tsconfig.base.json -{ - "compilerOptions": { - "customConditions": ["@repo/source"] - } -} -``` - -```typescript -// apps/tanstack-sample/vite.config.ts -export default defineConfig({ - resolve: { - conditions: ['@repo/source'], - }, - // … -}) -``` - -이 두 줄은 **빌드 산출물이 있는 패키지를 추가했을 때** 비로소 의미가 생긴다. 즉, 지금 당장 동작하는 것은 없지만 그쪽으로 자연스럽게 확장할 수 있도록 준비되어 있다. - -## 패키지를 "build 시점"으로 키울 때: 커스텀 조건 패턴 - -`packages/sample`을 npm에 배포하기로 했다고 가정한다. 그러면 **외부 소비자에게는 `dist/index.js` + `dist/index.d.ts`**를 제공해야 하지만, **로컬 워크스페이스는 여전히 `src/index.ts`를 봐야 한다** (그래야 Live Types가 유지된다). - -`exports`를 다음과 같이 확장한다. - -```jsonc -// packages/sample/package.json (가상의 미래 모습) -{ - "name": "@repo/sample", - "type": "module", - "exports": { - ".": { - "@repo/source": "./src/index.ts", // 1순위 — 로컬 실행 시점 - "types": "./dist/index.d.ts", // 2순위 — 외부 소비자의 타입 - "default": "./dist/index.js" // 3순위 — 외부 소비자의 런타임 - } - }, - "scripts": { - "build": "tsc -b", // dist/ 생성 (또는 tsup, unbuild 등) - "typecheck": "tsc -b", - "test": "vitest run --passWithNoTests" - } -} -``` - -해석 흐름이 시점별로 갈라진다. - -| 소비자 | 인식하는 조건 | 매칭 결과 | 읽히는 파일 | -|--------|---------------|-----------|-------------| -| 워크스페이스 내부 TypeScript | `customConditions: ["@repo/source"]` | `@repo/source` | `./src/index.ts` | -| 워크스페이스 내부 Vite | `resolve.conditions: ["@repo/source"]` | `@repo/source` | `./src/index.ts` | -| 워크스페이스 내부 Next.js | (커스텀 조건 미등록 — 아래 주의 참조) | `default` | `./dist/index.js` | -| 외부 npm 소비자 | (커스텀 조건 모름) | `types` → `default` | `./dist/index.d.ts` + `./dist/index.js` | - -### 핵심 포인트 - -1. **순서가 결정한다.** 객체 키는 위에서 아래로 첫 매칭이 이긴다. 커스텀 조건이 `types`보다 위에 있어야 로컬 도구가 산출물 대신 소스를 선택한다. -2. **커스텀 조건의 이름은 scoped로**. `"source"` 같은 일반 이름은 외부 패키지가 같은 키를 쓰면 충돌해서 외부 패키지의 미빌드 소스까지 읽히는 사고가 난다. `@repo/source`처럼 워크스페이스 스코프와 같은 prefix가 안전하다. -3. **로컬 도구만 조건을 등록한다.** 외부 소비자는 `customConditions`를 모르기 때문에 자동으로 `types` → `default` 폴백 경로를 탄다. 한 `package.json`이 두 시점을 동시에 만족시킨다. - -### Next.js를 같은 패턴에 합류시키려면 - -위 표에서 Next.js만 `dist/index.js`를 본다. Next는 dev/build 양쪽에서 커스텀 조건을 직접 노출하는 옵션이 일관되게 제공되지 않으므로, 보통 두 가지 중 하나를 선택한다. - -- **(A) 산출물을 보게 두기** — 그 패키지에 한해 빌드를 거치게 한다. `dist/`가 항상 최신이도록 Turbo의 `dependsOn: ["^build"]`를 활용 ([04 문서](./04-turbo-and-tsconfig-references.md)). -- **(B) 소스를 보게 하기** — `transpilePackages: ['@repo/sample']`로 Next에 소스 트랜스파일을 맡긴다. 단, Next 빌드의 webpack/Turbopack 설정에서 `resolve.conditions`를 추가해야 `@repo/source`가 매칭된다. (현재 Next.js 16 기준 `next.config.ts`의 `webpack` 콜백 또는 `turbopack.resolveAlias`를 통해 가능.) - -이 템플릿의 현재 `next-sample`은 (B)의 소스 트랜스파일만 켜져 있고 source-level exports를 쓰는 패키지(`@repo/sample`)에 대해서만 동작한다. `dist`를 가진 패키지를 추가하면 그 패키지에 맞춰 결정을 내려야 한다. - -## `composite: true`는 런타임과 무관하다 - -`packages/sample/tsconfig.json`은 `composite: true`, `outDir: "dist"`, `declaration: true`를 가진다. - -```jsonc -// packages/sample/tsconfig.json -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true, - "outDir": "dist", - "rootDir": "src", - "noEmit": false, - "paths": { "@/*": ["./src/*"] } - }, - "include": ["src/**/*.ts"] -} -``` - -여기서 만들어지는 `dist/`는 **런타임에 절대로 사용되지 않는다.** `exports`가 `src/index.ts`를 가리키고 있고 `apps/*`의 어떤 import도 `dist/`를 거치지 않는다. - -이 `dist/`의 진짜 용도는 **TypeScript의 project references**다. `composite: true` 프로젝트는 반드시 선언 파일을 emit해야 다른 프로젝트가 `references`로 참조할 수 있다. 그래서 `noEmit: true`(`tsconfig.base.json`에서 상속)를 명시적으로 `false`로 덮어쓴다. project references의 의미와 그것을 Turbo가 어떻게 활용하는지는 [04 문서](./04-turbo-and-tsconfig-references.md)에서 다룬다. - -요약하자면: - -| 메커니즘 | 영향 범위 | 비고 | -|----------|-----------|------| -| `package.json#exports` | 런타임 + TypeScript 타입 해석 | Live Types를 만든다 | -| `tsconfig.json#references` + `composite` | `tsc -b`의 incremental typecheck | 런타임 import와 무관 | - -이 둘을 혼동하면 "왜 `dist/`가 있는데 수정이 즉시 반영되지?"라거나, 반대로 "`dist/`를 지웠더니 typecheck가 이상해진다"는 혼선이 생긴다. - -## 새 패키지를 추가할 때의 결정 트리 - -``` -새 패키지가 npm에 배포되거나 외부 소비자가 있는가? -├── No (internal 전용) -│ └── source-level exports -│ package.json: -│ "exports": { -│ ".": { -│ "types": "./src/index.ts", -│ "default": "./src/index.ts" -│ } -│ } -│ Next.js로 import한다면 transpilePackages에 추가 -│ -└── Yes (또는 미래에 그럴 수 있음) - └── 커스텀 조건 + dist 폴백 - package.json: - "exports": { - ".": { - "@repo/source": "./src/index.ts", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - } - + 빌드 스크립트 - + Next.js로 import한다면 위의 (A)/(B) 결정 -``` - -두 경우 모두 **로컬 워크스페이스는 항상 `.ts` 소스를 본다**는 불변식을 지킨다. 빌드 산출물은 외부 경계에서만 등장한다. - -## 정리 - -| 결정 | 이유 | -|------|------| -| `@repo/sample`은 source-level exports만 사용 | internal 전용. dist가 필요 없으므로 가장 단순한 형태로 충분 | -| `tsconfig.base.json`에 `customConditions: ["@repo/source"]` 미리 등록 | 빌드 산출물이 있는 패키지가 추가될 때, 워크스페이스 측에 추가 설정 없이 바로 매칭되도록 | -| `vite.config.ts`에 `resolve.conditions` 미리 등록 | 같은 이유. Vite 측 무대장치 | -| Next.js는 `transpilePackages`로 source-level 패키지 처리 | Next의 기본 가정(`node_modules`는 빌드 완료)을 우회 | -| `packages/sample/dist/`는 project references 용도 | 런타임과 무관. 04 문서 참조 | - -다음: [04. Turborepo + tsconfig references](./04-turbo-and-tsconfig-references.md) diff --git a/docs/04-turbo-and-tsconfig-references.md b/docs/04-turbo-and-tsconfig-references.md deleted file mode 100644 index dfc11aa..0000000 --- a/docs/04-turbo-and-tsconfig-references.md +++ /dev/null @@ -1,187 +0,0 @@ -# 04. Turborepo + tsconfig references - -이 템플릿은 두 가지 의존 그래프를 명시적으로 관리한다. - -- **TypeScript의 그래프** — `tsconfig.json#references`로 표현. `tsc -b`와 에디터의 incremental typecheck를 위한 것. -- **태스크의 그래프** — `turbo.json#tasks.*.dependsOn`으로 표현. `pnpm typecheck`, `pnpm build` 같은 태스크가 어느 패키지부터 어느 순서로 실행되어야 하는지를 결정한다. - -두 그래프는 같은 워크스페이스 의존 관계를 다른 도구의 언어로 두 번 적은 것이다. 굳이 두 번 적는 이유가 이 문서의 주제다. - -## tsconfig references — `tsc`의 의존 그래프 - -### 각 워크스페이스의 references - -```jsonc -// apps/tanstack-sample/tsconfig.json -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { "jsx": "react-jsx" }, - "references": [{ "path": "../../packages/sample" }] -} -``` - -```jsonc -// apps/next-sample/tsconfig.json -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { "jsx": "preserve" }, - "references": [{ "path": "../../packages/sample" }] -} -``` - -`references`는 "이 프로젝트는 `packages/sample`에 의존한다"고 TypeScript에 알리는 선언이다. - -1. **Incremental typecheck** — `tsc -b`가 각 프로젝트의 `.tsbuildinfo`를 캐시한다. -2. **순서 보장** — `tsc -b apps/tanstack-sample`을 실행하면 `packages/sample`의 typecheck가 먼저 끝난 뒤 앱이 검사된다. - -### `composite: true`가 references의 전제 - -`references`로 가리켜지는 쪽 프로젝트는 반드시 `composite: true`여야 한다. - -```jsonc -// packages/sample/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true, - "outDir": "dist", - "rootDir": "src", - "noEmit": false - } -} -``` - -`composite`가 켜지면 TypeScript는 그 프로젝트의 선언 파일을 emit해야 한다. 다른 프로젝트가 이 패키지를 typecheck할 때 소스를 다시 파싱하지 않고 `.d.ts`를 읽도록 하기 위한 최적화다. - -> 주의: 여기서 만들어지는 `dist/`는 런타임에 쓰이지 않는다. 런타임/타입 import는 `package.json#exports`로 따로 결정되며 ([03 문서](./03-live-types.md) 참조), 현재는 `src/index.ts`를 가리킨다. - -### 루트 `tsconfig.json` — 그래프 전체의 진입점 - -```jsonc -// tsconfig.json (root) -{ - "extends": "./tsconfig.base.json", - "compilerOptions": {}, - "files": [], - "references": [ - { "path": "apps/tanstack-sample" }, - { "path": "apps/next-sample" }, - { "path": "packages/sample" } - ] -} -``` - -`files: []`로 자기 자신은 아무 소스도 가지지 않고, `references`로 모든 워크스페이스를 가리킨다. - -- `tsc -b`만 실행해도 모든 프로젝트가 의존 순서대로 빌드된다. -- 에디터가 그래프 전체를 알게 되어 cross-package "Go to Definition", "Find All References"가 정확해진다. - -## Turborepo — 태스크의 의존 그래프 - -### `turbo.json`의 핵심 - -```jsonc -// turbo.json -{ - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**", ".output/**", "dist/**"] - }, - "typecheck": { - "dependsOn": ["^typecheck"], - "outputs": [] - }, - "test": { - "dependsOn": ["^test"], - "outputs": [] - }, - "dev": { - "cache": false, - "persistent": true - } - } -} -``` - -세 가지를 보장한다. - -1. **`^` prefix = 의존 패키지의 같은 타깃 먼저** — `build` 또는 `typecheck`를 앱에 대해 실행하면 Turbo가 먼저 `packages/sample`의 같은 타깃을 실행한다. -2. **캐시** — 입력이 같으면 결과를 다시 계산하지 않는다. 이 템플릿의 `typecheck`/`test`는 워크스페이스별 출력물이 일정하지 않아 `outputs: []`로 둔다. -3. **`dev`는 캐시 안 함** — 장시간 실행되는 워치 모드는 캐시 대상이 아니다. - -Turbo는 패키지 그래프를 각 워크스페이스의 `package.json#dependencies`에서 읽는다. 그래서 `apps/*/package.json`의 `"@repo/sample": "workspace:*"`가 태스크 순서에도 영향을 준다. - -### Turbo가 references 위에 더하는 것 - -Turbo와 `tsconfig#references`는 둘 다 "의존 패키지를 먼저 처리한다"는 같은 아이디어를 표현한다. 차이는 적용 범위다. - -| 도구 | 무엇을 순서 짓는가 | 캐시 | -|------|-------------------|------| -| `tsc -b` (references 기반) | TypeScript 컴파일/typecheck만 | `.tsbuildinfo` 파일 단위, TypeScript 한정 | -| Turbo | 임의의 npm 스크립트 (`build`, `typecheck`, `test`, …) | content-hash 기반, 도구 무관 | - -같은 그래프지만 Turbo는 `pnpm build`에도, `pnpm test`에도, 향후 추가될 어떤 태스크에도 적용된다. `tsc`만 알았다면 `test` 같은 태스크에 대해서는 따로 의존 순서를 관리해야 했을 것이다. - -### 루트 `package.json`의 진입점 - -```jsonc -// package.json (root) -{ - "scripts": { - "build": "turbo run build", - "dev": "turbo run dev", - "typecheck": "turbo run typecheck", - "test": "turbo run test", - "lint": "oxlint -c .oxlintrc.json", - "check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm sheriff && pnpm knip" - } -} -``` - -`pnpm typecheck` 한 줄이 다음을 보장한다. - -```text -turbo run typecheck - ↓ tasks.typecheck.dependsOn: ["^typecheck"] 적용 - ↓ package.json dependencies로 의존 그래프 추출 - ↓ packages/sample 먼저, apps/* 나중에 - ↓ 각 패키지의 package.json#scripts.typecheck 실행 - ↓ 입력/출력 해시가 같으면 캐시 hit -``` - -각 워크스페이스의 `typecheck` 스크립트는 자기 도구를 자유롭게 고른다. - -| 워크스페이스 | `typecheck` 스크립트 | 사용하는 도구 | -|-------------|----------------------|---------------| -| `packages/sample` | `tsc -b` | composite + project references | -| `apps/tanstack-sample` | `tsc --noEmit` | references는 자체적으로 따라감 | -| `apps/next-sample` | `tsc --noEmit` | 동일 | - -## 두 그래프를 동기화하는 책임 - -같은 의존 관계가 세 군데에 적힌다. - -1. `package.json#dependencies`의 `"@repo/sample": "workspace:*"` -2. 그 패키지를 쓰는 워크스페이스의 `tsconfig.json#references` -3. 루트 `tsconfig.json#references` (전체 목록) - -세 곳이 일치해야 한다. - -- (1) 누락 → pnpm이 심볼릭 링크를 만들지 않음 → import 자체가 깨짐 -- (2) 누락 → typecheck는 통과하지만 `tsc -b`의 incremental 캐시가 부정확해짐 -- (3) 누락 → 에디터가 cross-package 기능에서 그 패키지를 못 찾음 - -## 정리 - -| 도구 | 무엇을 책임지는가 | -|------|-------------------| -| `pnpm` (`workspace:*`) | 패키지가 서로 import 가능한 상태를 만든다 | -| `package.json#exports` | import가 어느 파일로 해석되는지 결정 (런타임 + TypeScript 모두) | -| `tsconfig#references` + `composite` | TypeScript 한정의 incremental typecheck와 빌드 순서 | -| 루트 `tsconfig.json` | 그래프 전체의 진입점. 에디터가 cross-package 기능을 정확히 제공하도록 | -| `turbo.json#tasks.*.dependsOn` | 임의의 npm 스크립트에 같은 의존 순서를 적용. 캐시 | -| `pnpm