diff --git a/.agents/skills/pinia/GENERATION.md b/.agents/skills/pinia/GENERATION.md new file mode 100644 index 000000000..a2c0f71b3 --- /dev/null +++ b/.agents/skills/pinia/GENERATION.md @@ -0,0 +1,5 @@ +# Generation Info + +- **Source:** `sources/pinia` +- **Git SHA:** `55dbfc5c20d4461748996aa74d8c0913e89fb98e` +- **Generated:** 2026-01-28 diff --git a/.agents/skills/pinia/SKILL.md b/.agents/skills/pinia/SKILL.md new file mode 100644 index 000000000..89cfa7ded --- /dev/null +++ b/.agents/skills/pinia/SKILL.md @@ -0,0 +1,59 @@ +--- +name: pinia +description: Pinia official Vue state management library, type-safe and extensible. Use when defining stores, working with state/getters/actions, or implementing store patterns in Vue apps. +metadata: + author: Anthony Fu + version: "2026.1.28" + source: Generated from https://github.com/vuejs/pinia, scripts located at https://github.com/antfu/skills +--- + +# Pinia + +Pinia is the official state management library for Vue, designed to be intuitive and type-safe. It supports both Options API and Composition API styles, with first-class TypeScript support and devtools integration. + +> The skill is based on Pinia v3.0.4, generated at 2026-01-28. + +## Core References + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Stores | Defining stores, state, getters, actions, storeToRefs, subscriptions | [core-stores](references/core-stores.md) | + +## Features + +### Extensibility + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Plugins | Extend stores with custom properties, state, and behavior | [features-plugins](references/features-plugins.md) | + +### Composability + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Composables | Using Vue composables within stores (VueUse, etc.) | [features-composables](references/features-composables.md) | +| Composing Stores | Store-to-store communication, avoiding circular dependencies | [features-composing-stores](references/features-composing-stores.md) | + +## Best Practices + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Testing | Unit testing with @pinia/testing, mocking, stubbing | [best-practices-testing](references/best-practices-testing.md) | +| Outside Components | Using stores in navigation guards, plugins, middlewares | [best-practices-outside-component](references/best-practices-outside-component.md) | + +## Advanced + +| Topic | Description | Reference | +|-------|-------------|-----------| +| SSR | Server-side rendering, state hydration | [advanced-ssr](references/advanced-ssr.md) | +| Nuxt | Nuxt integration, auto-imports, SSR best practices | [advanced-nuxt](references/advanced-nuxt.md) | +| HMR | Hot module replacement for development | [advanced-hmr](references/advanced-hmr.md) | + +## Key Recommendations + +- **Prefer Setup Stores** for complex logic, composables, and watchers +- **Use `storeToRefs()`** when destructuring state/getters to preserve reactivity +- **Actions can be destructured directly** - they're bound to the store +- **Call stores inside functions** not at module scope, especially for SSR +- **Add HMR support** to each store for better development experience +- **Use `@pinia/testing`** for component tests with mocked stores diff --git a/.agents/skills/pinia/references/advanced-hmr.md b/.agents/skills/pinia/references/advanced-hmr.md new file mode 100644 index 000000000..3eef5c7ef --- /dev/null +++ b/.agents/skills/pinia/references/advanced-hmr.md @@ -0,0 +1,61 @@ +--- +name: hot-module-replacement +description: Enable HMR to preserve store state during development +--- + +# Hot Module Replacement (HMR) + +Pinia supports HMR to edit stores without page reload, preserving existing state. + +## Setup + +Add this snippet after each store definition: + +```ts +import { defineStore, acceptHMRUpdate } from 'pinia' + +export const useAuth = defineStore('auth', { + // store options... +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot)) +} +``` + +## Setup Store Example + +```ts +import { defineStore, acceptHMRUpdate } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const increment = () => count.value++ + return { count, increment } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot)) +} +``` + +## Bundler Support + +- **Vite:** Officially supported via `import.meta.hot` +- **Webpack:** Uses `import.meta.webpackHot` +- Any bundler implementing the `import.meta.hot` spec should work + +## Nuxt + +With `@pinia/nuxt`, `acceptHMRUpdate` is auto-imported but you still need to add the HMR snippet manually. + +## Benefits + +- Edit store logic without losing state +- Add/remove state, actions, and getters on the fly +- Faster development iteration + + diff --git a/.agents/skills/pinia/references/advanced-nuxt.md b/.agents/skills/pinia/references/advanced-nuxt.md new file mode 100644 index 000000000..569da63b2 --- /dev/null +++ b/.agents/skills/pinia/references/advanced-nuxt.md @@ -0,0 +1,119 @@ +--- +name: nuxt-integration +description: Using Pinia with Nuxt - auto-imports, SSR, and best practices +--- + +# Nuxt Integration + +Pinia works seamlessly with Nuxt 3/4, handling SSR, serialization, and XSS protection automatically. + +## Installation + +```bash +npx nuxi@latest module add pinia +``` + +This installs both `@pinia/nuxt` and `pinia`. If `pinia` isn't installed, add it manually. + +> **npm users:** If you get `ERESOLVE unable to resolve dependency tree`, add to `package.json`: +> ```json +> "overrides": { "vue": "latest" } +> ``` + +## Configuration + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@pinia/nuxt'], +}) +``` + +## Auto Imports + +These are automatically available: +- `usePinia()` - get pinia instance +- `defineStore()` - define stores +- `storeToRefs()` - extract reactive refs +- `acceptHMRUpdate()` - HMR support + +**All stores in `app/stores/` (Nuxt 4) or `stores/` are auto-imported.** + +### Custom Store Directories + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@pinia/nuxt'], + pinia: { + storesDirs: ['./stores/**', './custom-folder/stores/**'], + }, +}) +``` + +## Fetching Data in Pages + +Use `callOnce()` for SSR-friendly data fetching: + +```vue + +``` + +### Refetch on Navigation + +```vue + +``` + +## Using Stores Outside Components + +In navigation guards, middlewares, or other stores, pass the `pinia` instance: + +```ts +// middleware/auth.ts +export default defineNuxtRouteMiddleware((to) => { + const nuxtApp = useNuxtApp() + const store = useStore(nuxtApp.$pinia) + + if (to.meta.requiresAuth && !store.isLoggedIn) { + return navigateTo('/login') + } +}) +``` + +Most of the time, you don't need this - just use stores in components or other injection-aware contexts. + +## Pinia Plugins with Nuxt + +Create a Nuxt plugin: + +```ts +// plugins/myPiniaPlugin.ts +import { PiniaPluginContext } from 'pinia' + +function MyPiniaPlugin({ store }: PiniaPluginContext) { + store.$subscribe((mutation) => { + console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}`) + }) + return { creationTime: new Date() } +} + +export default defineNuxtPlugin(({ $pinia }) => { + $pinia.use(MyPiniaPlugin) +}) +``` + + diff --git a/.agents/skills/pinia/references/advanced-ssr.md b/.agents/skills/pinia/references/advanced-ssr.md new file mode 100644 index 000000000..2972f3a99 --- /dev/null +++ b/.agents/skills/pinia/references/advanced-ssr.md @@ -0,0 +1,121 @@ +--- +name: server-side-rendering +description: SSR setup, state hydration, and avoiding cross-request state pollution +--- + +# Server Side Rendering (SSR) + +Pinia works with SSR when stores are called at the top of `setup`, getters, or actions. + +> **Using Nuxt?** See the [Nuxt integration](advanced-nuxt.md) instead. + +## Basic Usage + +```vue + +``` + +## Using Store Outside setup() + +Pass the `pinia` instance explicitly: + +```ts +const pinia = createPinia() +const app = createApp(App) +app.use(router) +app.use(pinia) + +router.beforeEach((to) => { + // ✅ Pass pinia for correct SSR context + const main = useMainStore(pinia) + + if (to.meta.requiresAuth && !main.isLoggedIn) { + return '/login' + } +}) +``` + +## serverPrefetch() + +Access pinia via `this.$pinia`: + +```ts +export default { + serverPrefetch() { + const store = useStore(this.$pinia) + return store.fetchData() + }, +} +``` + +## onServerPrefetch() + +Works normally: + +```vue + +``` + +## State Hydration + +Serialize state on server and hydrate on client. + +### Server Side + +Use [devalue](https://github.com/Rich-Harris/devalue) for XSS-safe serialization: + +```ts +import devalue from 'devalue' +import { createPinia } from 'pinia' + +const pinia = createPinia() +const app = createApp(App) +app.use(router) +app.use(pinia) + +// After rendering, state is available +const serializedState = devalue(pinia.state.value) +// Inject into HTML as global variable +``` + +### Client Side + +Hydrate before any `useStore()` call: + +```ts +const pinia = createPinia() +const app = createApp(App) +app.use(pinia) + +// Hydrate from serialized state (e.g., from window.__pinia) +if (typeof window !== 'undefined') { + pinia.state.value = JSON.parse(window.__pinia) +} +``` + +## SSR Examples + +- [Vitesse template](https://github.com/antfu/vitesse/blob/main/src/modules/pinia.ts) +- [vite-plugin-ssr](https://vite-plugin-ssr.com/pinia) + +## Key Points + +1. Call stores inside functions, not at module scope +2. Pass `pinia` instance when using stores outside components in SSR +3. Hydrate state before calling any `useStore()` +4. Use `devalue` or similar for safe serialization +5. Avoid cross-request state pollution by creating fresh pinia per request + + diff --git a/.agents/skills/pinia/references/best-practices-outside-component.md b/.agents/skills/pinia/references/best-practices-outside-component.md new file mode 100644 index 000000000..126f7a602 --- /dev/null +++ b/.agents/skills/pinia/references/best-practices-outside-component.md @@ -0,0 +1,115 @@ +--- +name: using-stores-outside-components +description: Correctly using stores in navigation guards, plugins, and other non-component contexts +--- + +# Using Stores Outside Components + +Stores need the `pinia` instance, which is automatically injected in components. Outside components, you may need to provide it manually. + +## Single Page Applications + +Call stores **after** pinia is installed: + +```ts +import { useUserStore } from '@/stores/user' +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import App from './App.vue' + +// ❌ Fails - pinia not created yet +const userStore = useUserStore() + +const pinia = createPinia() +const app = createApp(App) +app.use(pinia) + +// ✅ Works - pinia is active +const userStore = useUserStore() +``` + +## Navigation Guards + +**Wrong:** Call at module level + +```ts +import { createRouter } from 'vue-router' +const router = createRouter({ /* ... */ }) + +// ❌ May fail depending on import order +const store = useUserStore() + +router.beforeEach((to) => { + if (store.isLoggedIn) { /* ... */ } +}) +``` + +**Correct:** Call inside the guard + +```ts +router.beforeEach((to) => { + // ✅ Called after pinia is installed + const store = useUserStore() + + if (to.meta.requiresAuth && !store.isLoggedIn) { + return '/login' + } +}) +``` + +## SSR Applications + +Always pass the `pinia` instance to `useStore()`: + +```ts +const pinia = createPinia() +const app = createApp(App) +app.use(router) +app.use(pinia) + +router.beforeEach((to) => { + // ✅ Pass pinia instance + const main = useMainStore(pinia) + + if (to.meta.requiresAuth && !main.isLoggedIn) { + return '/login' + } +}) +``` + +## serverPrefetch() + +Access pinia via `this.$pinia`: + +```ts +export default { + serverPrefetch() { + const store = useStore(this.$pinia) + return store.fetchData() + }, +} +``` + +## onServerPrefetch() + +Works normally in ` +``` + +## Key Takeaway + +Defer `useStore()` calls to functions that run after pinia is installed, rather than calling at module scope. + + diff --git a/.agents/skills/pinia/references/best-practices-testing.md b/.agents/skills/pinia/references/best-practices-testing.md new file mode 100644 index 000000000..7227cd474 --- /dev/null +++ b/.agents/skills/pinia/references/best-practices-testing.md @@ -0,0 +1,212 @@ +--- +name: testing +description: Unit testing stores and components with @pinia/testing +--- + +# Testing Stores + +## Unit Testing Stores + +Create a fresh pinia instance for each test: + +```ts +import { setActivePinia, createPinia } from 'pinia' +import { useCounterStore } from '../src/stores/counter' + +describe('Counter Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('increments', () => { + const counter = useCounterStore() + expect(counter.n).toBe(0) + counter.increment() + expect(counter.n).toBe(1) + }) +}) +``` + +### With Plugins + +```ts +import { setActivePinia, createPinia } from 'pinia' +import { createApp } from 'vue' +import { somePlugin } from '../src/stores/plugin' + +const app = createApp({}) + +beforeEach(() => { + const pinia = createPinia().use(somePlugin) + app.use(pinia) + setActivePinia(pinia) +}) +``` + +## Testing Components + +Install `@pinia/testing`: + +```bash +npm i -D @pinia/testing +``` + +Use `createTestingPinia()`: + +```ts +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { useSomeStore } from '@/stores/myStore' + +const wrapper = mount(Counter, { + global: { + plugins: [createTestingPinia()], + }, +}) + +const store = useSomeStore() + +// Manipulate state directly +store.name = 'new name' +store.$patch({ name: 'new name' }) + +// Actions are stubbed by default +store.someAction() +expect(store.someAction).toHaveBeenCalledTimes(1) +``` + +## Initial State + +Set initial state for tests: + +```ts +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + initialState: { + counter: { n: 20 }, // Store name → initial state + }, + }), + ], + }, +}) +``` + +## Action Stubbing + +### Execute Real Actions + +```ts +createTestingPinia({ stubActions: false }) +``` + +### Selective Stubbing + +```ts +// Only stub specific actions +createTestingPinia({ + stubActions: ['increment', 'reset'], +}) + +// Or use a function +createTestingPinia({ + stubActions: (actionName, store) => { + if (actionName.startsWith('set')) return true + return false + }, +}) +``` + +### Mock Action Return Values + +```ts +import type { Mock } from 'vitest' + +// After getting store +store.someAction.mockResolvedValue('mocked value') +``` + +## Mocking Getters + +Getters are writable in tests: + +```ts +const pinia = createTestingPinia() +const counter = useCounterStore(pinia) + +counter.double = 3 // Override computed value + +// Reset to default behavior +counter.double = undefined +counter.double // Now computed normally +``` + +## Custom Spy Function + +If not using Jest/Vitest with globals: + +```ts +import { vi } from 'vitest' + +createTestingPinia({ + createSpy: vi.fn, +}) +``` + +With Sinon: + +```ts +import sinon from 'sinon' + +createTestingPinia({ + createSpy: sinon.spy, +}) +``` + +## Pinia Plugins in Tests + +Pass plugins to `createTestingPinia()`: + +```ts +import { somePlugin } from '../src/stores/plugin' + +createTestingPinia({ + stubActions: false, + plugins: [somePlugin], +}) +``` + +**Don't use** `testingPinia.use(MyPlugin)` - pass plugins in options. + +## Type-Safe Mocked Store + +```ts +import type { Mock } from 'vitest' +import type { Store, StoreDefinition } from 'pinia' + +function mockedStore unknown>( + useStore: TStoreDef +): TStoreDef extends StoreDefinition + ? Store, { + [K in keyof Actions]: Actions[K] extends (...args: any[]) => any + ? Mock + : Actions[K] + }> + : ReturnType { + return useStore() as any +} + +// Usage +const store = mockedStore(useSomeStore) +store.someAction.mockResolvedValue('value') // Typed! +``` + +## E2E Tests + +No special handling needed - Pinia works normally. + + diff --git a/.agents/skills/pinia/references/core-stores.md b/.agents/skills/pinia/references/core-stores.md new file mode 100644 index 000000000..ea6a72bb6 --- /dev/null +++ b/.agents/skills/pinia/references/core-stores.md @@ -0,0 +1,389 @@ +--- +name: stores +description: Defining stores, state, getters, and actions in Pinia +--- + +# Pinia Stores + +Stores are defined using `defineStore()` with a unique name. Each store has three core concepts: **state**, **getters**, and **actions**. + +## Defining Stores + +### Option Stores + +Similar to Vue's Options API: + +```ts +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', { + state: () => ({ + count: 0, + name: 'Eduardo', + }), + getters: { + doubleCount: (state) => state.count * 2, + }, + actions: { + increment() { + this.count++ + }, + }, +}) +``` + +Think of `state` as `data`, `getters` as `computed`, and `actions` as `methods`. + +### Setup Stores (Recommended) + +Uses Composition API syntax - more flexible and powerful: + +```ts +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const name = ref('Eduardo') + const doubleCount = computed(() => count.value * 2) + + function increment() { + count.value++ + } + + return { count, name, doubleCount, increment } +}) +``` + +In Setup Stores: `ref()` → state, `computed()` → getters, `function()` → actions. + +**Important:** You must return all state properties for Pinia to track them. + +### Using Stores + +```vue + +``` + +### Destructuring with storeToRefs + +```vue + +``` + +--- + +## State + +State is defined as a function returning the initial state. + +### TypeScript + +Type inference works automatically. For complex types: + +```ts +interface UserInfo { + name: string + age: number +} + +export const useUserStore = defineStore('user', { + state: () => ({ + userList: [] as UserInfo[], + user: null as UserInfo | null, + }), +}) +``` + +Or use an interface for the return type: + +```ts +interface State { + userList: UserInfo[] + user: UserInfo | null +} + +export const useUserStore = defineStore('user', { + state: (): State => ({ + userList: [], + user: null, + }), +}) +``` + +### Accessing and Modifying + +```ts +const store = useStore() +store.count++ +``` + +```vue + +``` + +### Mutating with $patch + +Apply multiple changes at once: + +```ts +// Object syntax +store.$patch({ + count: store.count + 1, + name: 'DIO', +}) + +// Function syntax (for complex mutations) +store.$patch((state) => { + state.items.push({ name: 'shoes', quantity: 1 }) + state.hasChanged = true +}) +``` + +### Resetting State + +Option Stores have built-in `$reset()`. For Setup Stores, implement your own: + +```ts +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + + function $reset() { + count.value = 0 + } + + return { count, $reset } +}) +``` + +### Subscribing to State Changes + +```ts +cartStore.$subscribe((mutation, state) => { + mutation.type // 'direct' | 'patch object' | 'patch function' + mutation.storeId // 'cart' + mutation.payload // patch object (only for 'patch object') + + localStorage.setItem('cart', JSON.stringify(state)) +}) + +// Options +cartStore.$subscribe(callback, { flush: 'sync' }) // Immediate +cartStore.$subscribe(callback, { detached: true }) // Keep after unmount +``` + +--- + +## Getters + +Getters are computed values, equivalent to Vue's `computed()`. + +### Basic Getters + +```ts +getters: { + doubleCount: (state) => state.count * 2, +} +``` + +### Accessing Other Getters + +Use `this` with explicit return type: + +```ts +getters: { + doubleCount: (state) => state.count * 2, + doublePlusOne(): number { + return this.doubleCount + 1 + }, +}, +``` + +### Getters with Arguments + +Return a function (note: loses caching): + +```ts +getters: { + getUserById: (state) => { + return (userId: string) => state.users.find((user) => user.id === userId) + }, +}, +``` + +Cache within parameterized getters: + +```ts +getters: { + getActiveUserById(state) { + const activeUsers = state.users.filter((user) => user.active) + return (userId: string) => activeUsers.find((user) => user.id === userId) + }, +}, +``` + +### Accessing Other Stores in Getters + +```ts +import { useOtherStore } from './other-store' + +getters: { + combined(state) { + const otherStore = useOtherStore() + return state.localData + otherStore.data + }, +}, +``` + +--- + +## Actions + +Actions are methods for business logic. Unlike getters, they can be asynchronous. + +### Defining Actions + +```ts +actions: { + increment() { + this.count++ + }, + randomizeCounter() { + this.count = Math.round(100 * Math.random()) + }, +}, +``` + +### Async Actions + +```ts +actions: { + async registerUser(login: string, password: string) { + try { + this.userData = await api.post({ login, password }) + } catch (error) { + return error + } + }, +}, +``` + +### Accessing Other Stores in Actions + +```ts +import { useAuthStore } from './auth-store' + +actions: { + async fetchUserPreferences() { + const auth = useAuthStore() + if (auth.isAuthenticated) { + this.preferences = await fetchPreferences() + } + }, +}, +``` + +**SSR:** Call all `useStore()` before any `await`: + +```ts +async orderCart() { + // ✅ Call stores before await + const user = useUserStore() + + await apiOrderCart(user.token, this.items) + // ❌ Don't call useStore() after await in SSR +} +``` + +### Subscribing to Actions + +```ts +const unsubscribe = someStore.$onAction( + ({ name, store, args, after, onError }) => { + const startTime = Date.now() + console.log(`Start "${name}" with params [${args.join(', ')}]`) + + after((result) => { + console.log(`Finished "${name}" after ${Date.now() - startTime}ms`) + }) + + onError((error) => { + console.warn(`Failed "${name}": ${error}`) + }) + } +) + +unsubscribe() // Cleanup +``` + +Keep subscription after component unmount: + +```ts +someStore.$onAction(callback, true) +``` + +--- + +## Options API Helpers + +```ts +import { mapState, mapWritableState, mapActions } from 'pinia' +import { useCounterStore } from '../stores/counter' + +export default { + computed: { + // Readonly state/getters + ...mapState(useCounterStore, ['count', 'doubleCount']), + // Writable state + ...mapWritableState(useCounterStore, ['count']), + }, + methods: { + ...mapActions(useCounterStore, ['increment']), + }, +} +``` + +--- + +## Accessing Global Providers in Setup Stores + +```ts +import { inject } from 'vue' +import { useRoute } from 'vue-router' +import { defineStore } from 'pinia' + +export const useSearchFilters = defineStore('search-filters', () => { + const route = useRoute() + const appProvided = inject('appProvided') + + // Don't return these - access them directly in components + return { /* ... */ } +}) +``` + + diff --git a/.agents/skills/pinia/references/features-composables.md b/.agents/skills/pinia/references/features-composables.md new file mode 100644 index 000000000..79f7d94b1 --- /dev/null +++ b/.agents/skills/pinia/references/features-composables.md @@ -0,0 +1,114 @@ +--- +name: composables-in-stores +description: Using Vue composables within Pinia stores +--- + +# Composables in Stores + +Pinia stores can leverage Vue composables for reusable stateful logic. + +## Option Stores + +Call composables inside the `state` property, but only those returning writable refs: + +```ts +import { defineStore } from 'pinia' +import { useLocalStorage } from '@vueuse/core' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: useLocalStorage('pinia/auth/login', 'bob'), + }), +}) +``` + +**Works:** Composables returning `ref()`: +- `useLocalStorage` +- `useAsyncState` + +**Doesn't work in Option Stores:** +- Composables exposing functions +- Composables exposing readonly data + +## Setup Stores + +More flexible - can use almost any composable: + +```ts +import { defineStore } from 'pinia' +import { useMediaControls } from '@vueuse/core' +import { ref } from 'vue' + +export const useVideoPlayer = defineStore('video', () => { + const videoElement = ref() + const src = ref('/data/video.mp4') + const { playing, volume, currentTime, togglePictureInPicture } = + useMediaControls(videoElement, { src }) + + function loadVideo(element: HTMLVideoElement, newSrc: string) { + videoElement.value = element + src.value = newSrc + } + + return { + src, + playing, + volume, + currentTime, + loadVideo, + togglePictureInPicture, + } +}) +``` + +**Note:** Don't return non-serializable DOM refs like `videoElement` - they're internal implementation details. + +## SSR Considerations + +### Option Stores with hydrate() + +Define a `hydrate()` function to handle client-side hydration: + +```ts +import { defineStore } from 'pinia' +import { useLocalStorage } from '@vueuse/core' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: useLocalStorage('pinia/auth/login', 'bob'), + }), + + hydrate(state, initialState) { + // Ignore server state, read from browser + state.user = useLocalStorage('pinia/auth/login', 'bob') + }, +}) +``` + +### Setup Stores with skipHydrate() + +Mark state that shouldn't hydrate from server: + +```ts +import { defineStore, skipHydrate } from 'pinia' +import { useEyeDropper, useLocalStorage } from '@vueuse/core' + +export const useColorStore = defineStore('colors', () => { + const { isSupported, open, sRGBHex } = useEyeDropper() + const lastColor = useLocalStorage('lastColor', sRGBHex) + + return { + // Skip hydration for client-only state + lastColor: skipHydrate(lastColor), + open, // Function - no hydration needed + isSupported, // Boolean - not reactive + } +}) +``` + +`skipHydrate()` only applies to state properties (refs), not functions or non-reactive values. + + diff --git a/.agents/skills/pinia/references/features-composing-stores.md b/.agents/skills/pinia/references/features-composing-stores.md new file mode 100644 index 000000000..948f8b5b8 --- /dev/null +++ b/.agents/skills/pinia/references/features-composing-stores.md @@ -0,0 +1,134 @@ +--- +name: composing-stores +description: Store-to-store communication and avoiding circular dependencies +--- + +# Composing Stores + +Stores can use each other for shared state and logic. + +## Rule: Avoid Circular Dependencies + +Two stores cannot directly read each other's state during setup: + +```ts +// ❌ Infinite loop +const useX = defineStore('x', () => { + const y = useY() + y.name // Don't read here! + return { name: ref('X') } +}) + +const useY = defineStore('y', () => { + const x = useX() + x.name // Don't read here! + return { name: ref('Y') } +}) +``` + +**Solution:** Read in getters, computed, or actions: + +```ts +const useX = defineStore('x', () => { + const y = useY() + + // ✅ Read in computed/actions + function doSomething() { + const yName = y.name + } + + return { name: ref('X'), doSomething } +}) +``` + +## Setup Stores: Use Store at Top + +```ts +import { defineStore } from 'pinia' +import { useUserStore } from './user' + +export const useCartStore = defineStore('cart', () => { + const user = useUserStore() + const list = ref([]) + + const summary = computed(() => { + return `Hi ${user.name}, you have ${list.value.length} items` + }) + + function purchase() { + return apiPurchase(user.id, list.value) + } + + return { list, summary, purchase } +}) +``` + +## Shared Getters + +Call `useStore()` inside a getter: + +```ts +import { useUserStore } from './user' + +export const useCartStore = defineStore('cart', { + getters: { + summary(state) { + const user = useUserStore() + return `Hi ${user.name}, you have ${state.list.length} items` + }, + }, +}) +``` + +## Shared Actions + +Call `useStore()` inside an action: + +```ts +import { useUserStore } from './user' +import { apiOrderCart } from './api' + +export const useCartStore = defineStore('cart', { + actions: { + async orderCart() { + const user = useUserStore() + + try { + await apiOrderCart(user.token, this.items) + this.emptyCart() + } catch (err) { + displayError(err) + } + }, + }, +}) +``` + +## SSR: Call Stores Before Await + +In async actions, call all stores before any `await`: + +```ts +actions: { + async orderCart() { + // ✅ All useStore() calls before await + const user = useUserStore() + const analytics = useAnalyticsStore() + + try { + await apiOrderCart(user.token, this.items) + // ❌ Don't call useStore() after await (SSR issue) + // const otherStore = useOtherStore() + } catch (err) { + displayError(err) + } + }, +} +``` + +This ensures the correct Pinia instance is used during SSR. + + diff --git a/.agents/skills/pinia/references/features-plugins.md b/.agents/skills/pinia/references/features-plugins.md new file mode 100644 index 000000000..c4355ac60 --- /dev/null +++ b/.agents/skills/pinia/references/features-plugins.md @@ -0,0 +1,203 @@ +--- +name: plugins +description: Extend stores with custom properties, methods, and behavior +--- + +# Plugins + +Plugins extend all stores with custom properties, methods, or behavior. + +## Basic Plugin + +```ts +import { createPinia } from 'pinia' + +function SecretPiniaPlugin() { + return { secret: 'the cake is a lie' } +} + +const pinia = createPinia() +pinia.use(SecretPiniaPlugin) + +// In any store +const store = useStore() +store.secret // 'the cake is a lie' +``` + +## Plugin Context + +Plugins receive a context object: + +```ts +import { PiniaPluginContext } from 'pinia' + +export function myPiniaPlugin(context: PiniaPluginContext) { + context.pinia // pinia instance + context.app // Vue app instance + context.store // store being augmented + context.options // store definition options +} +``` + +## Adding Properties + +Return an object to add properties (tracked in devtools): + +```ts +pinia.use(() => ({ hello: 'world' })) +``` + +Or set directly on store: + +```ts +pinia.use(({ store }) => { + store.hello = 'world' + // For devtools visibility in dev mode + if (process.env.NODE_ENV === 'development') { + store._customProperties.add('hello') + } +}) +``` + +## Adding State + +Add to both `store` and `store.$state` for SSR/devtools: + +```ts +import { toRef, ref } from 'vue' + +pinia.use(({ store }) => { + if (!store.$state.hasOwnProperty('hasError')) { + const hasError = ref(false) + store.$state.hasError = hasError + } + store.hasError = toRef(store.$state, 'hasError') +}) +``` + +## Adding External Properties + +Wrap non-reactive objects with `markRaw()`: + +```ts +import { markRaw } from 'vue' +import { router } from './router' + +pinia.use(({ store }) => { + store.router = markRaw(router) +}) +``` + +## Custom Store Options + +Define custom options consumed by plugins: + +```ts +// Store definition +defineStore('search', { + actions: { + searchContacts() { /* ... */ }, + }, + debounce: { + searchContacts: 300, + }, +}) + +// Plugin reads custom option +import debounce from 'lodash/debounce' + +pinia.use(({ options, store }) => { + if (options.debounce) { + return Object.keys(options.debounce).reduce((acc, action) => { + acc[action] = debounce(store[action], options.debounce[action]) + return acc + }, {}) + } +}) +``` + +For Setup Stores, pass options as third argument: + +```ts +defineStore( + 'search', + () => { /* ... */ }, + { + debounce: { searchContacts: 300 }, + } +) +``` + +## TypeScript Augmentation + +### Custom Properties + +```ts +import 'pinia' +import type { Router } from 'vue-router' + +declare module 'pinia' { + export interface PiniaCustomProperties { + router: Router + hello: string + } +} +``` + +### Custom State + +```ts +declare module 'pinia' { + export interface PiniaCustomStateProperties { + hasError: boolean + } +} +``` + +### Custom Options + +```ts +declare module 'pinia' { + export interface DefineStoreOptionsBase { + debounce?: Partial, number>> + } +} +``` + +## Subscribe in Plugins + +```ts +pinia.use(({ store }) => { + store.$subscribe(() => { + // React to state changes + }) + store.$onAction(() => { + // React to actions + }) +}) +``` + +## Nuxt Plugin + +Create a Nuxt plugin to add Pinia plugins: + +```ts +// plugins/myPiniaPlugin.ts +import { PiniaPluginContext } from 'pinia' + +function MyPiniaPlugin({ store }: PiniaPluginContext) { + store.$subscribe((mutation) => { + console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}`) + }) + return { creationTime: new Date() } +} + +export default defineNuxtPlugin(({ $pinia }) => { + $pinia.use(MyPiniaPlugin) +}) +``` + + diff --git a/.agents/skills/tsdown/LICENSE.md b/.agents/skills/tsdown/LICENSE.md new file mode 100644 index 000000000..b88935cfe --- /dev/null +++ b/.agents/skills/tsdown/LICENSE.md @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2025-present VoidZero Inc. & Contributors +Copyright (c) 2024 Kevin Deng (https://github.com/sxzz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/tsdown/README.md b/.agents/skills/tsdown/README.md new file mode 100644 index 000000000..7774cc48c --- /dev/null +++ b/.agents/skills/tsdown/README.md @@ -0,0 +1,77 @@ +# tsdown Skills + +Agent skills that help AI coding agents understand and work with [tsdown](https://tsdown.dev), the elegant library bundler. + +## Installation + +```bash +npx skills add rolldown/tsdown +``` + +This will install all tsdown skills (including the migration skill). To install only the tsdown skill: + +```bash +npx skills add rolldown/tsdown --skill tsdown +``` + +## What's Included + +The tsdown skill provides Claude Code with knowledge about: + +- **Core Concepts** - What tsdown is, why use it, key features +- **Configuration** - Config file formats, options, multiple configs, workspace support +- **Build Options** - Entry points, output formats, type declarations, targets +- **Dependency Handling** - External/inline dependencies, auto-externalization +- **Output Enhancement** - Shims, CJS defaults, package exports +- **Framework Support** - React, Vue, Solid, Svelte integration +- **Advanced Features** - Plugins, hooks, programmatic API, Rolldown options +- **CLI Commands** - All CLI options and usage patterns +- **Migration** - Migrating from tsup to tsdown + +## Usage + +Once installed, Claude Code will automatically use tsdown knowledge when: + +- Building TypeScript/JavaScript libraries +- Configuring bundlers for library projects +- Setting up type declaration generation +- Working with multi-format builds (ESM, CJS, IIFE, UMD) +- Migrating from tsup +- Building framework component libraries + +### Example Prompts + +``` +Set up tsdown to build my TypeScript library with ESM and CJS formats +``` + +``` +Configure tsdown to generate type declarations and bundle for browsers +``` + +``` +Add React support to my tsdown config with Fast Refresh +``` + +``` +Help me migrate from tsup to tsdown +``` + +``` +Set up a monorepo build with tsdown workspace support +``` + +## Related Skills + +- **[tsdown-migrate](https://github.com/rolldown/tsdown/tree/main/skills/tsdown-migrate)** - Dedicated skill for migrating from tsup to tsdown, with complete option mappings, config transformations, and troubleshooting guidance. + +## Documentation + +- [tsdown Documentation](https://tsdown.dev) +- [GitHub Repository](https://github.com/rolldown/tsdown) +- [Rolldown](https://rolldown.rs) +- [Migration Guide](https://tsdown.dev/guide/migrate-from-tsup) + +## License + +MIT diff --git a/.agents/skills/tsdown/SKILL.md b/.agents/skills/tsdown/SKILL.md new file mode 100644 index 000000000..849e65d9e --- /dev/null +++ b/.agents/skills/tsdown/SKILL.md @@ -0,0 +1,416 @@ +--- +name: tsdown +description: Bundle TypeScript and JavaScript libraries with blazing-fast speed powered by Rolldown. Use when building libraries, generating type declarations, bundling for multiple formats, or migrating from tsup. +--- + +# tsdown - The Elegant Library Bundler + +Blazing-fast bundler for TypeScript/JavaScript libraries powered by Rolldown and Oxc. + +## Runtime Requirement + +`tsdown` requires **Node.js 22.18.0 or higher to run** (build-time only). However, the bundled output can target much lower Node.js versions via the [`target`](references/option-target.md) option, so libraries built with tsdown are **not locked to Node.js 22+ at runtime**. + +If your package needs to support Node.js 18 / 20: + +- **Build with Node.js 22+ in CI** (e.g. set `target: 'node18'` or `target: 'node20'`). +- **Test the built output (or the packed tarball) on the lower Node.js versions** you intend to support — e.g. using a matrix job that runs the published package's tests on Node.js 18 / 20 / 22. + +## When to Use + +- Building TypeScript/JavaScript libraries for npm +- Generating TypeScript declaration files (.d.ts) +- Bundling for multiple formats (ESM, CJS, IIFE, UMD) +- Optimizing bundles with tree shaking and minification +- Migrating from tsup with minimal changes +- Building React, Vue, Solid, or Svelte component libraries + +## Quick Start + +```bash +# Install +pnpm add -D tsdown + +# Basic usage +npx tsdown + +# With config file +npx tsdown --config tsdown.config.ts + +# Watch mode +npx tsdown --watch + +# Migrate from tsup +npx tsdown-migrate +``` + +## Basic Configuration + +```ts +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, +}) +``` + +## Core References + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Getting Started | Installation, first bundle, CLI basics | [guide-getting-started](references/guide-getting-started.md) | +| Configuration File | Config file formats, multiple configs, workspace | [option-config-file](references/option-config-file.md) | +| CLI Reference | All CLI commands and options | [reference-cli](references/reference-cli.md) | +| Migrate from tsup | Migration guide and compatibility notes | [guide-migrate-from-tsup](references/guide-migrate-from-tsup.md) | +| Plugins | Rolldown, Rollup, Unplugin support | [advanced-plugins](references/advanced-plugins.md) | + +> For comprehensive migration assistance with complete option mappings, install the dedicated [`tsdown-migrate`](../tsdown-migrate/SKILL.md) skill: `npx skills add rolldown/tsdown --skill tsdown-migrate` +| Hooks | Lifecycle hooks for custom logic | [advanced-hooks](references/advanced-hooks.md) | +| Programmatic API | Build from Node.js scripts | [advanced-programmatic](references/advanced-programmatic.md) | +| Rolldown Options | Pass options directly to Rolldown | [advanced-rolldown-options](references/advanced-rolldown-options.md) | +| CI Environment | CI detection, `'ci-only'` / `'local-only'` values | [advanced-ci](references/advanced-ci.md) | + +## Build Options + +| Option | Usage | Reference | +|--------|-------|-----------| +| Entry points | `entry: ['src/*.ts', '!**/*.test.ts']` | [option-entry](references/option-entry.md) | +| Output formats | `format: ['esm', 'cjs', 'iife', 'umd']` | [option-output-format](references/option-output-format.md) | +| Output directory | `outDir: 'dist'`, `outExtensions` | [option-output-directory](references/option-output-directory.md) | +| Type declarations | `dts: true`, `dts: { sourcemap, compilerOptions, vue }` | [option-dts](references/option-dts.md) | +| Target environment | `target: 'es2020'`, `target: 'esnext'` | [option-target](references/option-target.md) | +| Platform | `platform: 'node'`, `platform: 'browser'` | [option-platform](references/option-platform.md) | +| Tree shaking | `treeshake: true`, custom options | [option-tree-shaking](references/option-tree-shaking.md) | +| Minification | `minify: true`, `minify: 'dce-only'` | [option-minification](references/option-minification.md) | +| Source maps | `sourcemap: true`, `'inline'`, `'hidden'` | [option-sourcemap](references/option-sourcemap.md) | +| Watch mode | `watch: true`, watch options | [option-watch-mode](references/option-watch-mode.md) | +| Cleaning | `clean: true`, clean patterns | [option-cleaning](references/option-cleaning.md) | +| Log level | `logLevel: 'silent'`, `failOnWarn: false` | [option-log-level](references/option-log-level.md) | + +## Dependency Handling + +| Feature | Usage | Reference | +|---------|-------|-----------| +| Never bundle | `deps: { neverBundle: ['react', /^@myorg\//] }` | [option-dependencies](references/option-dependencies.md) | +| Always bundle | `deps: { alwaysBundle: ['dep-to-bundle'] }` | [option-dependencies](references/option-dependencies.md) | +| Only bundle | `deps: { onlyBundle: ['cac', 'bumpp'] }` - Whitelist | [option-dependencies](references/option-dependencies.md) | +| Skip node_modules | `deps: { skipNodeModulesBundle: true }` | [option-dependencies](references/option-dependencies.md) | +| Auto external | Automatic dependency/peer/optional externalization | [option-dependencies](references/option-dependencies.md) | + +## Output Enhancement + +| Feature | Usage | Reference | +|---------|-------|-----------| +| Shims | `shims: true` - Add ESM/CJS compatibility | [option-shims](references/option-shims.md) | +| CJS default | `cjsDefault: true` (default) / `false` | [option-cjs-default](references/option-cjs-default.md) | +| Package exports | `exports: true` - Generate exports field | [option-package-exports](references/option-package-exports.md) | +| CSS handling | **[experimental]** `css: { ... }` — full pipeline with preprocessors, Lightning CSS, PostCSS, CSS modules, code splitting; requires `@tsdown/css` | [option-css](references/option-css.md) | +| CSS modules | `css: { modules: { localsConvention: 'camelCase' } }` — scoped class names for `.module.css` files | [option-css](references/option-css.md) | +| CSS inject | `css: { inject: true }` — preserve CSS imports in JS output | [option-css](references/option-css.md) | +| Unbundle mode | `unbundle: true` - Preserve directory structure | [option-unbundle](references/option-unbundle.md) | +| Root directory | `root: 'src'` - Control output directory mapping | [option-root](references/option-root.md) | +| Executable | **[experimental]** `exe: true` - Bundle as standalone executable, cross-platform via `@tsdown/exe` | [option-exe](references/option-exe.md) | +| Package validation | `publint: true`, `attw: true` - Validate package | [option-lint](references/option-lint.md) | + +## Framework & Runtime Support + +| Framework | Guide | Reference | +|-----------|-------|-----------| +| React | JSX transform, React Compiler | [recipe-react](references/recipe-react.md) | +| Vue | SFC support, JSX | [recipe-vue](references/recipe-vue.md) | +| Solid | SolidJS JSX transform | [recipe-solid](references/recipe-solid.md) | +| Svelte | Svelte component libraries (source distribution recommended) | [recipe-svelte](references/recipe-svelte.md) | +| WASM | WebAssembly modules via `rolldown-plugin-wasm` | [recipe-wasm](references/recipe-wasm.md) | + +## Common Patterns + +### Basic Library Bundle + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, +}) +``` + +### Multiple Entry Points + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + utils: 'src/utils.ts', + cli: 'src/cli.ts', + }, + format: ['esm', 'cjs'], + dts: true, +}) +``` + +### Browser Library (IIFE/UMD) + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['iife'], + globalName: 'MyLib', + platform: 'browser', + minify: true, +}) +``` + +### React Component Library + +```ts +export default defineConfig({ + entry: ['src/index.tsx'], + format: ['esm', 'cjs'], + dts: true, + deps: { + neverBundle: ['react', 'react-dom'], + }, + inputOptions: { + jsx: { runtime: 'automatic' }, + }, +}) +``` + +### Preserve Directory Structure + +```ts +export default defineConfig({ + entry: ['src/**/*.ts', '!**/*.test.ts'], + unbundle: true, // Preserve file structure + format: ['esm'], + dts: true, +}) +``` + +### CI-Aware Configuration + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + failOnWarn: 'ci-only', // opt-in: fail on warnings in CI + publint: 'ci-only', + attw: 'ci-only', +}) +``` + +### WASM Support + +```ts +import { wasm } from 'rolldown-plugin-wasm' +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [wasm()], +}) +``` + +### Library with CSS and Sass + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + target: 'chrome100', + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "src/styles/variables" as *;`, + }, + }, + }, +}) +``` + +### Standalone Executable + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + exe: true, +}) +``` + +### Cross-Platform Executable (requires `@tsdown/exe`) + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + exe: { + targets: [ + { platform: 'linux', arch: 'x64', nodeVersion: '25.7.0' }, + { platform: 'darwin', arch: 'arm64', nodeVersion: '25.7.0' }, + { platform: 'win', arch: 'x64', nodeVersion: '25.7.0' }, + ], + }, +}) +``` + +### Advanced with Hooks + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + hooks: { + 'build:before': async (context) => { + console.log('Building...') + }, + 'build:done': async (context) => { + console.log('Build complete!') + }, + }, +}) +``` + +## Configuration Features + +### Multiple Configs + +Export an array for multiple build configurations: + +```ts +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + }, + { + entry: ['src/cli.ts'], + format: ['esm'], + platform: 'node', + }, +]) +``` + +### Conditional Config + +Use functions for dynamic configuration: + +```ts +export default defineConfig((options) => { + const isDev = options.watch + return { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: !isDev, + sourcemap: isDev, + } +}) +``` + +### Workspace/Monorepo + +Use glob patterns to build multiple packages: + +```ts +export default defineConfig({ + workspace: 'packages/*', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +}) +``` + +## CLI Quick Reference + +```bash +# Basic commands +tsdown # Build once +tsdown --watch # Watch mode +tsdown --config custom.ts # Custom config +npx tsdown-migrate # Migrate from tsup + +# Output options +tsdown --format esm,cjs # Multiple formats +tsdown -d lib # Custom output directory (--out-dir) +tsdown --minify # Enable minification +tsdown --dts # Generate declarations +tsdown --exe # Bundle as standalone executable +tsdown --unbundle # Bundleless mode + +# Entry options +tsdown src/index.ts # Single entry +tsdown src/*.ts # Glob patterns +tsdown src/a.ts src/b.ts # Multiple entries + +# Workspace / Monorepo +tsdown -W # Enable workspace mode +tsdown -W -F my-package # Filter specific package +tsdown --filter /^pkg-/ # Filter by regex + +# Development +tsdown --watch # Watch mode +tsdown --sourcemap # Generate source maps +tsdown --clean # Clean output directory +tsdown --from-vite # Reuse Vite config +tsdown --tsconfig tsconfig.build.json # Custom tsconfig +``` + +## Best Practices + +1. **Always generate type declarations** for TypeScript libraries: + ```ts + { dts: true } + ``` + +2. **Externalize dependencies** to avoid bundling unnecessary code: + ```ts + { deps: { neverBundle: [/^react/, /^@myorg\//] } } + ``` + +3. **Use tree shaking** for optimal bundle size: + ```ts + { treeshake: true } + ``` + +4. **Enable minification** for production builds: + ```ts + { minify: true } + ``` + +5. **Add shims** for better ESM/CJS compatibility: + ```ts + { shims: true } // Adds __dirname, __filename, etc. + ``` + +6. **Auto-generate package.json exports**: + ```ts + { exports: true } // Creates proper exports field + ``` + +7. **Use watch mode** during development: + ```bash + tsdown --watch + ``` + +8. **Preserve structure** for utilities with many files: + ```ts + { unbundle: true } // Keep directory structure + ``` + +9. **Validate packages** in CI before publishing: + ```ts + { publint: 'ci-only', attw: 'ci-only' } + ``` + +## Resources + +- Documentation: https://tsdown.dev +- GitHub: https://github.com/rolldown/tsdown +- Rolldown: https://rolldown.rs +- Migration Guide: https://tsdown.dev/guide/migrate-from-tsup diff --git a/.agents/skills/tsdown/SYNC.md b/.agents/skills/tsdown/SYNC.md new file mode 100644 index 000000000..f9e5a9579 --- /dev/null +++ b/.agents/skills/tsdown/SYNC.md @@ -0,0 +1,5 @@ +# Sync Info + +- **Source:** `vendor/tsdown/skills/tsdown` +- **Git SHA:** `f635a43b3c8b18569b47f3789c801f44a45c668a` +- **Synced:** 2026-06-22 diff --git a/.agents/skills/tsdown/references/README.md b/.agents/skills/tsdown/references/README.md new file mode 100644 index 000000000..fdbc6bd27 --- /dev/null +++ b/.agents/skills/tsdown/references/README.md @@ -0,0 +1,139 @@ +# tsdown Skill References + +This directory contains detailed reference documentation for the tsdown skill. + +## Created Files (35 total) + +### Core Guides (3) +- ✅ `guide-getting-started.md` - Installation, first bundle, CLI basics +- ✅ `guide-migrate-from-tsup.md` - Migration guide from tsup +- ✅ `guide-introduction.md` - Introduction and key features + +### Configuration Options (20) +- ✅ `option-config-file.md` - Config file formats, loaders, workspace +- ✅ `option-entry.md` - Entry point configuration with globs +- ✅ `option-output-format.md` - Output formats (ESM, CJS, IIFE, UMD) +- ✅ `option-output-directory.md` - Output directory and extensions +- ✅ `option-dts.md` - TypeScript declaration generation +- ✅ `option-target.md` - Target environment (ES2020, ESNext, etc.) +- ✅ `option-platform.md` - Platform (node, browser, neutral) +- ✅ `option-dependencies.md` - External and inline dependencies +- ✅ `option-sourcemap.md` - Source map generation +- ✅ `option-minification.md` - Minification (`boolean | 'dce-only' | MinifyOptions`) +- ✅ `option-tree-shaking.md` - Tree shaking configuration +- ✅ `option-cleaning.md` - Output directory cleaning +- ✅ `option-watch-mode.md` - Watch mode configuration +- ✅ `option-shims.md` - ESM/CJS compatibility shims +- ✅ `option-package-exports.md` - Auto-generate package.json exports +- ✅ `option-css.md` - CSS handling (experimental, full pipeline: preprocessors, Lightning CSS, PostCSS, code splitting) +- ✅ `option-unbundle.md` - Preserve directory structure +- ✅ `option-cjs-default.md` - CommonJS default export handling +- ✅ `option-log-level.md` - Logging configuration +- ✅ `option-lint.md` - Package validation (publint & attw) + +### Executable (1) +- ✅ `option-exe.md` - Standalone executable bundling (Node.js SEA) + +### Advanced Topics (6) +- ✅ `advanced-plugins.md` - Rolldown, Rollup, Unplugin support +- ✅ `advanced-hooks.md` - Lifecycle hooks system +- ✅ `advanced-programmatic.md` - Node.js API usage +- ✅ `advanced-rolldown-options.md` - Pass options to Rolldown +- ✅ `advanced-ci.md` - CI environment detection and CI-aware options + +### Advanced (continued) +- ✅ `advanced-benchmark.md` - Performance benchmarks + +### Framework Recipes (5) +- ✅ `recipe-react.md` - React library setup with JSX +- ✅ `recipe-vue.md` - Vue library setup with SFC +- ✅ `recipe-solid.md` - Solid.js library setup +- ✅ `recipe-svelte.md` - Svelte component libraries +- ✅ `recipe-wasm.md` - WASM module support + +### Reference (1) +- ✅ `reference-cli.md` - Complete CLI command reference + +## Coverage Status + +**Created:** 35 files (100% complete) + +## Current Skill Features + +The tsdown skill now includes comprehensive coverage of: + +### ✅ Core Functionality +- Getting started and installation +- Entry points and glob patterns +- Output formats (ESM, CJS, IIFE, UMD) +- TypeScript declarations +- Configuration file setup +- CLI reference + +### ✅ Build Options +- Target environment configuration +- Platform selection +- Dependency management +- Source maps +- Minification +- Tree shaking +- Output cleaning +- Watch mode + +### ✅ Advanced Features +- Plugins (Rolldown, Rollup, Unplugin) +- Lifecycle hooks +- ESM/CJS shims +- Package exports generation +- Package validation (publint, attw) +- Programmatic API (Node.js) +- Output directory customization +- CSS handling and modules +- Unbundle mode +- CI environment detection and CI-aware options + +### ✅ Framework & Runtime Support +- React with JSX/TSX +- React Compiler integration +- Vue with SFC support +- Vue type generation (vue-tsc) +- WASM module bundling (rolldown-plugin-wasm) + +### ✅ Migration +- Complete migration guide from tsup +- Compatibility notes + +## Usage + +The skill is now ready for use with comprehensive coverage of core features. Additional files can be added incrementally as needed. + +## File Naming Convention + +Files are prefixed by category: +- `guide-*` - Getting started guides and tutorials +- `option-*` - Configuration options +- `advanced-*` - Advanced topics (plugins, hooks, programmatic API) +- `recipe-*` - Framework-specific recipes +- `reference-*` - CLI and API reference + +## Creating New Reference Files + +When creating new reference files: + +1. **Read source documentation** from `/docs` directory +2. **Simplify for AI consumption** - concise, actionable content +3. **Include code examples** - practical, copy-paste ready +4. **Add cross-references** - link to related options +5. **Follow naming convention** - use appropriate prefix +6. **Keep it focused** - one topic per file + +## Updating Existing Files + +When documentation changes: + +1. Check git diff: `git diff ..HEAD -- docs/` +2. Update affected reference files +3. Update SKILL.md if needed +4. Update GENERATION.md with new SHA + +See `skills/GENERATION.md` for detailed update instructions. diff --git a/.agents/skills/tsdown/references/advanced-benchmark.md b/.agents/skills/tsdown/references/advanced-benchmark.md new file mode 100644 index 000000000..7d269c1c7 --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-benchmark.md @@ -0,0 +1,8 @@ +# Benchmark + +tsdown delivers exceptional performance: + +- **~2x faster** than tsup for standard builds +- **Up to 8x faster** for TypeScript declaration generation + +For detailed comparisons, see [bundler-benchmark](https://gugustinette.github.io/bundler-benchmark/). diff --git a/.agents/skills/tsdown/references/advanced-ci.md b/.agents/skills/tsdown/references/advanced-ci.md new file mode 100644 index 000000000..f3d453324 --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-ci.md @@ -0,0 +1,89 @@ +# CI Environment Support + +Automatically detect CI environments and toggle features based on local vs CI builds. + +## Overview + +tsdown detects CI from the `CI` environment variable. CI mode is enabled when `process.env.CI` is set to a value other than `0` or `false` (case-insensitive). + +## CI-Aware Values + +Several options accept CI-aware string values: + +| Value | Behavior | +|-------|----------| +| `true` | Always enabled | +| `false` | Always disabled | +| `'ci-only'` | Enabled only in CI, disabled locally | +| `'local-only'` | Enabled only locally, disabled in CI | + +## Supported Options + +These options accept CI-aware values: + +- `dts` - TypeScript declaration file generation +- `publint` - Package lint validation +- `attw` - "Are the types wrong" validation +- `report` - Bundle size reporting +- `exports` - Auto-generate `package.json` exports +- `unused` - Unused dependency check +- `devtools` - DevTools integration +- `failOnWarn` - Fail on warnings (defaults to `false`) + +## Usage + +### String Form + +```ts +export default defineConfig({ + dts: 'local-only', // Skip DTS in CI for faster builds + publint: 'ci-only', // Only run publint in CI + failOnWarn: 'ci-only', // Fail on warnings in CI only (opt-in) +}) +``` + +### Object Form + +When an option takes a configuration object, set `enabled` to a CI-aware value: + +```ts +export default defineConfig({ + publint: { + enabled: 'ci-only', + level: 'error', + }, + attw: { + enabled: 'ci-only', + profile: 'node16', + }, +}) +``` + +### Config Function + +The config function receives a `ci` boolean in its context: + +```ts +export default defineConfig((_, { ci }) => ({ + minify: ci, + sourcemap: !ci, +})) +``` + +## Typical CI Configuration + +```ts +export default defineConfig({ + entry: 'src/index.ts', + format: ['esm', 'cjs'], + dts: true, + failOnWarn: 'ci-only', + publint: 'ci-only', + attw: 'ci-only', +}) +``` + +## Related Options + +- [Package Validation](option-lint.md) - publint and attw configuration +- [Log Level](option-log-level.md) - `failOnWarn` option details diff --git a/.agents/skills/tsdown/references/advanced-hooks.md b/.agents/skills/tsdown/references/advanced-hooks.md new file mode 100644 index 000000000..b9a69e7ef --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-hooks.md @@ -0,0 +1,363 @@ +# Lifecycle Hooks + +Extend the build process with lifecycle hooks. + +## Overview + +Hooks provide a way to inject custom logic at specific stages of the build lifecycle. Inspired by [unbuild](https://github.com/unjs/unbuild). + +**Recommendation:** Use [plugins](advanced-plugins.md) for most extensions. Use hooks for simple custom tasks or Rolldown plugin injection. + +## Usage Patterns + +### Object Syntax + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + hooks: { + 'build:prepare': async (context) => { + console.log('Build starting...') + }, + 'build:done': async (context) => { + console.log('Build complete!') + }, + }, +}) +``` + +### Function Syntax + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + hooks(hooks) { + hooks.hook('build:prepare', () => { + console.log('Preparing build...') + }) + + hooks.hook('build:before', (context) => { + console.log(`Building format: ${context.format}`) + }) + }, +}) +``` + +## Available Hooks + +### `build:prepare` + +Called before the build process starts. + +**When:** Once per build session + +**Context:** +```ts +{ + options: ResolvedConfig, + hooks: Hookable +} +``` + +**Use cases:** +- Setup tasks +- Validation +- Environment preparation + +**Example:** +```ts +hooks: { + 'build:prepare': async (context) => { + console.log('Starting build for:', context.options.entry) + await cleanOldFiles() + }, +} +``` + +### `build:before` + +Called before each Rolldown build. + +**When:** Once per format (ESM, CJS, etc.) + +**Context:** +```ts +{ + options: ResolvedConfig, + buildOptions: BuildOptions, + hooks: Hookable +} +``` + +**Use cases:** +- Modify build options per format +- Inject plugins dynamically +- Format-specific setup + +**Example:** +```ts +hooks: { + 'build:before': async (context) => { + console.log(`Building ${context.buildOptions.format} format...`) + + // Add format-specific plugin + if (context.buildOptions.format === 'iife') { + context.buildOptions.plugins.push(browserPlugin()) + } + }, +} +``` + +### `build:done` + +Called after the build completes. + +**When:** Once per build session + +**Context:** +```ts +{ + options: ResolvedConfig, + chunks: RolldownChunk[], + hooks: Hookable +} +``` + +**Use cases:** +- Post-processing +- Asset copying +- Notifications +- Deployment + +**Example:** +```ts +hooks: { + 'build:done': async (context) => { + console.log(`Built ${context.chunks.length} chunks`) + + // Copy additional files + await copyAssets() + + // Send notification + notifyBuildComplete() + }, +} +``` + +## Common Patterns + +### Build Notifications + +```ts +export default defineConfig({ + hooks: { + 'build:prepare': () => { + console.log('🚀 Starting build...') + }, + 'build:done': (context) => { + const size = context.chunks.reduce((sum, c) => sum + c.code.length, 0) + console.log(`✅ Build complete! Total size: ${size} bytes`) + }, + }, +}) +``` + +### Conditional Plugin Injection + +```ts +export default defineConfig({ + hooks(hooks) { + hooks.hook('build:before', (context) => { + // Add minification only for production + if (process.env.NODE_ENV === 'production') { + context.buildOptions.plugins.push(minifyPlugin()) + } + }) + }, +}) +``` + +### Custom File Copy + +```ts +import { copyFile } from 'fs/promises' + +export default defineConfig({ + hooks: { + 'build:done': async (context) => { + // Copy README to dist + await copyFile('README.md', `${context.options.outDir}/README.md`) + }, + }, +}) +``` + +### Build Metrics + +```ts +export default defineConfig({ + hooks: { + 'build:prepare': (context) => { + context.startTime = Date.now() + }, + 'build:done': (context) => { + const duration = Date.now() - context.startTime + console.log(`Build took ${duration}ms`) + + // Log chunk sizes + context.chunks.forEach((chunk) => { + console.log(`${chunk.fileName}: ${chunk.code.length} bytes`) + }) + }, + }, +}) +``` + +### Format-Specific Logic + +```ts +export default defineConfig({ + format: ['esm', 'cjs', 'iife'], + hooks: { + 'build:before': (context) => { + const format = context.buildOptions.format + + if (format === 'iife') { + // Browser-specific setup + context.buildOptions.globalName = 'MyLib' + } else if (format === 'cjs') { + // Node-specific setup + context.buildOptions.platform = 'node' + } + }, + }, +}) +``` + +### Deployment Hook + +```ts +export default defineConfig({ + hooks: { + 'build:done': async (context) => { + if (process.env.DEPLOY === 'true') { + console.log('Deploying to CDN...') + await deployToCDN(context.options.outDir) + } + }, + }, +}) +``` + +## Advanced Usage + +### Multiple Hooks + +```ts +export default defineConfig({ + hooks(hooks) { + // Register multiple hooks + hooks.hook('build:prepare', setupEnvironment) + hooks.hook('build:prepare', validateConfig) + + hooks.hook('build:before', injectPlugins) + hooks.hook('build:before', logFormat) + + hooks.hook('build:done', generateManifest) + hooks.hook('build:done', notifyComplete) + }, +}) +``` + +### Async Hooks + +```ts +export default defineConfig({ + hooks: { + 'build:prepare': async (context) => { + await fetchRemoteConfig() + await initializeDatabase() + }, + 'build:done': async (context) => { + await uploadToS3(context.chunks) + await invalidateCDN() + }, + }, +}) +``` + +### Error Handling + +```ts +export default defineConfig({ + hooks: { + 'build:done': async (context) => { + try { + await riskyOperation() + } catch (error) { + console.error('Hook failed:', error) + // Don't throw - allow build to complete + } + }, + }, +}) +``` + +## Hookable API + +tsdown uses [hookable](https://github.com/unjs/hookable) for hooks. Additional methods: + +```ts +export default defineConfig({ + hooks(hooks) { + // Register hook + hooks.hook('build:done', handler) + + // Register hook once + hooks.hookOnce('build:prepare', handler) + + // Remove hook + hooks.removeHook('build:done', handler) + + // Clear all hooks for event + hooks.removeHooks('build:done') + + // Call hooks manually + await hooks.callHook('build:done', context) + }, +}) +``` + +## Tips + +1. **Use plugins** for most extensions +2. **Hooks for simple tasks** like notifications or file copying +3. **Async hooks supported** for I/O operations +4. **Don't throw errors** unless you want to fail the build +5. **Context is mutable** in `build:before` for advanced use cases +6. **Multiple hooks allowed** for the same event + +## Troubleshooting + +### Hook Not Called + +- Verify hook name is correct +- Check hook is registered in config +- Ensure async hooks are awaited + +### Build Fails in Hook + +- Add try/catch for error handling +- Don't throw unless intentional +- Log errors for debugging + +### Context Undefined + +- Check which hook you're using +- Verify context properties available for that hook + +## Related + +- [Plugins](advanced-plugins.md) - Plugin system +- [Rolldown Options](advanced-rolldown-options.md) - Build options +- [Watch Mode](option-watch-mode.md) - Development workflow diff --git a/.agents/skills/tsdown/references/advanced-plugins.md b/.agents/skills/tsdown/references/advanced-plugins.md new file mode 100644 index 000000000..a7a303ec3 --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-plugins.md @@ -0,0 +1,381 @@ +# Plugins + +Extend tsdown with plugins from multiple ecosystems. + +## Overview + +tsdown, built on Rolldown, supports plugins from multiple ecosystems to extend and customize the bundling process. + +## Supported Ecosystems + +### 1. Rolldown Plugins + +Native plugins designed for Rolldown: + +```ts +import RolldownPlugin from 'rolldown-plugin-something' + +export default defineConfig({ + plugins: [RolldownPlugin()], +}) +``` + +**Compatibility:** ✅ Full support + +### 2. Unplugin + +Universal plugins that work across bundlers: + +```ts +import UnpluginPlugin from 'unplugin-something' + +export default defineConfig({ + plugins: [UnpluginPlugin.rolldown()], +}) +``` + +**Compatibility:** ✅ Most unplugin-* plugins work + +**Examples:** +- `unplugin-vue-components` +- `unplugin-auto-import` +- `unplugin-icons` + +### 3. Rollup Plugins + +Most Rollup plugins work with tsdown: + +```ts +import RollupPlugin from '@rollup/plugin-something' + +export default defineConfig({ + plugins: [RollupPlugin()], +}) +``` + +**Compatibility:** ✅ High compatibility + +**Type Issues:** May cause TypeScript errors - use type casting: + +```ts +import RollupPlugin from 'rollup-plugin-something' + +export default defineConfig({ + plugins: [ + // @ts-expect-error Rollup plugin type mismatch + RollupPlugin(), + // Or cast to any + RollupPlugin() as any, + ], +}) +``` + +### 4. Vite Plugins + +Some Vite plugins may work: + +```ts +import VitePlugin from 'vite-plugin-something' + +export default defineConfig({ + plugins: [ + // @ts-expect-error Vite plugin type mismatch + VitePlugin(), + ], +}) +``` + +**Compatibility:** ⚠️ Limited - only if not using Vite-specific APIs + +**Note:** Improved support planned for future releases. + +## Usage + +### Basic Plugin Usage + +```ts +import { defineConfig } from 'tsdown' +import SomePlugin from 'some-plugin' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [SomePlugin()], +}) +``` + +### Multiple Plugins + +```ts +import PluginA from 'plugin-a' +import PluginB from 'plugin-b' +import PluginC from 'plugin-c' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [ + PluginA(), + PluginB({ option: true }), + PluginC(), + ], +}) +``` + +### Conditional Plugins + +```ts +export default defineConfig((options) => ({ + entry: ['src/index.ts'], + plugins: [ + SomePlugin(), + options.watch && DevPlugin(), + !options.watch && ProdPlugin(), + ].filter(Boolean), +})) +``` + +## Common Plugin Patterns + +### JSON Import + +```ts +import json from '@rollup/plugin-json' + +export default defineConfig({ + plugins: [json()], +}) +``` + +### Node Resolve + +```ts +import { nodeResolve } from '@rollup/plugin-node-resolve' + +export default defineConfig({ + plugins: [nodeResolve()], +}) +``` + +### CommonJS + +```ts +import commonjs from '@rollup/plugin-commonjs' + +export default defineConfig({ + plugins: [commonjs()], +}) +``` + +### Replace + +```ts +import replace from '@rollup/plugin-replace' + +export default defineConfig({ + plugins: [ + replace({ + 'process.env.NODE_ENV': JSON.stringify('production'), + __VERSION__: JSON.stringify('1.0.0'), + }), + ], +}) +``` + +### Auto Import + +```ts +import AutoImport from 'unplugin-auto-import/rolldown' + +export default defineConfig({ + plugins: [ + AutoImport({ + imports: ['vue', 'vue-router'], + dts: 'src/auto-imports.d.ts', + }), + ], +}) +``` + +### Vue Components + +```ts +import Components from 'unplugin-vue-components/rolldown' + +export default defineConfig({ + plugins: [ + Components({ + dts: 'src/components.d.ts', + }), + ], +}) +``` + +## Framework-Specific Plugins + +### React + +```ts +import react from '@vitejs/plugin-react' + +export default defineConfig({ + entry: ['src/index.tsx'], + plugins: [ + // @ts-expect-error Vite plugin + react(), + ], +}) +``` + +### Vue + +```ts +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [ + // @ts-expect-error Vite plugin + vue(), + ], +}) +``` + +### Solid + +```ts +import solid from 'vite-plugin-solid' + +export default defineConfig({ + entry: ['src/index.tsx'], + plugins: [ + // @ts-expect-error Vite plugin + solid(), + ], +}) +``` + +### Svelte + +```ts +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [ + // @ts-expect-error Vite plugin + svelte(), + ], +}) +``` + +## Writing Custom Plugins + +Follow Rolldown's plugin development guide: + +### Basic Plugin Structure + +```ts +import type { Plugin } from 'rolldown' + +function myPlugin(): Plugin { + return { + name: 'my-plugin', + + // Transform hook + transform(code, id) { + if (id.endsWith('.custom')) { + return { + code: transformCode(code), + map: null, + } + } + }, + + // Other hooks... + } +} +``` + +### Using Custom Plugin + +```ts +import { myPlugin } from './my-plugin' + +export default defineConfig({ + plugins: [myPlugin()], +}) +``` + +## Plugin Configuration + +### Plugin-Specific Options + +Refer to each plugin's documentation for configuration options. + +### Plugin Order + +Plugins run in the order they're defined: + +```ts +export default defineConfig({ + plugins: [ + PluginA(), // Runs first + PluginB(), // Runs second + PluginC(), // Runs last + ], +}) +``` + +## Troubleshooting + +### Type Errors with Rollup/Vite Plugins + +Use type casting: + +```ts +plugins: [ + // Option 1: @ts-expect-error + // @ts-expect-error Plugin type mismatch + SomePlugin(), + + // Option 2: as any + SomePlugin() as any, +] +``` + +### Plugin Not Working + +1. **Check compatibility** - Verify plugin supports your bundler +2. **Read documentation** - Follow plugin's setup instructions +3. **Check plugin order** - Some plugins depend on execution order +4. **Enable debug mode** - Use `--debug` flag + +### Vite Plugin Fails + +Vite plugins may rely on Vite-specific APIs: + +1. **Find Rollup equivalent** - Look for Rollup version of plugin +2. **Use Unplugin version** - Check for `unplugin-*` alternative +3. **Wait for support** - Vite plugin support improving + +## Resources + +- [Rolldown Plugin Development](https://rolldown.rs/apis/plugin-api) +- [Unplugin Documentation](https://unplugin.unjs.io/) +- [Rollup Plugins](https://github.com/rollup/plugins) +- [Vite Plugins](https://vitejs.dev/plugins/) + +## Tips + +1. **Prefer Rolldown plugins** for best compatibility +2. **Use Unplugin** for cross-bundler support +3. **Cast types** for Rollup/Vite plugins +4. **Test thoroughly** when using cross-ecosystem plugins +5. **Check plugin docs** for specific configuration +6. **Write custom plugins** for unique needs + +## Related + +- [Hooks](advanced-hooks.md) - Lifecycle hooks +- [Rolldown Options](advanced-rolldown-options.md) - Advanced Rolldown config +- [React Recipe](recipe-react.md) - React setup with plugins +- [Vue Recipe](recipe-vue.md) - Vue setup with plugins diff --git a/.agents/skills/tsdown/references/advanced-programmatic.md b/.agents/skills/tsdown/references/advanced-programmatic.md new file mode 100644 index 000000000..092ec60fa --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-programmatic.md @@ -0,0 +1,378 @@ +# Programmatic Usage + +Use tsdown from JavaScript/TypeScript code. + +## Overview + +tsdown can be imported and used programmatically in your Node.js scripts, custom build tools, or automation workflows. + +## Basic Usage + +### Simple Build + +```ts +import { build } from 'tsdown' + +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +}) +``` + +### With Options + +```ts +import { build } from 'tsdown' + +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist', + dts: true, + minify: true, + sourcemap: true, + clean: true, +}) +``` + +## API Reference + +### build() + +Main function to run a build. + +```ts +import { build } from 'tsdown' + +await build(options) +``` + +**Parameters:** +- `options` - Build configuration object (same as config file) + +**Returns:** +- `Promise` - Resolves when build completes + +**Throws:** +- Build errors if compilation fails + +## Configuration Object + +All config file options are available: + +```ts +import { build, defineConfig } from 'tsdown' + +const config = defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + minify: true, + sourcemap: true, + deps: { + neverBundle: ['react', 'react-dom'], + }, + plugins: [/* plugins */], + hooks: { + 'build:done': async () => { + console.log('Build complete!') + }, + }, +}) + +await build(config) +``` + +See [Config Reference](option-config-file.md) for all options. + +## Common Patterns + +### Custom Build Script + +```ts +// scripts/build.ts +import { build } from 'tsdown' + +async function main() { + console.log('Building library...') + + await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + }) + + console.log('Build complete!') +} + +main().catch(console.error) +``` + +Run with: +```bash +tsx scripts/build.ts +``` + +### Multiple Builds + +```ts +import { build } from 'tsdown' + +// Build main library +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist', + dts: true, +}) + +// Build CLI tool +await build({ + entry: ['src/cli.ts'], + format: ['esm'], + outDir: 'dist/bin', + platform: 'node', + shims: true, +}) +``` + +### Conditional Build + +```ts +import { build } from 'tsdown' + +const isDev = process.env.NODE_ENV === 'development' + +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: !isDev, + sourcemap: isDev, + clean: !isDev, +}) +``` + +### With Error Handling + +```ts +import { build } from 'tsdown' + +try { + await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + }) + console.log('✅ Build successful') +} catch (error) { + console.error('❌ Build failed:', error) + process.exit(1) +} +``` + +### Automated Workflow + +```ts +import { build } from 'tsdown' +import { execSync } from 'child_process' + +async function release() { + // Clean + console.log('Cleaning...') + execSync('rm -rf dist') + + // Build + console.log('Building...') + await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + minify: true, + }) + + // Test + console.log('Testing...') + execSync('npm test') + + // Publish + console.log('Publishing...') + execSync('npm publish') +} + +release().catch(console.error) +``` + +### Build with Post-Processing + +```ts +import { build } from 'tsdown' +import { copyFileSync } from 'fs' + +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + hooks: { + 'build:done': async () => { + // Copy additional files + copyFileSync('README.md', 'dist/README.md') + copyFileSync('LICENSE', 'dist/LICENSE') + console.log('Copied additional files') + }, + }, +}) +``` + +## Watch Mode + +Unfortunately, watch mode is not directly exposed in the programmatic API. Use the CLI for watch mode: + +```ts +// Use CLI for watch mode +import { spawn } from 'child_process' + +spawn('tsdown', ['--watch'], { + stdio: 'inherit', + shell: true, +}) +``` + +## Integration Examples + +### With Task Runner + +```ts +// gulpfile.js +import { build } from 'tsdown' +import gulp from 'gulp' + +gulp.task('build', async () => { + await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + }) +}) + +gulp.task('watch', () => { + return gulp.watch('src/**/*.ts', gulp.series('build')) +}) +``` + +### With Custom CLI + +```ts +// scripts/cli.ts +import { build } from 'tsdown' +import { Command } from 'commander' + +const program = new Command() + +program + .command('build') + .option('--prod', 'Production build') + .action(async (options) => { + await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: options.prod, + sourcemap: !options.prod, + }) + }) + +program.parse() +``` + +### With CI/CD + +```ts +// .github/scripts/build.ts +import { build } from 'tsdown' + +const isCI = process.env.CI === 'true' + +await build({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + minify: isCI, + clean: true, +}) + +// Upload to artifact storage +if (isCI) { + // Upload dist/ to S3, etc. +} +``` + +## TypeScript Support + +```ts +// scripts/build.ts +import { build, type UserConfig } from 'tsdown' + +const config: UserConfig = { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +} + +await build(config) +``` + +## Tips + +1. **Use TypeScript** for type safety +2. **Handle errors** properly +3. **Use hooks** for custom logic +4. **Log progress** for visibility +5. **Use CLI for watch** mode +6. **Exit on error** in scripts + +## Troubleshooting + +### Import Errors + +Ensure tsdown is installed: +```bash +pnpm add -D tsdown +``` + +### Type Errors + +Import types: +```ts +import type { UserConfig } from 'tsdown' +``` + +### Build Fails Silently + +Add error handling: +```ts +try { + await build(config) +} catch (error) { + console.error(error) + process.exit(1) +} +``` + +### Options Not Working + +Check spelling and types: +```ts +// ✅ Correct +{ format: ['esm', 'cjs'] } + +// ❌ Wrong +{ formats: ['esm', 'cjs'] } +``` + +## Related + +- [Config File](option-config-file.md) - Configuration options +- [Hooks](advanced-hooks.md) - Lifecycle hooks +- [CLI](reference-cli.md) - Command-line interface +- [Plugins](advanced-plugins.md) - Plugin system diff --git a/.agents/skills/tsdown/references/advanced-rolldown-options.md b/.agents/skills/tsdown/references/advanced-rolldown-options.md new file mode 100644 index 000000000..2a35044b8 --- /dev/null +++ b/.agents/skills/tsdown/references/advanced-rolldown-options.md @@ -0,0 +1,117 @@ +# Customizing Rolldown Options + +Pass options directly to the underlying Rolldown bundler. + +## Overview + +tsdown uses [Rolldown](https://rolldown.rs) as its core bundling engine. You can override Rolldown's input and output options directly for fine-grained control. + +**Warning:** You should be familiar with Rolldown's behavior before overriding options. Refer to the [Rolldown Config Options](https://rolldown.rs/options/input) documentation. + +## Input Options + +### Using an Object + +```ts +export default defineConfig({ + inputOptions: { + cwd: './custom-directory', + }, +}) +``` + +### Using a Function + +Dynamically modify options based on the output format: + +```ts +export default defineConfig({ + inputOptions(inputOptions, format) { + inputOptions.cwd = './custom-directory' + return inputOptions + }, +}) +``` + +## Output Options + +### Using an Object + +```ts +export default defineConfig({ + outputOptions: { + legalComments: 'inline', + }, +}) +``` + +### Using a Function + +```ts +export default defineConfig({ + outputOptions(outputOptions, format) { + if (format === 'esm') { + outputOptions.legalComments = 'inline' + } + return outputOptions + }, +}) +``` + +## Common Use Cases + +### Preserve Legal Comments + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + outputOptions: { + legalComments: 'inline', + }, +}) +``` + +### Custom Working Directory + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + inputOptions: { + cwd: './packages/my-lib', + }, +}) +``` + +### Format-Specific Options + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outputOptions(outputOptions, format) { + if (format === 'esm') { + outputOptions.legalComments = 'inline' + } + return outputOptions + }, +}) +``` + +## When to Use + +- When tsdown doesn't expose a specific Rolldown option +- For format-specific Rolldown customizations +- For advanced bundling scenarios + +## Tips + +1. **Read Rolldown docs** before overriding options +2. **Use functions** for format-specific customization +3. **Test thoroughly** when overriding defaults +4. **Prefer tsdown options** when available (e.g., use `minify` instead of setting it via `outputOptions`) + +## Related + +- [Plugins](advanced-plugins.md) - Plugin system +- [Hooks](advanced-hooks.md) - Lifecycle hooks +- [Config File](option-config-file.md) - Configuration options diff --git a/.agents/skills/tsdown/references/guide-getting-started.md b/.agents/skills/tsdown/references/guide-getting-started.md new file mode 100644 index 000000000..d7acc9fec --- /dev/null +++ b/.agents/skills/tsdown/references/guide-getting-started.md @@ -0,0 +1,183 @@ +# Getting Started + +Quick guide to installing and using tsdown for the first time. + +## Installation + +Install tsdown as a development dependency: + +```bash +pnpm add -D tsdown + +# Optionally install TypeScript if not using isolatedDeclarations +pnpm add -D typescript +``` + +**Requirements:** +- Node.js 22.18.0 or higher **to run tsdown** (build-time only) +- Experimental support for Deno and Bun + +> [!NOTE] +> The Node.js 22.18+ requirement only applies to the environment that runs `tsdown` itself. The **bundled output** can target much lower Node.js versions via the [`target`](./option-target.md) option, so libraries built with tsdown are not locked to Node.js 22+ at runtime. +> +> If your package needs to support Node.js 18 / 20, the recommended workflow is to **build with Node.js 22+ in CI**, then **test the built output (or the packed tarball) against the lower Node.js versions** you intend to support. + +## Quick Start Templates + +Use `create-tsdown` CLI for instant setup: + +```bash +pnpm create tsdown@latest +``` + +Provides templates for: +- Pure TypeScript libraries +- React component libraries +- Vue component libraries +- Ready-to-use configurations + +## First Bundle + +### 1. Create Source Files + +```ts +// src/index.ts +import { hello } from './hello.ts' +hello() + +// src/hello.ts +export function hello() { + console.log('Hello tsdown!') +} +``` + +### 2. Create Config File + +```ts +// tsdown.config.ts +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['./src/index.ts'], +}) +``` + +### 3. Run Build + +```bash +./node_modules/.bin/tsdown +``` + +Output: `dist/index.mjs` + +### 4. Test Output + +```bash +node dist/index.mjs +# Output: Hello tsdown! +``` + +## Add to npm Scripts + +```json +{ + "scripts": { + "build": "tsdown" + } +} +``` + +Run with: + +```bash +pnpm build +``` + +## CLI Commands + +```bash +# Check version +tsdown --version + +# View help +tsdown --help + +# Build with watch mode +tsdown --watch + +# Build with specific format +tsdown --format esm,cjs + +# Generate type declarations +tsdown --dts +``` + +## Basic Configurations + +### TypeScript Library (ESM + CJS) + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, +}) +``` + +### Browser Library (IIFE) + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['iife'], + globalName: 'MyLib', + platform: 'browser', + minify: true, +}) +``` + +### Multiple Entry Points + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + utils: 'src/utils.ts', + cli: 'src/cli.ts', + }, + format: ['esm', 'cjs'], + dts: true, +}) +``` + +## Using Plugins + +Add Rolldown, Rollup, or Unplugin plugins: + +```ts +import SomePlugin from 'some-plugin' + +export default defineConfig({ + entry: ['src/index.ts'], + plugins: [SomePlugin()], +}) +``` + +## Watch Mode + +Enable automatic rebuilds on file changes: + +```bash +tsdown --watch +# or +tsdown -w +``` + +## Next Steps + +- Configure [entry points](option-entry.md) with glob patterns +- Set up [multiple output formats](option-output-format.md) +- Enable [type declaration generation](option-dts.md) +- Explore [plugins](advanced-plugins.md) for extended functionality +- Read [migration guide](guide-migrate-from-tsup.md) if coming from tsup diff --git a/.agents/skills/tsdown/references/guide-introduction.md b/.agents/skills/tsdown/references/guide-introduction.md new file mode 100644 index 000000000..52d717e3c --- /dev/null +++ b/.agents/skills/tsdown/references/guide-introduction.md @@ -0,0 +1,42 @@ +# Introduction + +**tsdown** is _The Elegant Library Bundler_ — a fast, simple bundler for TypeScript and JavaScript libraries powered by Rolldown (Rust-based). + +## Why tsdown? + +Built on [Rolldown](https://rolldown.rs), tsdown provides a complete out-of-the-box solution for library authors: + +- **Simplified Configuration**: Sensible defaults for library development, minimal boilerplate +- **Library-Specific Features**: Auto TypeScript declarations, multiple output formats, package validation +- **Future-Ready**: Official Rolldown project, foundation for Rolldown Vite's Library Mode + +## Plugin Ecosystem + +Supports the full Rolldown plugin ecosystem plus most Rollup plugins. See [Plugins](advanced-plugins.md). + +## What Can It Bundle? + +- **TypeScript/JavaScript**: `.ts`, `.js` with modern syntax +- **TypeScript Declarations**: Auto-generate `.d.ts` files +- **Multiple Formats**: `esm`, `cjs`, `iife`, `umd` +- **Assets**: `.json`, `.wasm`, CSS files +- Built-in tree shaking, minification, and source maps + +## Key Differences from Rolldown + +tsdown wraps Rolldown with library-specific features: +- Auto-external `dependencies`, `peerDependencies`, and `optionalDependencies` from `package.json` +- DTS generation +- `package.json` exports field generation +- Watch mode with keyboard shortcuts +- CSS preprocessing pipeline +- Executable bundling (SEA) + +## Prior Arts + +Inspired by: Rollup, esbuild, tsup, unbuild. Powered by Rolldown. + +## Related + +- [Getting Started](guide-getting-started.md) - Installation and first build +- [Migrate from tsup](guide-migrate-from-tsup.md) - Migration guide diff --git a/.agents/skills/tsdown/references/guide-migrate-from-tsup.md b/.agents/skills/tsdown/references/guide-migrate-from-tsup.md new file mode 100644 index 000000000..b0568e50d --- /dev/null +++ b/.agents/skills/tsdown/references/guide-migrate-from-tsup.md @@ -0,0 +1,199 @@ +# Migrate from tsup + +Migration guide for switching from tsup to tsdown. + +## Overview + +tsdown is built on Rolldown (Rust-based) vs tsup's esbuild, providing faster and more powerful bundling while maintaining compatibility. + +## Automatic Migration + +### Single Package + +```bash +npx tsdown-migrate +``` + +### Monorepo + +```bash +# Using glob patterns +npx tsdown-migrate packages/* + +# Multiple directories +npx tsdown-migrate packages/foo packages/bar +``` + +### Migration Options + +- `[...dirs]` - Directories to migrate (supports globs) +- `--dry-run` or `-d` - Preview changes without modifying files + +**Important:** Commit your changes before running migration. + +## Key Differences + +### Default Values + +| Option | tsup | tsdown | +|--------|------|--------| +| `format` | `['cjs']` | `['esm']` | +| `clean` | `false` | `true` | +| `dts` | `false` | Auto-enabled if `types`/`typings` in package.json | +| `target` | Manual | Auto-read from `engines.node` in package.json | + +### Option Renames + +| tsup | tsdown | +|------|--------| +| `outExtension` | `outExtensions` | + +### Output Filename Differences + +For IIFE builds, `tsdown` emits `[name].iife.js`; `tsup` commonly emitted `[name].global.js`. `outExtensions` customizes extensions or suffixes, but it does not remove `.iife` or `.umd`. Use `outputOptions.entryFileNames: '[name].global.js'` to preserve old IIFE filenames. + +### New Features in tsdown + +#### Node Protocol Control + +```ts +export default defineConfig({ + nodeProtocol: true, // Add node: prefix (fs → node:fs) + nodeProtocol: 'strip', // Remove node: prefix (node:fs → fs) + nodeProtocol: false, // Keep as-is (default) +}) +``` + +#### Better Workspace Support + +```ts +export default defineConfig({ + workspace: 'packages/*', // Build all packages +}) +``` + +## Migration Checklist + +1. **Backup your code** - Commit all changes +2. **Run migration tool** - `npx tsdown-migrate` +3. **Review changes** - Check modified config files +4. **Update scripts** - Change `tsup` to `tsdown` in package.json +5. **Test build** - Run `pnpm build` to verify +6. **Adjust config** - Fine-tune based on your needs + +## Common Migration Patterns + +### Basic Library + +**Before (tsup):** +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, +}) +``` + +**After (tsdown):** +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], // ESM now default + dts: true, + clean: true, // Now enabled by default +}) +``` + +### With Custom Target + +**Before (tsup):** +```ts +export default defineConfig({ + entry: ['src/index.ts'], + target: 'es2020', +}) +``` + +**After (tsdown):** +```ts +export default defineConfig({ + entry: ['src/index.ts'], + // target auto-reads from package.json engines.node + // Or override explicitly: + target: 'es2020', +}) +``` + +### CLI Scripts + +**Before (package.json):** +```json +{ + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + } +} +``` + +**After (package.json):** +```json +{ + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch" + } +} +``` + +## Feature Compatibility + +### Supported tsup Features + +Most tsup features are supported: +- ✅ Multiple entry points +- ✅ Multiple formats (ESM, CJS, IIFE, UMD) +- ✅ TypeScript declarations +- ✅ Source maps +- ✅ Minification +- ✅ Watch mode +- ✅ External dependencies +- ✅ Tree shaking +- ✅ Shims +- ✅ Plugins (Rollup compatible) + +### Missing Features + +Some tsup features are not yet available. Check [GitHub issues](https://github.com/rolldown/tsdown/issues) for status and request features. + +## Troubleshooting + +### Build Fails After Migration + +1. **Check Node.js version** - Requires Node.js 22.18.0+ to run tsdown itself. The bundled output can still target lower Node.js versions via `target`; if you need to support Node.js 18 / 20, build with Node.js 22+ in CI and test the produced output (or packed tarball) on the lower versions. +2. **Install TypeScript** - Required for DTS generation +3. **Review config changes** - Ensure format and options are correct +4. **Check dependencies** - Verify all dependencies are installed + +### Different Output + +- **Format order** - tsdown defaults to ESM first +- **Clean behavior** - tsdown cleans outDir by default +- **Target** - tsdown auto-detects from package.json + +### Performance Issues + +tsdown should be faster than tsup. If not: +1. Enable `isolatedDeclarations` for faster DTS generation +2. Check for large dependencies being bundled +3. Use `skipNodeModulesBundle` if needed + +## Getting Help + +- [GitHub Issues](https://github.com/rolldown/tsdown/issues) - Report bugs or request features +- [Documentation](https://tsdown.dev) - Full documentation +- [Migration Tool](https://github.com/rolldown/tsdown/tree/main/packages/migrate) - Source code + +## Acknowledgements + +tsdown is heavily inspired by tsup and incorporates parts of its codebase. Thanks to [@egoist](https://github.com/egoist) and the tsup community. diff --git a/.agents/skills/tsdown/references/option-cjs-default.md b/.agents/skills/tsdown/references/option-cjs-default.md new file mode 100644 index 000000000..85ba705e4 --- /dev/null +++ b/.agents/skills/tsdown/references/option-cjs-default.md @@ -0,0 +1,98 @@ +# CJS Default Export + +Control how default exports are handled in CommonJS output. + +## Overview + +The `cjsDefault` option improves compatibility when generating CommonJS modules. When enabled (default), modules with only a single default export use `module.exports = ...` instead of `exports.default = ...`. + +## Type + +```ts +cjsDefault?: boolean // default: true +``` + +## Basic Usage + +### Enabled (Default) + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs'], + cjsDefault: true, // default behavior +}) +``` + +### Disabled + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs'], + cjsDefault: false, +}) +``` + +## How It Works + +### With `cjsDefault: true` (Default) + +When your module has **only a single default export**, tsdown transforms: + +**Source:** +```ts +// src/index.ts +export default function greet() { + console.log('Hello, world!') +} +``` + +**Generated CJS:** +```js +// dist/index.cjs +function greet() { + console.log('Hello, world!') +} +module.exports = greet +``` + +**Generated Declaration:** +```ts +// dist/index.d.cts +declare function greet(): void +export = greet +``` + +This allows consumers to use `const greet = require('your-module')` directly. + +### With `cjsDefault: false` + +The default export stays as `exports.default`: + +```js +// dist/index.cjs +function greet() { + console.log('Hello, world!') +} +exports.default = greet +``` + +Consumers need `require('your-module').default`. + +## When to Disable + +- When your module has both default and named exports +- When you need consistent `exports.default` behavior +- When consumers always use ESM imports + +## Tips + +1. **Leave enabled** for most libraries (default `true`) +2. **Disable** if you have both default and named exports and need consistent behavior +3. **Test CJS consumers** to verify compatibility + +## Related Options + +- [Output Format](option-output-format.md) - Module formats +- [Shims](option-shims.md) - ESM/CJS compatibility diff --git a/.agents/skills/tsdown/references/option-cleaning.md b/.agents/skills/tsdown/references/option-cleaning.md new file mode 100644 index 000000000..9afa0f806 --- /dev/null +++ b/.agents/skills/tsdown/references/option-cleaning.md @@ -0,0 +1,275 @@ +# Output Directory Cleaning + +Control how the output directory is cleaned before builds. + +## Overview + +By default, tsdown **cleans the output directory** before each build to remove stale files from previous builds. + +## Basic Usage + +### CLI + +```bash +# Clean enabled (default) +tsdown + +# Disable cleaning +tsdown --no-clean +``` + +### Config File + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + clean: true, // Default +}) +``` + +## Behavior + +### With Cleaning (Default) + +Before each build: +1. All files in `outDir` are removed +2. Fresh build starts with empty directory +3. Only current build outputs remain + +**Benefits:** +- No stale files +- Predictable output +- Clean slate each build + +### Without Cleaning + +Build outputs are added to existing files: + +```ts +export default defineConfig({ + clean: false, +}) +``` + +**Use when:** +- Multiple builds to same directory +- Incremental builds +- Preserving other files +- Watch mode (faster rebuilds) + +## Common Patterns + +### Production Build + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + clean: true, // Ensure clean output + minify: true, +}) +``` + +### Development Mode + +```ts +export default defineConfig((options) => ({ + entry: ['src/index.ts'], + clean: !options.watch, // Don't clean in watch mode + sourcemap: options.watch, +})) +``` + +### Multiple Builds + +```ts +export default defineConfig([ + { + entry: ['src/index.ts'], + outDir: 'dist', + clean: true, // Clean once + }, + { + entry: ['src/cli.ts'], + outDir: 'dist', + clean: false, // Don't clean, add to same dir + }, +]) +``` + +### Monorepo Package + +```ts +export default defineConfig({ + workspace: 'packages/*', + entry: ['src/index.ts'], + clean: true, // Clean each package's dist +}) +``` + +### Preserve Static Files + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + clean: false, // Keep manually added files + outDir: 'dist', +}) + +// Manually copy files first +// Then run tsdown --no-clean +``` + +## Clean Patterns + +### Selective Cleaning + +```ts +import { rmSync } from 'fs' + +export default defineConfig({ + clean: false, // Disable auto clean + hooks: { + 'build:prepare': () => { + // Custom cleaning logic + rmSync('dist/*.js', { force: true }) + // Keep other files + }, + }, +}) +``` + +### Clean Specific Directories + +```ts +export default defineConfig({ + clean: false, + hooks: { + 'build:prepare': async () => { + const { rm } = await import('fs/promises') + // Only clean specific subdirectories + await rm('dist/esm', { recursive: true, force: true }) + await rm('dist/cjs', { recursive: true, force: true }) + // Keep dist/types + }, + }, +}) +``` + +## Watch Mode Behavior + +In watch mode, cleaning behavior is important: + +### Clean on First Build Only + +```ts +export default defineConfig((options) => ({ + entry: ['src/index.ts'], + watch: options.watch, + clean: !options.watch, // Only clean initial build +})) +``` + +**Result:** +- First build: Clean +- Subsequent rebuilds: Incremental + +### Always Clean + +```ts +export default defineConfig({ + watch: true, + clean: true, // Clean every rebuild +}) +``` + +**Trade-off:** Slower rebuilds, but always fresh output. + +## Tips + +1. **Leave enabled** for production builds +2. **Disable in watch mode** for faster rebuilds +3. **Use multiple configs** carefully with cleaning +4. **Custom clean logic** via hooks if needed +5. **Be cautious** - cleaning removes ALL files in outDir +6. **Test cleaning** - ensure no important files are lost + +## Troubleshooting + +### Important Files Deleted + +- Don't put non-build files in outDir +- Use separate directory for static files +- Disable cleaning and manage manually + +### Stale Files in Output + +- Enable cleaning: `clean: true` +- Or manually remove before build + +### Slow Rebuilds in Watch + +- Disable cleaning in watch mode +- Use incremental builds + +## CLI Examples + +```bash +# Default (clean enabled) +tsdown + +# Disable cleaning +tsdown --no-clean + +# Watch mode without cleaning +tsdown --watch --no-clean + +# Multiple formats with cleaning +tsdown --format esm,cjs --clean +``` + +## Examples + +### Safe Production Build + +```bash +# Clean before build +rm -rf dist +tsdown --clean +``` + +### Incremental Development + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + watch: true, + clean: false, // Faster rebuilds + sourcemap: true, +}) +``` + +### Multi-Stage Build + +```ts +// Stage 1: Clean and build main +export default defineConfig([ + { + entry: ['src/index.ts'], + outDir: 'dist', + clean: true, + }, + { + entry: ['src/utils.ts'], + outDir: 'dist', + clean: false, // Add to same directory + }, +]) +``` + +## Related Options + +- [Output Directory](option-output-directory.md) - Configure outDir +- [Watch Mode](option-watch-mode.md) - Development workflow +- [Hooks](advanced-hooks.md) - Custom clean logic +- [Entry](option-entry.md) - Entry points diff --git a/.agents/skills/tsdown/references/option-config-file.md b/.agents/skills/tsdown/references/option-config-file.md new file mode 100644 index 000000000..85d5909d9 --- /dev/null +++ b/.agents/skills/tsdown/references/option-config-file.md @@ -0,0 +1,291 @@ +# Configuration File + +Centralize and manage build settings with a configuration file. + +## Overview + +tsdown searches for config files automatically in the current directory and parent directories. + +## Supported File Names + +tsdown looks for these files (in order): +- `tsdown.config.ts` +- `tsdown.config.mts` +- `tsdown.config.cts` +- `tsdown.config.js` +- `tsdown.config.mjs` +- `tsdown.config.cjs` +- `tsdown.config.json` +- `tsdown.config` +- `package.json` (in `tsdown` field) + +## Basic Configuration + +### TypeScript Config + +```ts +// tsdown.config.ts +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, +}) +``` + +### JavaScript Config + +```js +// tsdown.config.js +export default { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +} +``` + +### JSON Config + +```json +// tsdown.config.json +{ + "entry": ["src/index.ts"], + "format": ["esm", "cjs"], + "dts": true +} +``` + +### Package.json Config + +```json +// package.json +{ + "name": "my-library", + "tsdown": { + "entry": ["src/index.ts"], + "format": ["esm", "cjs"], + "dts": true + } +} +``` + +## Multiple Configurations + +Build multiple outputs with different settings: + +```ts +export default defineConfig([ + { + entry: 'src/index.ts', + format: ['esm', 'cjs'], + platform: 'node', + dts: true, + }, + { + entry: 'src/browser.ts', + format: ['iife'], + platform: 'browser', + globalName: 'MyLib', + minify: true, + }, +]) +``` + +Each configuration runs as a separate build. + +## Dynamic Configuration + +Use a function for conditional config: + +```ts +export default defineConfig((options) => { + const isDev = options.watch + + return { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: !isDev, + sourcemap: isDev, + clean: !isDev, + } +}) +``` + +Available options: +- `watch` - Whether watch mode is enabled +- Other CLI flags passed to config + +## Config Loaders + +Control how TypeScript config files are loaded: + +### Auto Loader (Default) + +Uses native TypeScript support if available, otherwise falls back to `unrun`: + +```bash +tsdown # Uses auto loader +``` + +### Native Loader + +Uses runtime's native TypeScript support (Node.js 22.18.0+, Bun, Deno): + +```bash +tsdown --config-loader native +``` + +### tsx Loader + +Uses [tsx](https://tsx.is/) library for loading via its tsImport API. Note: `tsx` is an optional peer dependency — install it manually first. + +```bash +pnpm add -D tsx +tsdown --config-loader tsx +``` + +### Unrun Loader + +Uses [unrun](https://gugustinette.github.io/unrun/) library for loading. Note: `unrun` is an optional peer dependency — install it manually first. + +```bash +pnpm add -D unrun +tsdown --config-loader unrun +``` + +**Tip:** Use `tsx` or `unrun` loader if you need to load TypeScript configs without file extensions in Node.js. + +## Custom Config Path + +Specify a custom config file location: + +```bash +tsdown --config ./configs/build.config.ts +# or +tsdown -c custom-config.ts +``` + +## Disable Config File + +Ignore config files and use CLI options only: + +```bash +tsdown --no-config src/index.ts --format esm +``` + +## Extend Vite/Vitest Config (Experimental) + +Reuse existing Vite or Vitest configurations: + +```bash +# Extend vite.config.* +tsdown --from-vite + +# Extend vitest.config.* +tsdown --from-vite vitest +``` + +**Note:** Only specific options like `resolve` and `plugins` are reused. Test thoroughly as this feature is experimental. + +## Workspace / Monorepo + +Build multiple packages with a single config: + +```ts +export default defineConfig({ + workspace: 'packages/*', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +}) +``` + +Each package directory matching the glob pattern will be built with the same configuration. + +## Common Patterns + +### Library with Multiple Builds + +```ts +export default defineConfig([ + // Node.js build + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + platform: 'node', + dts: true, + }, + // Browser build + { + entry: ['src/browser.ts'], + format: ['iife'], + platform: 'browser', + globalName: 'MyLib', + }, +]) +``` + +### Development vs Production + +```ts +export default defineConfig((options) => ({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: !options.watch, + sourcemap: options.watch ? true : false, + clean: !options.watch, +})) +``` + +### Monorepo Root Config + +```ts +// Root tsdown.config.ts +export default defineConfig({ + workspace: 'packages/*', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + // Shared config for all packages +}) +``` + +### Per-Package Override + +```ts +// packages/special/tsdown.config.ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], // Override: only ESM + platform: 'browser', // Override: browser only +}) +``` + +## Config Precedence + +When multiple configs exist: + +1. CLI options (highest priority) +2. Config file specified with `--config` +3. Auto-discovered config files +4. Package.json `tsdown` field +5. Default values + +## Tips + +1. **Use TypeScript config** for type checking and autocomplete +2. **Use defineConfig** helper for better DX +3. **Export arrays** for multiple build configurations +4. **Use functions** for dynamic/conditional configs +5. **Keep configs simple** - prefer convention over configuration +6. **Use workspace** for monorepo builds +7. **Test experimental features** thoroughly before production use + +## Related Options + +- [Entry](option-entry.md) - Configure entry points +- [Output Format](option-output-format.md) - Output formats +- [Watch Mode](option-watch-mode.md) - Watch mode configuration diff --git a/.agents/skills/tsdown/references/option-css.md b/.agents/skills/tsdown/references/option-css.md new file mode 100644 index 000000000..4a0c692db --- /dev/null +++ b/.agents/skills/tsdown/references/option-css.md @@ -0,0 +1,301 @@ +# CSS Support + +**Status: Experimental — API and behavior may change.** + +Configure CSS handling including preprocessors, syntax lowering, minification, and code splitting. + +## Getting Started + +All CSS support in `tsdown` is provided by the `@tsdown/css` package. Install it to enable CSS handling: + +```bash +npm install -D @tsdown/css +``` + +When `@tsdown/css` is installed, CSS processing is automatically enabled. Without it, encountering CSS files will result in an error. + +## CSS Import + +Import `.css` files from TypeScript/JavaScript — CSS is extracted into separate `.css` assets: + +```ts +// src/index.ts +import './style.css' +export function greet() { return 'Hello' } +``` + +Output: `index.mjs` + `index.css` + +### `@import` Inlining + +CSS `@import` statements are resolved and inlined automatically. No separate output files produced. + +### Inline CSS (`?inline`) + +Append `?inline` to return processed CSS as a JS string instead of emitting a `.css` file: + +```ts +import './style.css' // → .css file +import css from './theme.css?inline' // → JS string +``` + +Works with preprocessors too (`./foo.scss?inline`). Goes through full pipeline (preprocessors, @import inlining, lowering, minification). Tree-shakeable (`moduleSideEffects: false`). + +## CSS Pre-processors + +Built-in support for Sass, Less, and Stylus. Install the preprocessor: + +```bash +# Sass (either one) +npm install -D sass-embedded # recommended, faster +npm install -D sass + +# Less +npm install -D less + +# Stylus +npm install -D stylus +``` + +Then import directly: + +```ts +import './style.scss' +import './theme.less' +import './global.styl' +``` + +### Preprocessor Options + +```ts +export default defineConfig({ + css: { + preprocessorOptions: { + scss: { + additionalData: `$brand-color: #ff7e17;`, + }, + less: { + math: 'always', + }, + stylus: { + define: { '$brand-color': '#ff7e17' }, + }, + }, + }, +}) +``` + +### `additionalData` + +Inject code at the beginning of every preprocessor file: + +```ts +// String form +scss: { + additionalData: `@use "src/styles/variables" as *;`, +} + +// Function form +scss: { + additionalData: (source, filename) => { + if (filename.includes('theme')) return source + return `@use "src/styles/variables" as *;\n${source}` + }, +} +``` + +## CSS Minification + +```ts +export default defineConfig({ + css: { + minify: true, + }, +}) +``` + +Powered by Lightning CSS. + +## CSS Target + +Override the top-level `target` specifically for CSS: + +```ts +export default defineConfig({ + target: 'node18', + css: { + target: 'chrome90', // CSS-specific target + }, +}) +``` + +Set `css.target: false` to disable CSS syntax lowering entirely. + +## CSS Transformer + +`css.transformer` controls mutually exclusive CSS processing paths: + +- `'lightningcss'` (default): `@import` via Lightning CSS `bundleAsync()`, no PostCSS. +- `'postcss'`: `@import` via `postcss-import`, PostCSS plugins applied, Lightning CSS for final transform only. + +```ts +export default defineConfig({ + css: { + transformer: 'postcss', + }, +}) +``` + +### PostCSS Options + +```ts +export default defineConfig({ + css: { + transformer: 'postcss', + postcss: { + plugins: [require('autoprefixer')], + }, + // Or: postcss: './config' — path to search for postcss.config.js + }, +}) +``` + +Auto-detects PostCSS config from project root when `transformer` is `'postcss'` and `css.postcss` is omitted. + +## Lightning CSS (Syntax Lowering) + +Install `lightningcss` to enable CSS syntax lowering based on your `target`: + +```bash +npm install -D lightningcss +``` + +When `target` is set (e.g., `target: 'chrome108'`), modern CSS features are automatically downleveled: + +```css +/* Input */ +.foo { & .bar { color: red } } + +/* Output (chrome108) */ +.foo .bar { color: red } +``` + +### Custom Lightning CSS Options + +```ts +import { Features } from 'lightningcss' + +export default defineConfig({ + css: { + lightningcss: { + targets: { chrome: 100 << 16 }, + include: Features.Nesting, + }, + }, +}) +``` + +`css.lightningcss.targets` takes precedence over both `target` and `css.target` for CSS. + +## CSS Modules + +Files with `.module.css` (and `.module.scss`, `.module.less`, etc.) are treated as CSS modules — class names are scoped and exported as JS: + +```ts +import styles from './app.module.css' +console.log(styles.title) // "scoped_title_hash" +``` + +### Configuration + +```ts +export default defineConfig({ + css: { + modules: { + scopeBehaviour: 'local', // 'local' (default) | 'global' + generateScopedName: '[hash]_[local]', // Lightning CSS pattern string + localsConvention: 'camelCase', // 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly' + }, + }, +}) +``` + +Set `css.modules: false` to disable. Function-form `generateScopedName` requires `transformer: 'postcss'`. + +### Optional Dependencies (PostCSS path) + +```bash +npm install -D postcss postcss-modules +``` + +## Code Splitting + +### Merged (Default) + +All CSS merged into a single file (default: `style.css`). + +```ts +export default defineConfig({ + css: { + fileName: 'my-library.css', // Custom name (default: 'style.css') + }, +}) +``` + +### Per-Chunk Splitting + +```ts +export default defineConfig({ + css: { + splitting: true, // Each JS chunk gets a corresponding .css file + }, +}) +``` + +## Preserving CSS Imports (`css.inject`) + +When enabled, JS output preserves `import` statements pointing to emitted CSS files. Consumers auto-import CSS alongside JS: + +```ts +export default defineConfig({ + css: { + inject: true, + }, +}) +``` + +## PostCSS Optional Peer Dependencies + +When using `transformer: 'postcss'`, install these as needed: + +| Package | Purpose | Required When | +|---------|---------|---------------| +| `postcss` | Core PostCSS engine | Always (with `transformer: 'postcss'`) | +| `postcss-import` | Resolve/inline `@import` | CSS uses `@import` | +| `postcss-modules` | CSS modules (scoped classes) | Using `.module.css` files | + +```bash +npm install -D postcss postcss-import postcss-modules +``` + +All declared as optional peer dependencies of `@tsdown/css`. + +## Options Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `css.transformer` | `'postcss' \| 'lightningcss'` | `'lightningcss'` | CSS processing pipeline | +| `css.splitting` | `boolean` | `false` | Per-chunk CSS splitting | +| `css.fileName` | `string` | `'style.css'` | Merged CSS file name | +| `css.minify` | `boolean` | `false` | CSS minification | +| `css.modules` | `object \| false` | `{}` | CSS modules config, or `false` to disable | +| `css.inject` | `boolean` | `false` | Preserve CSS imports in JS output | +| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific lowering target | +| `css.postcss` | `string \| object` | — | PostCSS config path or inline options | +| `css.preprocessorOptions` | `object` | — | Preprocessor options | +| `css.lightningcss` | `object` | — | Lightning CSS options | + +## Related + +- [Target](option-target.md) - Configure syntax lowering targets +- [Output Format](option-output-format.md) - Module output formats diff --git a/.agents/skills/tsdown/references/option-dependencies.md b/.agents/skills/tsdown/references/option-dependencies.md new file mode 100644 index 000000000..52e9b9ae3 --- /dev/null +++ b/.agents/skills/tsdown/references/option-dependencies.md @@ -0,0 +1,385 @@ +# Dependencies + +Control how dependencies are bundled or externalized. + +## Overview + +tsdown intelligently handles dependencies to keep your library lightweight while ensuring all necessary code is included. + +## Default Behavior + +### Auto-Externalized + +These are **NOT bundled** by default: + +- **`dependencies`** - Installed automatically with your package +- **`peerDependencies`** - User must install manually +- **`optionalDependencies`** - May or may not be installed depending on platform/config + +### Conditionally Bundled + +These are **bundled ONLY if imported**: + +- **`devDependencies`** - Only if actually used in source code +- **Phantom dependencies** - In node_modules but not in package.json + +## Configuration Options + +All dependency options are grouped under the `deps` field: + +```ts +export default defineConfig({ + deps: { + neverBundle: ['react', /^@myorg\//], + alwaysBundle: ['some-package'], + onlyBundle: ['cac', 'bumpp'], + skipNodeModulesBundle: true, + }, +}) +``` + +### `deps.neverBundle` + +Mark dependencies as external (not bundled): + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + deps: { + neverBundle: [ + 'react', // Single package + 'react-dom', + /^@myorg\//, // Regex pattern (all @myorg/* packages) + /^lodash/, // All lodash packages + ], + }, +}) +``` + +### `deps.alwaysBundle` + +Force dependencies to be bundled: + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + deps: { + alwaysBundle: [ + 'some-package', // Bundle this even if in dependencies + 'vendor-lib', + ], + }, +}) +``` + +### `deps.onlyBundle` + +Whitelist of dependencies allowed to be bundled from node_modules. Throws an error if any unlisted dependency is bundled: + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + deps: { + onlyBundle: [ + 'cac', // Allow bundling cac + 'bumpp', // Allow bundling bumpp + /^my-utils/, // Regex patterns supported + ], + }, +}) +``` + +**Behavior:** +- **Array** (`['cac', /^my-/]`): Only matching dependencies can be bundled. Error for others. +- **`false`**: Suppress all warnings about bundled dependencies. +- **Not set** (default): Warns if any node_modules dependencies are bundled. + +**Note:** Include all sub-dependencies in the list, not just top-level imports. + +### `deps.skipNodeModulesBundle` + +Skip bundling ALL node_modules: + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + deps: { + skipNodeModulesBundle: true, + }, +}) +``` + +**Result:** No dependencies from node_modules are bundled. + +**Note:** Cannot be used together with `alwaysBundle`. + +## Common Patterns + +### React Component Library + +```ts +export default defineConfig({ + entry: ['src/index.tsx'], + format: ['esm', 'cjs'], + deps: { + neverBundle: [ + 'react', + 'react-dom', + /^react\//, // react/jsx-runtime, etc. + ], + }, + dts: true, +}) +``` + +### Utility Library with Shared Deps + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + alwaysBundle: ['lodash-es'], + }, + dts: true, +}) +``` + +### Monorepo Package + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + neverBundle: [ + /^@mycompany\//, // Don't bundle other workspace packages + ], + }, + dts: true, +}) +``` + +### CLI Tool (Bundle Everything) + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + format: ['esm'], + platform: 'node', + deps: { + alwaysBundle: [/.*/], + }, + shims: true, +}) +``` + +### Library with Specific Externals + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + neverBundle: [ + 'vue', + '@vue/runtime-core', + '@vue/reactivity', + ], + }, + dts: true, +}) +``` + +## Declaration Files + +Dependency handling for `.d.ts` files follows the same rules as JavaScript. + +### Complex Type Resolution + +Use TypeScript resolver for complex third-party types: + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + dts: { + resolver: 'tsc', // Use TypeScript resolver instead of Oxc + }, +}) +``` + +**When to use `tsc` resolver:** +- Types in `@types/*` packages with non-standard naming (e.g., `@types/babel__generator`) +- Complex type dependencies +- Issues with default Oxc resolver + +**Trade-off:** `tsc` is slower but more compatible. + +## CLI Usage + +### Never Bundle + +```bash +tsdown --deps.never-bundle react --deps.never-bundle react-dom +tsdown --deps.never-bundle '/^@myorg\/.*/' +``` + +### Skip Node Modules + +```bash +tsdown --deps.skip-node-modules-bundle +``` + +## Migration from Deprecated Options + +| Deprecated Option | New Option | +|---|---| +| `external` | `deps.neverBundle` | +| `noExternal` | `deps.alwaysBundle` | +| `inlineOnly` | `deps.onlyBundle` | +| `deps.onlyAllowBundle` | `deps.onlyBundle` | +| `skipNodeModulesBundle` | `deps.skipNodeModulesBundle` | + +## Examples by Use Case + +### Framework Component + +```ts +// Don't bundle framework +export default defineConfig({ + deps: { + neverBundle: ['vue', 'react', 'solid-js', 'svelte'], + }, +}) +``` + +### Standalone App + +```ts +// Bundle everything +export default defineConfig({ + deps: { + alwaysBundle: [/.*/], + }, +}) +``` + +### Shared Library + +```ts +// Bundle only specific utils +export default defineConfig({ + deps: { + neverBundle: [/.*/], // External by default + alwaysBundle: ['tiny-utils'], // Except this one + }, +}) +``` + +### Monorepo Package + +```ts +// External workspace packages, bundle utilities +export default defineConfig({ + deps: { + neverBundle: [ + /^@workspace\//, // Other workspace packages + 'react', + 'react-dom', + ], + alwaysBundle: [ + 'lodash-es', // Bundle utility libraries + ], + }, +}) +``` + +## Troubleshooting + +### Dependency Bundled Unexpectedly + +Check if it's in `devDependencies` and imported. Move to `dependencies`: + +```json +{ + "dependencies": { + "should-be-external": "^1.0.0" + } +} +``` + +Or explicitly externalize: + +```ts +export default defineConfig({ + deps: { + neverBundle: ['should-be-external'], + }, +}) +``` + +### Missing Dependency at Runtime + +Ensure it's in `dependencies`, `peerDependencies`, or `optionalDependencies`: + +```json +{ + "dependencies": { + "needed-package": "^1.0.0" + } +} +``` + +Or bundle it: + +```ts +export default defineConfig({ + deps: { + alwaysBundle: ['needed-package'], + }, +}) +``` + +### Type Resolution Errors + +Use TypeScript resolver for complex types: + +```ts +export default defineConfig({ + dts: { + resolver: 'tsc', + }, +}) +``` + +## Summary + +**Default behavior:** +- `dependencies`, `peerDependencies`, & `optionalDependencies` → External +- `devDependencies` & phantom deps → Bundled if imported + +**Override (under `deps`):** +- `neverBundle` → Force external +- `alwaysBundle` → Force bundled +- `onlyBundle` → Whitelist bundled deps +- `skipNodeModulesBundle` → Skip all node_modules + +**Declaration files:** +- Same bundling logic as JavaScript +- Use `resolver: 'tsc'` for complex types + +## Tips + +1. **Keep dependencies external** for libraries +2. **Bundle everything** for standalone CLIs +3. **Use regex patterns** for namespaced packages +4. **Check bundle size** to verify external/bundled split +5. **Test with fresh install** to catch missing dependencies +6. **Use tsc resolver** only when needed (slower) + +## Related Options + +- [External](option-dependencies.md) - This page +- [Platform](option-platform.md) - Runtime environment +- [Output Format](option-output-format.md) - Module formats +- [DTS](option-dts.md) - Type declarations diff --git a/.agents/skills/tsdown/references/option-dts.md b/.agents/skills/tsdown/references/option-dts.md new file mode 100644 index 000000000..fa2ec6a8b --- /dev/null +++ b/.agents/skills/tsdown/references/option-dts.md @@ -0,0 +1,251 @@ +# TypeScript Declaration Files + +Generate `.d.ts` type declaration files for your library. + +## Overview + +tsdown uses [rolldown-plugin-dts](https://github.com/sxzz/rolldown-plugin-dts) to generate and bundle TypeScript declaration files. + +**Requirements:** +- TypeScript must be installed in your project + +## Enabling DTS Generation + +### Auto-Enabled + +DTS generation is **automatically enabled** if `package.json` contains: +- `types` field, or +- `typings` field + +### Manual Enable + +#### CLI + +```bash +tsdown --dts +``` + +#### Config File + +```ts +export default defineConfig({ + dts: true, +}) +``` + +## Performance + +### With `isolatedDeclarations` (Recommended) + +**Extremely fast** - uses oxc-transform for generation. + +```json +// tsconfig.json +{ + "compilerOptions": { + "isolatedDeclarations": true + } +} +``` + +### Without `isolatedDeclarations` + +Falls back to TypeScript compiler. Reliable but slower. + +## Declaration Maps + +Map `.d.ts` files back to original `.ts` sources (useful for monorepos). + +### Enable in tsconfig.json + +```json +{ + "compilerOptions": { + "declarationMap": true + } +} +``` + +### Enable in tsdown Config + +```ts +export default defineConfig({ + dts: { + sourcemap: true, + }, +}) +``` + +## Advanced Options + +### Custom Compiler Options + +Override TypeScript compiler options: + +```ts +export default defineConfig({ + dts: { + compilerOptions: { + removeComments: false, + }, + }, +}) +``` + +## Build Process + +- **ESM format**: `.js` and `.d.ts` files generated in same build +- **CJS format**: Separate build process for `.d.ts` files + +## Common Patterns + +### Basic Library + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, +}) +``` + +Output: +- `dist/index.mjs` +- `dist/index.cjs` +- `dist/index.d.ts` + +### Multiple Entry Points + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + utils: 'src/utils.ts', + }, + format: ['esm', 'cjs'], + dts: true, +}) +``` + +Output: +- `dist/index.mjs`, `dist/index.cjs`, `dist/index.d.ts` +- `dist/utils.mjs`, `dist/utils.cjs`, `dist/utils.d.ts` + +### With Monorepo Support + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: { + sourcemap: true, // Enable declaration maps + }, +}) +``` + +### Fast Build (Isolated Declarations) + +```json +// tsconfig.json +{ + "compilerOptions": { + "isolatedDeclarations": true, + "declaration": true, + "declarationMap": true + } +} +``` + +```ts +// tsdown.config.ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, // Will use fast oxc-transform +}) +``` + +## Troubleshooting + +### Missing Types + +Ensure TypeScript is installed: + +```bash +pnpm add -D typescript +``` + +### Slow Generation + +Enable `isolatedDeclarations` in `tsconfig.json` for faster builds. + +### Declaration Errors + +Check that all exports have explicit types (required for `isolatedDeclarations`). + +### Report Issues + +For DTS-specific issues, report to [rolldown-plugin-dts](https://github.com/sxzz/rolldown-plugin-dts/issues). + +### Vue Support + +Enable Vue component type generation (requires `vue-tsc`): + +```ts +export default defineConfig({ + dts: { + vue: true, + }, +}) +``` + +### Oxc Transform + +Control Oxc usage for declaration generation: + +```ts +export default defineConfig({ + dts: { + oxc: true, // Use oxc-transform (fast, requires isolatedDeclarations) + }, +}) +``` + +### Custom TSConfig + +Specify a different tsconfig for DTS generation: + +```ts +export default defineConfig({ + dts: { + tsconfig: './tsconfig.build.json', + }, +}) +``` + +## Available DTS Options + +| Option | Type | Description | +|--------|------|-------------| +| `sourcemap` | `boolean` | Generate declaration source maps | +| `compilerOptions` | `object` | Override TypeScript compiler options | +| `vue` | `boolean` | Enable Vue type generation (requires vue-tsc) | +| `oxc` | `boolean` | Use oxc-transform for fast generation | +| `tsconfig` | `string` | Path to tsconfig file | +| `resolver` | `'oxc' \| 'tsc'` | Module resolver: `'oxc'` (default, fast) or `'tsc'` (more compatible) | +| `cjsDefault` | `boolean` | CJS default export handling | +| `sideEffects` | `boolean` | Preserve side effects in declarations | + +## Tips + +1. **Always enable DTS** for TypeScript libraries +2. **Use isolatedDeclarations** for fast builds +3. **Enable declaration maps** in monorepos +4. **Ensure explicit types** for all exports +5. **Install TypeScript** as dev dependency + +## Related Options + +- [Entry](option-entry.md) - Configure entry points +- [Output Format](option-output-format.md) - Multiple output formats +- [Target](option-target.md) - JavaScript version diff --git a/.agents/skills/tsdown/references/option-entry.md b/.agents/skills/tsdown/references/option-entry.md new file mode 100644 index 000000000..639250cfc --- /dev/null +++ b/.agents/skills/tsdown/references/option-entry.md @@ -0,0 +1,211 @@ +# Entry Points + +Configure which files to bundle as entry points. + +## Overview + +Entry points are the starting files for the bundling process. Each entry point generates a separate bundle. + +## Usage Patterns + +### CLI + +```bash +# Single entry +tsdown src/index.ts + +# Multiple entries +tsdown src/index.ts src/cli.ts + +# Glob patterns +tsdown 'src/*.ts' +``` + +### Config File + +#### Single Entry + +```ts +export default defineConfig({ + entry: 'src/index.ts', +}) +``` + +#### Multiple Entries (Array) + +```ts +export default defineConfig({ + entry: ['src/entry1.ts', 'src/entry2.ts'], +}) +``` + +#### Named Entries (Object) + +```ts +export default defineConfig({ + entry: { + main: 'src/index.ts', + utils: 'src/utils.ts', + cli: 'src/cli.ts', + }, +}) +``` + +Output files will match the keys: +- `dist/main.mjs` +- `dist/utils.mjs` +- `dist/cli.mjs` + +## Glob Patterns + +Match multiple files dynamically using glob patterns: + +### All TypeScript Files + +```ts +export default defineConfig({ + entry: 'src/**/*.ts', +}) +``` + +### Exclude Test Files + +```ts +export default defineConfig({ + entry: ['src/*.ts', '!src/*.test.ts'], +}) +``` + +### Object Entries with Glob Patterns + +Use glob wildcards (`*`) in both keys and values. The `*` in the key acts as a placeholder replaced with the matched file name (without extension): + +```ts +export default defineConfig({ + entry: { + // Maps src/foo.ts → dist/lib/foo.js, src/bar.ts → dist/lib/bar.js + 'lib/*': 'src/*.ts', + }, +}) +``` + +#### Negation Patterns in Object Entries + +Values can be an array with negation patterns (`!`): + +```ts +export default defineConfig({ + entry: { + 'hooks/*': ['src/hooks/*.ts', '!src/hooks/index.ts'], + }, +}) +``` + +Multiple positive and negation patterns: + +```ts +export default defineConfig({ + entry: { + 'utils/*': [ + 'src/utils/*.ts', + 'src/utils/*.tsx', + '!src/utils/index.ts', + '!src/utils/internal.ts', + ], + }, +}) +``` + +**Warning:** Multiple positive patterns in an array value must share the same base directory. + +### Mixed Entries + +Mix strings, glob patterns, and object entries in an array: + +```ts +export default defineConfig({ + entry: [ + 'src/*', + '!src/foo.ts', + { main: 'index.ts' }, + { 'lib/*': ['src/*.ts', '!src/bar.ts'] }, + ], +}) +``` + +Object entries take precedence when output names conflict. + +### Windows Compatibility + +Use forward slashes `/` instead of backslashes `\` on Windows: + +```ts +// ✅ Correct +entry: 'src/utils/*.ts' + +// ❌ Wrong on Windows +entry: 'src\\utils\\*.ts' +``` + +## Common Patterns + +### Library with Main Export + +```ts +export default defineConfig({ + entry: 'src/index.ts', + format: ['esm', 'cjs'], + dts: true, +}) +``` + +### Library with Multiple Exports + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + client: 'src/client.ts', + server: 'src/server.ts', + }, + format: ['esm', 'cjs'], + dts: true, +}) +``` + +### CLI Tool + +```ts +export default defineConfig({ + entry: { + cli: 'src/cli.ts', + }, + format: ['esm'], + platform: 'node', +}) +``` + +### Preserve Directory Structure + +Use with `unbundle: true` to keep file structure: + +```ts +export default defineConfig({ + entry: ['src/**/*.ts', '!**/*.test.ts'], + unbundle: true, + format: ['esm'], + dts: true, +}) +``` + +This will output files matching the source structure: +- `src/index.ts` → `dist/index.mjs` +- `src/utils/helper.ts` → `dist/utils/helper.mjs` + +## Tips + +1. **Use glob patterns** for multiple related files +2. **Use object syntax** for custom output names +3. **Exclude test files** with negation patterns `!**/*.test.ts` +4. **Combine with unbundle** to preserve directory structure +5. **Use named entries** for better control over output filenames diff --git a/.agents/skills/tsdown/references/option-exe.md b/.agents/skills/tsdown/references/option-exe.md new file mode 100644 index 000000000..db06e2acb --- /dev/null +++ b/.agents/skills/tsdown/references/option-exe.md @@ -0,0 +1,120 @@ +# Executable - `exe` + +**[experimental]** Bundle as a standalone executable using [Node.js Single Executable Applications](https://nodejs.org/api/single-executable-applications.html). + +## Requirements + +- Node.js >= 25.5.0 (ESM support requires >= 25.7.0) +- Not supported in Bun or Deno + +## Basic Usage + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + exe: true, +}) +``` + +## Behavior When Enabled + +- Default output format changes from `esm` to `cjs` (unless Node.js >= 25.7.0) +- Declaration file generation (`dts`) is disabled by default +- Code splitting is disabled +- Only single entry points are supported +- Legacy CJS warnings are suppressed + +## Advanced Configuration + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + exe: { + fileName: 'my-tool', + seaConfig: { + disableExperimentalSEAWarning: true, + useCodeCache: true, + useSnapshot: false, + }, + }, +}) +``` + +## `ExeOptions` + +| Option | Type | Description | +|--------|------|-------------| +| `seaConfig` | `Omit` | Node.js configuration options | +| `fileName` | `string \| ((chunk) => string)` | Custom output file name (without `.exe` or platform suffixes) | +| `targets` | `ExeTarget[]` | Cross-platform build targets (requires `@tsdown/exe`) | + +## `SeaConfig` + +See [Node.js Single Executable Applications documentation](https://nodejs.org/api/single-executable-applications.html). + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `disableExperimentalSEAWarning` | `boolean` | `true` | Disable the experimental warning | +| `useSnapshot` | `boolean` | `false` | Use V8 snapshot | +| `useCodeCache` | `boolean` | `false` | Use V8 code cache | +| `execArgv` | `string[]` | - | Extra Node.js arguments | +| `execArgvExtension` | `'none' \| 'env' \| 'cli'` | `'env'` | How to extend execArgv | +| `assets` | `Record` | - | Assets to embed | + +## Cross-Platform Builds + +Install `@tsdown/exe` to build executables for multiple platforms from a single machine: + +```bash +pnpm add -D @tsdown/exe +``` + +```ts +export default defineConfig({ + entry: ['src/cli.ts'], + exe: { + targets: [ + { platform: 'linux', arch: 'x64', nodeVersion: '25.7.0' }, + { platform: 'darwin', arch: 'arm64', nodeVersion: '25.7.0' }, + { platform: 'win', arch: 'x64', nodeVersion: '25.7.0' }, + ], + }, +}) +``` + +This downloads the target platform's Node.js binary, caches it locally, and produces platform-suffixed output: + +``` +dist/ + cli-linux-x64 + cli-darwin-arm64 + cli-win-x64.exe +``` + +### `ExeTarget` + +| Field | Type | Description | +|-------|------|-------------| +| `platform` | `'win' \| 'darwin' \| 'linux'` | Target OS (nodejs.org naming) | +| `arch` | `'x64' \| 'arm64'` | Target CPU architecture | +| `nodeVersion` | `string` | Node.js version (must be `>=25.7.0`) | + +### Caching + +Downloaded Node.js binaries are cached in system cache directories: +- **macOS:** `~/Library/Caches/tsdown/node/` +- **Linux:** `~/.cache/tsdown/node/` +- **Windows:** `%LOCALAPPDATA%/tsdown/Caches/node/` + +## Platform Notes + +- On macOS, the executable is automatically codesigned (ad-hoc) for Gatekeeper compatibility +- On Windows, the `.exe` extension is automatically appended +- When `targets` is specified, `seaConfig.executable` is ignored + +## CLI + +```bash +tsdown --exe +tsdown src/cli.ts --exe +``` diff --git a/.agents/skills/tsdown/references/option-lint.md b/.agents/skills/tsdown/references/option-lint.md new file mode 100644 index 000000000..246855ac8 --- /dev/null +++ b/.agents/skills/tsdown/references/option-lint.md @@ -0,0 +1,127 @@ +# Package Validation (publint & attw) + +Validate your package configuration and type declarations before publishing. + +## Overview + +tsdown integrates with [publint](https://publint.dev/) and [Are the types wrong?](https://arethetypeswrong.github.io/) (attw) to catch common packaging issues. Both are optional dependencies. + +## Installation + +```bash +# publint only +npm install -D publint + +# attw only +npm install -D @arethetypeswrong/core + +# both +npm install -D publint @arethetypeswrong/core +``` + +## publint + +Checks that `package.json` fields (`exports`, `main`, `module`, `types`) match your actual output files. + +### Enable + +```ts +export default defineConfig({ + publint: true, +}) +``` + +### Configuration + +```ts +export default defineConfig({ + publint: { + level: 'error', // 'warning' | 'error' | 'suggestion' + }, +}) +``` + +### CLI + +```bash +tsdown --publint +``` + +## attw (Are the types wrong?) + +Verifies TypeScript declarations are correct across different module resolution strategies (`node10`, `node16`, `bundler`). + +### Enable + +```ts +export default defineConfig({ + attw: true, +}) +``` + +### Configuration + +```ts +export default defineConfig({ + attw: { + profile: 'node16', // 'strict' | 'node16' | 'esm-only' + level: 'error', // 'warn' | 'error' + ignoreRules: ['false-cjs', 'cjs-resolves-to-esm'], + }, +}) +``` + +### Profiles + +| Profile | Description | +|---------|-------------| +| `strict` | Requires all resolutions to pass (default) | +| `node16` | Ignores `node10` resolution failures | +| `esm-only` | Ignores `node10` and `node16-cjs` resolution failures | + +### Ignore Rules + +Suppress specific problem types with `ignoreRules`: + +| Rule | Description | +|------|-------------| +| `no-resolution` | Module could not be resolved | +| `untyped-resolution` | Resolution succeeded but has no types | +| `false-cjs` | Types indicate CJS but implementation is ESM | +| `false-esm` | Types indicate ESM but implementation is CJS | +| `cjs-resolves-to-esm` | CJS resolution points to an ESM module | +| `fallback-condition` | A fallback/wildcard condition was used | +| `cjs-only-exports-default` | CJS module only exports a default | +| `named-exports` | Named exports mismatch between types and implementation | +| `false-export-default` | Types declare a default export that doesn't exist | +| `missing-export-equals` | Types are missing `export =` for CJS | +| `unexpected-module-syntax` | File uses unexpected module syntax | +| `internal-resolution-error` | Internal resolution error in type checking | + +### CLI + +```bash +tsdown --attw +``` + +## CI Integration + +Both tools support CI-aware options: + +```ts +export default defineConfig({ + publint: 'ci-only', + attw: { + enabled: 'ci-only', + profile: 'node16', + level: 'error', + }, +}) +``` + +Both tools require a `package.json` in your project directory. + +## Related Options + +- [CI Environment](advanced-ci.md) - CI-aware option details +- [Package Exports](option-package-exports.md) - Generate exports field diff --git a/.agents/skills/tsdown/references/option-log-level.md b/.agents/skills/tsdown/references/option-log-level.md new file mode 100644 index 000000000..9b5706d4d --- /dev/null +++ b/.agents/skills/tsdown/references/option-log-level.md @@ -0,0 +1,91 @@ +# Log Level + +Control the verbosity of build output. + +## Overview + +The `logLevel` option controls how much information tsdown displays during the build process. + +## Type + +```ts +logLevel?: 'silent' | 'error' | 'warn' | 'info' +``` + +**Default:** `'info'` + +## Basic Usage + +### CLI + +```bash +# Suppress all output +tsdown --log-level silent + +# Only show errors +tsdown --log-level error + +# Show warnings and errors +tsdown --log-level warn + +# Show all info (default) +tsdown --log-level info +``` + +### Config File + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + logLevel: 'error', +}) +``` + +## Available Levels + +| Level | Shows | Use Case | +|-------|-------|----------| +| `silent` | Nothing | CI/CD pipelines, scripting | +| `error` | Errors only | Minimal output | +| `warn` | Warnings + errors | Standard CI/CD | +| `info` | All messages | Development (default) | + +## Common Patterns + +### CI/CD Pipeline + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + logLevel: 'error', // Only show errors in CI +}) +``` + +### Scripting + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + logLevel: 'silent', // No output for automation +}) +``` + +## Fail on Warnings + +The `failOnWarn` option controls whether warnings cause the build to exit with a non-zero code. Defaults to `false` — warnings never fail the build. + +```ts +export default defineConfig({ + failOnWarn: false, // Default: never fail on warnings + // failOnWarn: true, // Always fail on warnings + // failOnWarn: 'ci-only', // Fail on warnings only in CI +}) +``` + +See [CI Environment](advanced-ci.md) for more about CI-aware options. + +## Related Options + +- [CI Environment](advanced-ci.md) - CI-aware option details +- [CLI Reference](reference-cli.md) - All CLI options +- [Config File](option-config-file.md) - Configuration setup diff --git a/.agents/skills/tsdown/references/option-minification.md b/.agents/skills/tsdown/references/option-minification.md new file mode 100644 index 000000000..ac0dfaa4b --- /dev/null +++ b/.agents/skills/tsdown/references/option-minification.md @@ -0,0 +1,177 @@ +# Minification + +Compress code to reduce bundle size. + +## Overview + +Minification removes unnecessary characters (whitespace, comments) and optimizes code for production, reducing bundle size and improving load times. + +**Note:** Uses [Oxc minifier](https://oxc.rs/docs/contribute/minifier) internally. The minifier is currently in alpha. + +## Type + +```ts +minify?: boolean | 'dce-only' | MinifyOptions +``` + +- `true` — Enable full minification (whitespace removal, mangling, compression) +- `false` — Disable minification (default) +- `'dce-only'` — Only perform dead code elimination without full minification +- `MinifyOptions` — Pass detailed options to the Oxc minifier + +## Basic Usage + +### CLI + +```bash +# Enable minification +tsdown --minify + +# Disable minification +tsdown --no-minify +``` + +**Note:** The CLI `--minify` flag is a boolean toggle. For `'dce-only'` mode or advanced options, use the config file. + +### Config File + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + minify: true, +}) +``` + +### DCE-Only Mode + +Remove dead code without full minification (keeps readable output): + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + minify: 'dce-only', +}) +``` + +## Example Output + +### Without Minification + +```js +// dist/index.mjs +const x = 1 + +function hello(x$1) { + console.log('Hello World') + console.log(x$1) +} + +hello(x) +``` + +### With Minification + +```js +// dist/index.mjs +const e=1;function t(e){console.log(`Hello World`),console.log(e)}t(e); +``` + +## Common Patterns + +### Production Build + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: true, + clean: true, +}) +``` + +### Conditional Minification + +```ts +export default defineConfig((options) => ({ + entry: ['src/index.ts'], + format: ['esm'], + minify: !options.watch, // Only minify in production +})) +``` + +### Browser Library + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['iife'], + platform: 'browser', + globalName: 'MyLib', + minify: true, +}) +``` + +### Multiple Builds + +```ts +export default defineConfig([ + // Development build + { + entry: ['src/index.ts'], + format: ['esm'], + minify: false, + outDir: 'dist/dev', + }, + // Production build + { + entry: ['src/index.ts'], + format: ['esm'], + minify: true, + outDir: 'dist/prod', + }, +]) +``` + +## CLI Examples + +```bash +# Production build with minification +tsdown --minify --clean + +# Multiple formats with minification +tsdown --format esm --format cjs --minify + +# Conditional minification (only when not watching) +tsdown --minify # Or omit --watch +``` + +## Tips + +1. **Use `minify: true`** for production builds +2. **Use `'dce-only'`** to remove dead code while keeping output readable +3. **Skip minification** during development for faster rebuilds +4. **Combine with tree shaking** for best results +5. **Test minified output** thoroughly (Oxc minifier is in alpha) + +## Troubleshooting + +### Minified Code Has Bugs + +Oxc minifier is in alpha and may have issues: + +1. **Use DCE-only mode**: `minify: 'dce-only'` +2. **Report bug** to [Oxc project](https://github.com/oxc-project/oxc/issues) +3. **Disable minification**: `minify: false` + +### Unexpected Output + +- **Test unminified** first to isolate issue +- **Check source maps** for debugging +- **Verify target compatibility** + +## Related Options + +- [Tree Shaking](option-tree-shaking.md) - Remove unused code +- [Target](option-target.md) - Syntax transformations +- [Output Format](option-output-format.md) - Module formats +- [Sourcemap](option-sourcemap.md) - Debug information diff --git a/.agents/skills/tsdown/references/option-output-directory.md b/.agents/skills/tsdown/references/option-output-directory.md new file mode 100644 index 000000000..ff09cfea5 --- /dev/null +++ b/.agents/skills/tsdown/references/option-output-directory.md @@ -0,0 +1,272 @@ +# Output Directory + +Configure the output directory for bundled files. + +## Overview + +By default, tsdown outputs bundled files to the `dist` directory. You can customize this location using the `outDir` option. + +## Basic Usage + +### CLI + +```bash +# Default output to dist/ +tsdown + +# Custom output directory +tsdown --out-dir build +tsdown -d lib +``` + +### Config File + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'build', +}) +``` + +## Common Patterns + +### Standard Library + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist', // Default + dts: true, +}) +``` + +**Output:** +``` +dist/ +├── index.mjs +├── index.cjs +└── index.d.ts +``` + +### Separate Directories by Format + +```ts +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist/esm', + }, + { + entry: ['src/index.ts'], + format: ['cjs'], + outDir: 'dist/cjs', + }, +]) +``` + +**Output:** +``` +dist/ +├── esm/ +│ └── index.js +└── cjs/ + └── index.js +``` + +### Monorepo Package + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'lib', // Custom directory + clean: true, +}) +``` + +### Build to Root + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + outDir: '.', // Output to project root (not recommended) + clean: false, // Don't clean root! +}) +``` + +**Warning:** Be careful when outputting to root to avoid deleting important files. + +## Output Extensions + +### Custom Extensions + +Use `outExtensions` to control file extensions: + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist', + outExtensions({ format }) { + return { + js: format === 'esm' ? '.mjs' : '.cjs', + } + }, +}) +``` + +### Default Extensions + +| Format | Default Extension | With `type: "module"` | +|--------|-------------------|----------------------| +| `esm` | `.mjs` | `.js` | +| `cjs` | `.cjs` | `.js` | +| `iife` | `.iife.js` | `.iife.js` | +| `umd` | `.umd.js` | `.umd.js` | + +For IIFE/UMD builds, `outExtensions` customizes extensions or suffixes but does not remove the built-in `.iife` or `.umd` segment. Use `outputOptions.entryFileNames` for custom full filename patterns. + +### ESM with .js Extension + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + outExtensions: () => ({ js: '.js' }), +}) +``` + +Requires `"type": "module"` in package.json. + +## File Naming + +### Entry Names + +Control output filenames based on entry names: + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + utils: 'src/utils.ts', + }, + outDir: 'dist', +}) +``` + +**Output:** +``` +dist/ +├── index.mjs +└── utils.mjs +``` + +### Glob Entry + +```ts +export default defineConfig({ + entry: ['src/**/*.ts', '!**/*.test.ts'], + outDir: 'dist', + unbundle: true, // Preserve structure +}) +``` + +**Output:** +``` +dist/ +├── index.mjs +├── utils/ +│ └── helper.mjs +└── components/ + └── button.mjs +``` + +## Multiple Builds + +### Same Output Directory + +```ts +export default defineConfig([ + { + entry: ['src/index.ts'], + outDir: 'dist', + clean: true, // Clean first + }, + { + entry: ['src/cli.ts'], + outDir: 'dist', + clean: false, // Don't clean again + }, +]) +``` + +### Different Output Directories + +```ts +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + outDir: 'dist/lib', + }, + { + entry: ['src/cli.ts'], + format: ['esm'], + outDir: 'dist/bin', + }, +]) +``` + +## CLI Examples + +```bash +# Default +tsdown + +# Custom directory +tsdown --out-dir build +tsdown -d lib + +# Nested directory +tsdown --out-dir dist/lib + +# With other options +tsdown --out-dir build --format esm,cjs --dts +``` + +## Tips + +1. **Use default `dist`** for standard projects +2. **Be careful with root** - avoid `outDir: '.'` +3. **Clean before build** - use `clean: true` +4. **Consistent naming** - match your project conventions +5. **Separate by format** if needed for clarity +6. **Check .gitignore** - ensure output dir is ignored + +## Troubleshooting + +### Files Not in Expected Location + +- Check `outDir` config +- Verify build completed successfully +- Look for typos in path + +### Files Deleted Unexpectedly + +- Check if `clean: true` +- Ensure outDir doesn't overlap with source +- Don't use root as outDir + +### Permission Errors + +- Check write permissions +- Ensure directory isn't locked +- Try different location + +## Related Options + +- [Cleaning](option-cleaning.md) - Clean output directory +- [Entry](option-entry.md) - Entry points +- [Output Format](option-output-format.md) - Module formats +- [Unbundle](option-unbundle.md) - Preserve structure diff --git a/.agents/skills/tsdown/references/option-output-format.md b/.agents/skills/tsdown/references/option-output-format.md new file mode 100644 index 000000000..6f9852e39 --- /dev/null +++ b/.agents/skills/tsdown/references/option-output-format.md @@ -0,0 +1,183 @@ +# Output Format + +Configure the module format(s) for generated bundles. + +## Overview + +tsdown can generate bundles in multiple formats. Default is ESM. + +## Available Formats + +| Format | Description | Use Case | +|--------|-------------|----------| +| `esm` | ECMAScript Module (default) | Modern Node.js, browsers, Deno | +| `cjs` | CommonJS | Legacy Node.js, require() | +| `iife` | Immediately Invoked Function Expression | Browser ` + + + + +``` + +### Export Components + +```ts +// src/index.ts +export { default as Button } from './Button.vue' +export { default as Input } from './Input.vue' +export { default as Modal } from './Modal.vue' + +// Re-export types +export type { ButtonProps } from './Button.vue' +``` + +## Common Patterns + +### Component Library + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + platform: 'neutral', + deps: { + neverBundle: ['vue'], + }, + plugins: [ + Vue({ + isProduction: true, + style: { + trim: true, + }, + }), + ], + dts: { + vue: true, + }, + clean: true, +}) +``` + +### Multiple Components + +```ts +export default defineConfig({ + entry: { + index: 'src/index.ts', + Button: 'src/Button.vue', + Input: 'src/Input.vue', + Modal: 'src/Modal.vue', + }, + format: ['esm', 'cjs'], + deps: { + neverBundle: ['vue'], + }, + plugins: [Vue({ isProduction: true })], + dts: { vue: true }, +}) +``` + +### With Composition Utilities + +```ts +// src/composables/useCounter.ts +import { ref } from 'vue' + +export function useCounter(initial = 0) { + const count = ref(initial) + const increment = () => count.value++ + const decrement = () => count.value-- + return { count, increment, decrement } +} +``` + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + neverBundle: ['vue'], + }, + plugins: [Vue({ isProduction: true })], + dts: { vue: true }, +}) +``` + +### TypeScript Configuration + +```json +// tsconfig.json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "isolatedDeclarations": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +### Package.json Configuration + +```json +{ + "name": "my-vue-library", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + }, + "files": ["dist"], + "peerDependencies": { + "vue": "^3.0.0" + }, + "devDependencies": { + "tsdown": "^0.9.0", + "typescript": "^5.0.0", + "unplugin-vue": "^5.0.0", + "vue": "^3.4.0", + "vue-tsc": "^2.0.0" + } +} +``` + +## Advanced Patterns + +### With Vite Plugins + +Some Vite Vue plugins may work: + +```ts +import Vue from 'unplugin-vue/rolldown' +import Components from 'unplugin-vue-components/rolldown' + +export default defineConfig({ + entry: ['src/index.ts'], + deps: { + neverBundle: ['vue'], + }, + plugins: [ + Vue({ isProduction: true }), + Components({ + dts: 'src/components.d.ts', + }), + ], + dts: { vue: true }, +}) +``` + +### JSX Support + +```ts +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + neverBundle: ['vue'], + }, + plugins: [ + Vue({ + isProduction: true, + script: { + propsDestructure: true, + }, + }), + ], + inputOptions: { + transform: { + jsx: 'automatic', + jsxImportSource: 'vue', + }, + }, + dts: { vue: true }, +}) +``` + +### Monorepo Vue Packages + +```ts +export default defineConfig({ + workspace: 'packages/*', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + deps: { + neverBundle: ['vue', /^@mycompany\//], + }, + plugins: [Vue({ isProduction: true })], + dts: { vue: true }, +}) +``` + +## Plugin Options + +### unplugin-vue Options + +```ts +Vue({ + isProduction: true, + script: { + defineModel: true, + propsDestructure: true, + }, + style: { + trim: true, + }, + template: { + compilerOptions: { + isCustomElement: (tag) => tag.startsWith('custom-'), + }, + }, +}) +``` + +## Tips + +1. **Always externalize Vue** - Don't bundle Vue itself +2. **Enable vue: true in dts** - For proper type generation +3. **Use platform: 'neutral'** - Maximum compatibility +4. **Install vue-tsc** - Required for type generation +5. **Set isProduction: true** - Optimize for production +6. **Add peer dependency** - Vue as peer dependency + +## Troubleshooting + +### Type Generation Fails + +Ensure vue-tsc is installed: +```bash +pnpm add -D vue-tsc +``` + +Enable in config: +```ts +dts: { vue: true } +``` + +### Component Types Missing + +Check TypeScript config: +```json +{ + "compilerOptions": { + "jsx": "preserve", + "moduleResolution": "bundler" + } +} +``` + +### Vue Not Externalized + +Add to deps.neverBundle: +```ts +deps: { + neverBundle: ['vue'], +} +``` + +### SFC Compilation Errors + +Check unplugin-vue version: +```bash +pnpm add -D unplugin-vue@latest +``` + +## Related + +- [Plugins](advanced-plugins.md) - Plugin system +- [Dependencies](option-dependencies.md) - External packages +- [DTS](option-dts.md) - Type declarations +- [React Recipe](recipe-react.md) - React component libraries diff --git a/.agents/skills/tsdown/references/recipe-wasm.md b/.agents/skills/tsdown/references/recipe-wasm.md new file mode 100644 index 000000000..2158ed1c4 --- /dev/null +++ b/.agents/skills/tsdown/references/recipe-wasm.md @@ -0,0 +1,123 @@ +# WASM Support + +Bundle WebAssembly modules in your TypeScript/JavaScript project. + +## Overview + +tsdown supports WASM through [`rolldown-plugin-wasm`](https://github.com/sxzz/rolldown-plugin-wasm), enabling direct `.wasm` imports with synchronous and asynchronous instantiation. + +## Setup + +### Install + +```bash +pnpm add -D rolldown-plugin-wasm +``` + +### Configure + +```ts +import { wasm } from 'rolldown-plugin-wasm' +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['./src/index.ts'], + plugins: [wasm()], +}) +``` + +### TypeScript Support + +Add type declarations to `tsconfig.json`: + +```jsonc +{ + "compilerOptions": { + "types": ["rolldown-plugin-wasm/types"] + } +} +``` + +## Importing WASM Modules + +### Direct Import + +```ts +import { add } from './add.wasm' +add(1, 2) +``` + +### Async Init + +Use `?init` query for async initialization: + +```ts +import init from './add.wasm?init' +const instance = await init(imports) // imports optional +instance.exports.add(1, 2) +``` + +### Sync Init + +Use `?init&sync` query for synchronous initialization: + +```ts +import initSync from './add.wasm?init&sync' +const instance = initSync(imports) // imports optional +instance.exports.add(1, 2) +``` + +## wasm-bindgen Support + +### Target `bundler` (Recommended) + +```ts +import { add } from 'some-pkg' +add(1, 2) +``` + +### Target `web` (Node.js) + +```ts +import { readFile } from 'node:fs/promises' +import init, { add } from 'some-pkg' +import wasmUrl from 'some-pkg/add_bg.wasm?url' + +await init({ + module_or_path: readFile(new URL(wasmUrl, import.meta.url)), +}) +add(1, 2) +``` + +### Target `web` (Browser) + +```ts +import init, { add } from 'some-pkg/add.js' +import wasmUrl from 'some-pkg/add_bg.wasm?url' + +await init({ module_or_path: wasmUrl }) +add(1, 2) +``` + +`nodejs` and `no-modules` wasm-bindgen targets are not supported. + +## Plugin Options + +```ts +wasm({ + maxFileSize: 14 * 1024, // Max size for inline (default: 14KB) + fileName: '[hash][extname]', // Output file name pattern + targetEnv: 'auto', // 'auto' | 'auto-inline' | 'browser' | 'node' +}) +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `maxFileSize` | `14 * 1024` | Max file size for inlining. Set to `0` to always copy. | +| `fileName` | `'[hash][extname]'` | Pattern for emitted WASM files | +| `targetEnv` | `'auto'` | `'auto'` detects at runtime; `'browser'` omits Node builtins; `'node'` omits fetch | + +## Related Options + +- [Plugins](advanced-plugins.md) - Plugin system overview +- [Platform](option-platform.md) - Target platform configuration diff --git a/.agents/skills/tsdown/references/reference-cli.md b/.agents/skills/tsdown/references/reference-cli.md new file mode 100644 index 000000000..fa4fa5718 --- /dev/null +++ b/.agents/skills/tsdown/references/reference-cli.md @@ -0,0 +1,472 @@ +# CLI Reference + +Complete reference for tsdown command-line interface. + +## Overview + +All CLI flags can also be set in the config file. CLI flags override config file options. + +## Flag Patterns + +CLI flag mapping rules: +- `--foo` sets `foo: true` +- `--no-foo` sets `foo: false` +- `--foo.bar` sets `foo: { bar: true }` +- `--format esm --format cjs` sets `format: ['esm', 'cjs']` + +CLI flags support both camelCase and kebab-case. For example, `--outDir` and `--out-dir` are equivalent. + +## Basic Commands + +### Build + +```bash +# Build with default config +tsdown + +# Build specific files +tsdown src/index.ts src/cli.ts + +# Build with watch mode +tsdown --watch +``` + +## Configuration + +### `--config, -c ` + +Specify custom config file: + +```bash +tsdown --config build.config.ts +tsdown -c custom-config.js +``` + +### `--no-config` + +Disable config file loading: + +```bash +tsdown --no-config src/index.ts +``` + +### `--config-loader ` + +Choose config loader (`auto`, `native`, `unrun`): + +```bash +tsdown --config-loader unrun +``` + +### `--tsconfig ` + +Specify TypeScript config file: + +```bash +tsdown --tsconfig tsconfig.build.json +``` + +## Entry Points + +### `[...files]` + +Specify entry files as arguments: + +```bash +tsdown src/index.ts src/utils.ts +``` + +## Output Options + +### `--format ` + +Output format (`esm`, `cjs`, `iife`, `umd`): + +```bash +tsdown --format esm +tsdown --format esm --format cjs +``` + +### `--out-dir, -d ` + +Output directory: + +```bash +tsdown --out-dir lib +tsdown -d dist +``` + +### `--dts` + +Generate TypeScript declarations: + +```bash +tsdown --dts +``` + +### `--clean` + +Clean output directory before build: + +```bash +tsdown --clean +``` + +## Build Options + +### `--target ` + +JavaScript target version: + +```bash +tsdown --target es2020 +tsdown --target node18 +tsdown --target chrome100 +tsdown --no-target # Disable transformations +``` + +### `--platform ` + +Target platform (`node`, `browser`, `neutral`): + +```bash +tsdown --platform node +tsdown --platform browser +``` + +### `--minify` + +Enable minification: + +```bash +tsdown --minify +tsdown --no-minify +``` + +### `--sourcemap` + +Generate source maps: + +```bash +tsdown --sourcemap +tsdown --sourcemap inline +``` + +### `--treeshake` + +Enable/disable tree shaking: + +```bash +tsdown --treeshake +tsdown --no-treeshake +``` + +## Dependencies + +### `--deps.never-bundle ` + +Mark module as external (not bundled): + +```bash +tsdown --deps.never-bundle react --deps.never-bundle react-dom +``` + +### `--deps.skip-node-modules-bundle` + +Skip resolving and bundling all node_modules: + +```bash +tsdown --deps.skip-node-modules-bundle +``` + +### `--shims` + +Add ESM/CJS compatibility shims: + +```bash +tsdown --shims +``` + +## Development + +### `--watch, -w [path]` + +Enable watch mode: + +```bash +tsdown --watch +tsdown -w +tsdown --watch src # Watch specific directory +``` + +### `--ignore-watch ` + +Ignore paths in watch mode: + +```bash +tsdown --watch --ignore-watch test +``` + +### `--on-success ` + +Run command after successful build: + +```bash +tsdown --watch --on-success "echo Build complete!" +``` + +## Environment Variables + +### `--env.* ` + +Set compile-time environment variables: + +```bash +tsdown --env.NODE_ENV=production --env.API_URL=https://api.example.com +``` + +Access as `import.meta.env.*` or `process.env.*`. + +### `--env-file ` + +Load environment variables from file: + +```bash +tsdown --env-file .env.production +``` + +### `--env-prefix ` + +Filter environment variables by prefix (default: `TSDOWN_`): + +```bash +tsdown --env-file .env --env-prefix APP_ --env-prefix TSDOWN_ +``` + +## Assets + +### `--copy ` + +Copy directory to output: + +```bash +tsdown --copy public +tsdown --copy assets --copy static +``` + +## Executable + +### `--exe` + +**[experimental]** Bundle as a standalone executable using [Node.js Single Executable Applications](https://nodejs.org/api/single-executable-applications.html). Requires Node.js >= 25.5.0, not supported in Bun or Deno. Cross-platform builds supported via `@tsdown/exe`. + +```bash +tsdown --exe +``` + +When enabled: +- Default format changes to `cjs` (unless Node.js >= 25.7.0) +- Declaration file generation (`dts`) is disabled by default +- Code splitting is disabled +- Only single entry points are supported + +See [Executable](option-exe.md) for advanced configuration and cross-platform builds. + +## Package Management + +### `--exports` + +Generate the `exports` field in package.json: + +```bash +tsdown --exports +``` + +### `--publint` + +Enable package validation: + +```bash +tsdown --publint +``` + +### `--attw` + +Enable "Are the types wrong" validation: + +```bash +tsdown --attw +``` + +### `--unused` + +Check for unused dependencies: + +```bash +tsdown --unused +``` + +## Logging + +### `--log-level ` + +Set logging verbosity (`silent`, `error`, `warn`, `info`): + +```bash +tsdown --log-level error +tsdown --log-level warn +``` + +### `--report` / `--no-report` + +Enable/disable build report: + +```bash +tsdown --no-report # Disable size report +tsdown --report # Enable (default) +``` + +### `--debug [feat]` + +Show debug logs: + +```bash +tsdown --debug +tsdown --debug rolldown # Debug specific feature +``` + +## Integration + +### `--from-vite [vitest]` + +Extend Vite or Vitest config: + +```bash +tsdown --from-vite # Use vite.config.* +tsdown --from-vite vitest # Use vitest.config.* +``` + +## Workspace / Monorepo + +### `--workspace, -W [dir]` + +Enable workspace mode for building multiple packages: + +```bash +tsdown -W +tsdown -W packages/ +``` + +### `--filter, -F ` + +Filter configs by name or working directory. Supports regex: + +```bash +tsdown -W -F my-package +tsdown -W -F /^pkg-/ +``` + +### `--unbundle` + +Enable unbundle (bundleless) mode: + +```bash +tsdown --unbundle +``` + +### `--root ` + +Specify the root directory of input files (similar to TypeScript's `rootDir`). Controls the output directory structure by determining how entry file paths map to output paths. Defaults to the common base directory of all entry files. + +```bash +tsdown --root src +tsdown --root . +``` + +### `--fail-on-warn` + +Fail on warnings (enabled by default): + +```bash +tsdown --no-fail-on-warn # Disable +``` + +## Common Usage Patterns + +### Basic Build + +```bash +tsdown +``` + +### Library (ESM + CJS + Types) + +```bash +tsdown --format esm --format cjs --dts --clean +``` + +### Production Build + +```bash +tsdown --minify --clean --no-report +``` + +### Development (Watch) + +```bash +tsdown --watch --sourcemap +``` + +### Browser Bundle (IIFE) + +```bash +tsdown --format iife --platform browser --minify +``` + +### Node.js CLI Tool + +```bash +tsdown --format esm --platform node --shims +``` + +### Standalone Executable + +```bash +tsdown src/cli.ts --exe +``` + +### Monorepo Package + +```bash +tsdown --clean --dts --exports --publint +``` + +### With Environment Variables + +```bash +tsdown --env-file .env.production --env.BUILD_TIME=$(date +%s) +``` + +### Copy Assets + +```bash +tsdown --copy public --copy assets --clean +``` + +## Tips + +1. **Use config file** for complex setups +2. **CLI flags override** config file options +3. **Chain multiple formats** for multi-target builds +4. **Use --clean** to avoid stale files +5. **Enable --dts** for TypeScript libraries +6. **Use --watch** during development +7. **Add --on-success** for post-build tasks +8. **Use --exports** to auto-generate package.json fields + +## Related Documentation + +- [Config File](option-config-file.md) - Configuration file options +- [Entry](option-entry.md) - Entry point configuration +- [Output Format](option-output-format.md) - Format options +- [Watch Mode](option-watch-mode.md) - Watch mode details diff --git a/.agents/skills/vue-testing-best-practices/LICENSE.md b/.agents/skills/vue-testing-best-practices/LICENSE.md new file mode 100644 index 000000000..3f08a54d0 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 hyf0, SerKo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/vue-testing-best-practices/SKILL.md b/.agents/skills/vue-testing-best-practices/SKILL.md new file mode 100644 index 000000000..c600b5292 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/SKILL.md @@ -0,0 +1,29 @@ +--- +name: vue-testing-best-practices +version: 1.0.0 +license: MIT +author: github.com/vuejs-ai +description: Use for Vue.js testing. Covers Vitest, Vue Test Utils, component testing, mocking, testing patterns, and Playwright for E2E testing. +--- + +Vue.js testing best practices, patterns, and common gotchas. + +### Testing +- Setting up test infrastructure for Vue 3 projects → See [testing-vitest-recommended-for-vue](reference/testing-vitest-recommended-for-vue.md) +- Tests keep breaking when refactoring component internals → See [testing-component-blackbox-approach](reference/testing-component-blackbox-approach.md) +- Tests fail intermittently with race conditions → See [testing-async-await-flushpromises](reference/testing-async-await-flushpromises.md) +- Composables using lifecycle hooks or inject fail to test → See [testing-composables-helper-wrapper](reference/testing-composables-helper-wrapper.md) +- Getting "injection Symbol(pinia) not found" errors in tests → See [testing-pinia-store-setup](reference/testing-pinia-store-setup.md) +- Components with async setup won't render in tests → See [testing-suspense-async-components](reference/testing-suspense-async-components.md) +- Snapshot tests keep passing despite broken functionality → See [testing-no-snapshot-only](reference/testing-no-snapshot-only.md) +- Choosing end-to-end testing framework for Vue apps → See [testing-e2e-playwright-recommended](reference/testing-e2e-playwright-recommended.md) +- Tests need to verify computed styles or real DOM events → See [testing-browser-vs-node-runners](reference/testing-browser-vs-node-runners.md) +- Testing components created with defineAsyncComponent fails → See [async-component-testing](reference/async-component-testing.md) +- Teleported modal content can't be found in wrapper queries → See [teleport-testing-complexity](reference/teleport-testing-complexity.md) + +## Reference + +- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing) +- [Vue Test Utils](https://test-utils.vuejs.org/) +- [Vitest Documentation](https://vitest.dev/) +- [Playwright Documentation](https://playwright.dev/) diff --git a/.agents/skills/vue-testing-best-practices/SYNC.md b/.agents/skills/vue-testing-best-practices/SYNC.md new file mode 100644 index 000000000..6e968f262 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/SYNC.md @@ -0,0 +1,5 @@ +# Sync Info + +- **Source:** `vendor/vuejs-ai/skills/vue-testing-best-practices` +- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a` +- **Synced:** 2026-03-16 diff --git a/.agents/skills/vue-testing-best-practices/reference/async-component-testing.md b/.agents/skills/vue-testing-best-practices/reference/async-component-testing.md new file mode 100644 index 000000000..a4d2c49dc --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/async-component-testing.md @@ -0,0 +1,163 @@ +--- +title: Use flushPromises for Testing Async Components +impact: HIGH +impactDescription: Without awaiting async operations, tests make assertions before the component has rendered, causing false negatives +type: gotcha +tags: [vue3, testing, async, defineAsyncComponent, flushPromises, vitest] +--- + +# Use flushPromises for Testing Async Components + +**Impact: HIGH** - When testing async components created with `defineAsyncComponent`, you must use `await flushPromises()` to ensure the component has loaded before making assertions. Vue updates asynchronously, so tests that don't account for this will make assertions before the component has rendered. + +## Task Checklist + +- [ ] Use `async/await` in test functions for async components +- [ ] Call `await flushPromises()` after mounting async components +- [ ] Test loading states by making assertions before `flushPromises()` +- [ ] Test error states using rejected promises in `defineAsyncComponent` +- [ ] Use `trigger()` with `await` as it returns a Promise + +**Incorrect:** + +```javascript +import { mount } from '@vue/test-utils' +import { defineAsyncComponent } from 'vue' + +const AsyncWidget = defineAsyncComponent(() => + import('./Widget.vue') +) + +test('renders async component', () => { + const wrapper = mount(AsyncWidget) + + // FAILS: Component hasn't loaded yet + expect(wrapper.text()).toContain('Widget Content') +}) +``` + +**Correct:** + +```javascript +import { mount, flushPromises } from '@vue/test-utils' +import { defineAsyncComponent, nextTick } from 'vue' + +const AsyncWidget = defineAsyncComponent(() => + import('./Widget.vue') +) + +test('renders async component', async () => { + const wrapper = mount(AsyncWidget) + + // Wait for async component to load + await flushPromises() + + expect(wrapper.text()).toContain('Widget Content') +}) + +test('shows loading state initially', async () => { + const AsyncWithLoading = defineAsyncComponent({ + loader: () => import('./Widget.vue'), + loadingComponent: { template: '
Loading...
' }, + delay: 0 + }) + + const wrapper = mount(AsyncWithLoading) + + // Check loading state immediately + expect(wrapper.text()).toContain('Loading...') + + // Wait for component to load + await flushPromises() + + // Check final state + expect(wrapper.text()).toContain('Widget Content') +}) +``` + +## Testing with Suspense + +```javascript +import { mount, flushPromises } from '@vue/test-utils' +import { Suspense, defineAsyncComponent, h } from 'vue' + +const AsyncWidget = defineAsyncComponent(() => + import('./Widget.vue') +) + +test('renders async component with Suspense', async () => { + const wrapper = mount({ + components: { AsyncWidget }, + template: ` + + + + + ` + }) + + // Initially shows fallback + expect(wrapper.text()).toContain('Loading...') + + // Wait for async resolution + await flushPromises() + + // Now shows actual content + expect(wrapper.text()).toContain('Widget Content') +}) +``` + +## Testing Error States + +```javascript +import { mount, flushPromises } from '@vue/test-utils' +import { defineAsyncComponent } from 'vue' + +test('shows error component on load failure', async () => { + const AsyncWithError = defineAsyncComponent({ + loader: () => Promise.reject(new Error('Failed to load')), + errorComponent: { template: '
Error loading component
' } + }) + + const wrapper = mount(AsyncWithError) + + await flushPromises() + + expect(wrapper.text()).toContain('Error loading component') +}) +``` + +## Utilities Reference + +| Utility | Purpose | +|---------|---------| +| `await flushPromises()` | Resolves all pending promises | +| `await nextTick()` | Waits for Vue's next DOM update cycle | +| `await wrapper.trigger('click')` | Triggers event and waits for update | + +## Dynamic Import Handling + +**Note:** Dynamic imports (`import('./File.vue')`) may require additional handling beyond `flushPromises()` in test environments. Test runners like Vitest handle module resolution differently than runtime bundlers, which can cause timing issues with dynamic imports. If `flushPromises()` alone doesn't resolve the component, consider: + +- Mocking the dynamic import to return the component synchronously +- Using multiple `await flushPromises()` calls in sequence +- Wrapping assertions in `waitFor()` or retry utilities +- Configuring your test runner's module resolution settings + +```javascript +// If flushPromises() isn't sufficient, mock the import +vi.mock('./Widget.vue', () => ({ + default: { template: '
Widget Content
' } +})) + +// Or use multiple flush calls for nested async operations +await flushPromises() +await flushPromises() +``` + +## References + +- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense) +- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async) diff --git a/.agents/skills/vue-testing-best-practices/reference/teleport-testing-complexity.md b/.agents/skills/vue-testing-best-practices/reference/teleport-testing-complexity.md new file mode 100644 index 000000000..887836fff --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/teleport-testing-complexity.md @@ -0,0 +1,158 @@ +--- +title: Teleported Content Requires Special Testing Approach +impact: MEDIUM +impactDescription: Vue Test Utils cannot find teleported content using standard wrapper.find() methods +type: gotcha +tags: [vue3, teleport, testing, vue-test-utils] +--- + +# Teleported Content Requires Special Testing Approach + +**Impact: MEDIUM** - Vue Test Utils scopes queries to the mounted component. Teleported content renders outside the component's DOM tree, so `wrapper.find()` cannot locate it. This leads to failing tests and confusion. + +## Task Checklist + +- [ ] Stub Teleport in unit tests to keep content in component tree +- [ ] Use `document.body` queries for integration tests with real Teleport +- [ ] Consider using `getComponent()` instead of DOM queries for teleported components + +**Problem - Standard Testing Fails:** +```vue + + +``` + +```ts +// Modal.spec.ts - BROKEN +import { mount } from '@vue/test-utils' +import Modal from './Modal.vue' + +test('modal input exists', async () => { + const wrapper = mount(Modal) + await wrapper.find('button').trigger('click') + + // FAILS: Teleported content is not in wrapper's DOM tree + expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true) +}) +``` + +**Solution 1 - Stub Teleport:** +```ts +import { mount } from '@vue/test-utils' +import Modal from './Modal.vue' + +test('modal input exists', async () => { + const wrapper = mount(Modal, { + global: { + stubs: { + // Stub teleport to render content inline + Teleport: true + } + } + }) + + await wrapper.find('button').trigger('click') + + // Works: Content renders inside wrapper + expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true) +}) +``` + +**Solution 2 - Query Document Body:** +```ts +import { mount } from '@vue/test-utils' +import Modal from './Modal.vue' + +test('modal renders to body', async () => { + const wrapper = mount(Modal, { + attachTo: document.body // Required for Teleport to work + }) + + await wrapper.find('button').trigger('click') + + // Query the actual DOM + const modal = document.querySelector('[data-testid="modal"]') + expect(modal).toBeTruthy() + + const input = document.querySelector('[data-testid="modal-input"]') + expect(input).toBeTruthy() + + // Cleanup + wrapper.unmount() +}) +``` + +**Solution 3 - Custom Teleport Stub with Content Access:** +```ts +import { mount, config } from '@vue/test-utils' +import { h, Teleport } from 'vue' +import Modal from './Modal.vue' + +// Custom stub that renders content in a testable way +const TeleportStub = { + setup(props, { slots }) { + return () => h('div', { class: 'teleport-stub' }, slots.default?.()) + } +} + +test('modal with custom stub', async () => { + const wrapper = mount(Modal, { + global: { + stubs: { + Teleport: TeleportStub + } + } + }) + + await wrapper.find('button').trigger('click') + + // Content is inside .teleport-stub + expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true) +}) +``` + +## Testing Vue Final Modal and UI Libraries + +Libraries like Vue Final Modal use Teleport internally, causing test failures: + +```ts +// Problem: Vue Final Modal teleports to body +import { VueFinalModal } from 'vue-final-modal' + +test('modal content', async () => { + const wrapper = mount(MyComponent, { + global: { + stubs: { + // Stub the modal component to avoid teleport issues + VueFinalModal: true + } + } + }) +}) +``` + +## E2E Testing (Cypress, Playwright) + +E2E tests query the real DOM, so Teleport works naturally: + +```ts +// Cypress +it('opens modal', () => { + cy.visit('/page-with-modal') + cy.get('button').click() + + // Works: Cypress queries the real DOM + cy.get('[data-testid="modal"]').should('be.visible') +}) +``` + +## Reference +- [Vue Test Utils - Teleport](https://test-utils.vuejs.org/guide/advanced/teleport) +- [Vue Test Utils - Stubs](https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-async-await-flushpromises.md b/.agents/skills/vue-testing-best-practices/reference/testing-async-await-flushpromises.md new file mode 100644 index 000000000..597d9c836 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-async-await-flushpromises.md @@ -0,0 +1,175 @@ +--- +title: Properly Handle Async Updates with nextTick and flushPromises +impact: HIGH +impactDescription: Race conditions and flaky tests occur when async DOM updates or API calls complete after assertions run +type: gotcha +tags: [vue3, testing, async, flushPromises, nextTick, vitest, vue-test-utils, race-condition] +--- + +# Properly Handle Async Updates with nextTick and flushPromises + +**Impact: HIGH** - Vue updates the DOM asynchronously. Without properly awaiting these updates, tests may assert against stale DOM state, causing intermittent failures and false negatives. + +Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, and use `flushPromises` for external async operations like API calls. + +## Task Checklist + +- [ ] Always await `trigger()` and `setValue()` calls +- [ ] Use `await nextTick()` after programmatic reactive state changes +- [ ] Use `await flushPromises()` for external async operations (API calls, timers) +- [ ] Don't chain multiple `nextTick` calls - use `flushPromises` instead +- [ ] Consider using `waitFor` from testing-library for polling assertions + +**Incorrect:** +```javascript +import { mount } from '@vue/test-utils' +import SearchComponent from './SearchComponent.vue' + +// BAD: Not awaiting trigger - assertion runs before DOM updates +test('search filters results', () => { + const wrapper = mount(SearchComponent) + + wrapper.find('input').setValue('vue') // Missing await! + wrapper.find('button').trigger('click') // Missing await! + + // This assertion likely fails - DOM hasn't updated yet + expect(wrapper.findAll('.result').length).toBe(3) +}) + +// BAD: Using nextTick for API calls +test('loads data from API', async () => { + const wrapper = mount(DataLoader) + + await nextTick() // This won't wait for the API call! + + // Assertion runs before fetch completes + expect(wrapper.find('.data').text()).toBe('Loaded data') +}) +``` + +**Correct:** +```javascript +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import SearchComponent from './SearchComponent.vue' +import DataLoader from './DataLoader.vue' + +// CORRECT: Await trigger and setValue +test('search filters results', async () => { + const wrapper = mount(SearchComponent) + + await wrapper.find('input').setValue('vue') + await wrapper.find('button').trigger('click') + + expect(wrapper.findAll('.result').length).toBe(3) +}) + +// CORRECT: Use flushPromises for API calls +test('loads data from API', async () => { + const wrapper = mount(DataLoader) + + // Wait for all pending promises to resolve + await flushPromises() + + expect(wrapper.find('.data').text()).toBe('Loaded data') +}) +``` + +## When to Use Each Method + +### `await trigger()` / `await setValue()` - User Interactions +```javascript +// These methods return nextTick internally +await wrapper.find('button').trigger('click') +await wrapper.find('input').setValue('new value') +await wrapper.find('form').trigger('submit') +``` + +### `await nextTick()` - Programmatic Reactive Updates +```javascript +import { nextTick } from 'vue' + +test('reflects programmatic state changes', async () => { + const wrapper = mount(Counter) + + // Direct state modification (when testing with exposed internals) + wrapper.vm.count = 5 + + await nextTick() // Wait for Vue to update DOM + + expect(wrapper.find('.count').text()).toBe('5') +}) +``` + +### `await flushPromises()` - External Async Operations +```javascript +import { flushPromises } from '@vue/test-utils' + +test('displays fetched data', async () => { + const wrapper = mount(UserProfile, { + props: { userId: 1 } + }) + + // Wait for component's API call to complete + await flushPromises() + + expect(wrapper.find('.username').text()).toBe('John') +}) + +// Sometimes you need multiple flushPromises for chained async operations +test('processes data after fetch', async () => { + const wrapper = mount(DataProcessor) + + await flushPromises() // Wait for fetch + await flushPromises() // Wait for processing triggered by fetch + + expect(wrapper.find('.processed').exists()).toBe(true) +}) +``` + +## Common Pattern: Combining Methods +```javascript +test('submits form and shows success', async () => { + const wrapper = mount(ContactForm) + + // Fill form (awaiting each interaction) + await wrapper.find('#name').setValue('John') + await wrapper.find('#email').setValue('john@example.com') + + // Submit form + await wrapper.find('form').trigger('submit') + + // Wait for API submission to complete + await flushPromises() + + // Assert success state + expect(wrapper.find('.success-message').exists()).toBe(true) +}) +``` + +## Testing with MSW or Mock APIs +```javascript +import { flushPromises } from '@vue/test-utils' +import { rest } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer( + rest.get('/api/user', (req, res, ctx) => { + return res(ctx.json({ name: 'John' })) + }) +) + +test('displays user data', async () => { + const wrapper = mount(UserCard) + + // MSW might require multiple flushPromises + await flushPromises() + await flushPromises() + + expect(wrapper.find('.name').text()).toBe('John') +}) +``` + +## Reference +- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense) +- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-browser-vs-node-runners.md b/.agents/skills/vue-testing-best-practices/reference/testing-browser-vs-node-runners.md new file mode 100644 index 000000000..a112a420f --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-browser-vs-node-runners.md @@ -0,0 +1,208 @@ +--- +title: Choose Browser-Based Runner for Style and DOM Event Testing +impact: MEDIUM +impactDescription: Node-based runners cannot test real CSS behavior, native DOM events, cookies, or computed styles +type: capability +tags: [vue3, testing, component-testing, vitest, browser, jsdom] +--- + +# Choose Browser-Based Runner for Style and DOM Event Testing + +**Impact: MEDIUM** - Node-based test runners (Vitest with jsdom/happy-dom) simulate the DOM but cannot test real CSS rendering, native browser events, cookies, computed styles, or cross-browser behavior. Use browser-based runners when these matter. + +Use Vitest for most component tests (fast), but use Vitest Browser Mode when testing visual/DOM-dependent features. + +## Task Checklist + +- [ ] Use Vitest (node) for logic-focused component tests +- [ ] Use Vitest Browser Mode for style-dependent tests +- [ ] Use Vitest Browser Mode for native events (focus, drag, resize) +- [ ] Use Vitest Browser Mode for cookies and computed CSS styles +- [ ] Accept slower speed tradeoff for browser accuracy + +## When to Use Each Approach + +### Node-Based Runner (Vitest + happy-dom/jsdom) +Best for: +- Pure logic testing +- State management +- Event emission +- Props/slots behavior +- Most component interactions +- Fast CI/CD pipelines + +```javascript +// vitest.config.js +export default defineConfig({ + test: { + environment: 'happy-dom', // or 'jsdom' + } +}) +``` + +```javascript +// Fast but limited - fine for most tests +test('button emits click event', async () => { + const wrapper = mount(Button) + await wrapper.trigger('click') + expect(wrapper.emitted('click')).toBeTruthy() +}) +``` + +### Vitest Browser Mode +Required for: +- CSS computed styles verification +- CSS transitions/animations +- Real focus/blur behavior +- Drag and drop +- Cookie operations +- Viewport-dependent behavior +- Cross-browser validation + +## Vitest Browser Mode Setup + +```bash +npm install -D @vitest/browser playwright +``` + +```javascript +// vitest.config.js +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + }, + }, +}) +``` + +```javascript +// Button.browser.test.js +import { render } from 'vitest-browser-vue' +import Button from './Button.vue' + +test('has correct hover styling', async () => { + const { getByRole } = render(Button, { props: { label: 'Click me' } }) + + const button = getByRole('button') + + // Check initial style + await expect.element(button).toHaveStyle({ + backgroundColor: 'rgb(59, 130, 246)' // blue + }) +}) + +test('maintains focus after click', async () => { + const { getByRole } = render(Button) + + const button = getByRole('button') + await button.click() + + await expect.element(button).toHaveFocus() +}) +``` + +## Examples: What Each Runner Can/Cannot Test + +### Styles - Browser Required +```javascript +// Node runner: CANNOT verify actual CSS +test('danger button has red background', () => { + const wrapper = mount(Button, { props: { variant: 'danger' } }) + // This only checks class exists, not actual color + expect(wrapper.classes()).toContain('bg-red-500') +}) + +// Vitest Browser Mode: CAN verify computed styles +test('danger button renders red', async () => { + const { getByRole } = render(Button, { props: { variant: 'danger' } }) + await expect.element(getByRole('button')).toHaveStyle({ + backgroundColor: 'rgb(239, 68, 68)' + }) +}) +``` + +### Computed CSS Styles - Browser Required +```javascript +// Node runner: CANNOT get real computed styles +test('button has correct padding', () => { + const wrapper = mount(Button) + // getComputedStyle returns empty/default values in jsdom + const style = window.getComputedStyle(wrapper.element) + // style.padding will be empty string, not actual computed value +}) + +// Vitest Browser Mode: Real computed styles +test('button has correct padding', async () => { + const { getByRole } = render(Button) + const button = getByRole('button') + + await expect.element(button).toHaveStyle({ + padding: '12px 24px' + }) +}) +``` + +### Native Events - Browser Required +```javascript +// Node runner: Synthetic events only +test('handles drag and drop', async () => { + const wrapper = mount(DraggableList) + // trigger('dragstart') is synthetic - may not work as expected + await wrapper.find('.item').trigger('dragstart') +}) + +// Vitest Browser Mode: Real native events via userEvent +import { userEvent } from '@vitest/browser/context' + +test('reorders items on drag', async () => { + const { getByTestId } = render(DraggableList) + + const item = getByTestId('item-1') + const target = getByTestId('item-3') + + await userEvent.dragAndDrop(item, target) + + // Assert reordering +}) +``` + +## Recommended Testing Strategy + +```javascript +// vitest.config.js - Separate test configurations + +export default defineConfig({ + test: { + // Default: Node environment for speed + environment: 'happy-dom', + + // Browser tests in separate directory + include: ['src/**/*.test.{js,ts}'], + }, +}) + +// Run browser tests separately +// npx vitest --browser.enabled +``` + +### Directory Structure +``` +tests/ +├── unit/ # Fast node-based tests +│ ├── Button.test.js +│ └── useCounter.test.js +├── component/ # Slower browser-based tests +│ ├── Button.browser.test.js +│ └── DragDrop.browser.test.js +└── e2e/ # Full E2E tests (Playwright) + └── user-flow.spec.ts +``` + +## Reference +- [Vue.js Testing - Component Testing](https://vuejs.org/guide/scaling-up/testing#component-testing) +- [Vitest Browser Mode](https://vitest.dev/guide/browser.html) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-component-blackbox-approach.md b/.agents/skills/vue-testing-best-practices/reference/testing-component-blackbox-approach.md new file mode 100644 index 000000000..e3a869c6b --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-component-blackbox-approach.md @@ -0,0 +1,144 @@ +--- +title: Test Components Using Blackbox Approach - Focus on Behavior Not Implementation +impact: HIGH +impactDescription: Implementation-aware tests become brittle and break during refactoring, leading to high maintenance burden +type: best-practice +tags: [vue3, testing, component-testing, vitest, vue-test-utils, blackbox] +--- + +# Test Components Using Blackbox Approach - Focus on Behavior Not Implementation + +**Impact: HIGH** - Tests that rely on implementation details (internal state, private methods, component structure) break during refactoring even when functionality remains correct. This leads to false negatives and high test maintenance burden. + +Follow Kent C. Dodds' testing philosophy: "The more your tests resemble how your software is used, the more confidence they can give you." + +## Task Checklist + +- [ ] Test what the component does, not how it does it +- [ ] Query elements by user-visible attributes (text, role, testid) +- [ ] Simulate user interactions (click, type) rather than calling methods directly +- [ ] Assert on rendered output, emitted events, and visible state changes +- [ ] Avoid accessing component internal state or private methods +- [ ] Use data-testid attributes for elements without semantic meaning + +**Incorrect:** +```javascript +import { mount } from '@vue/test-utils' +import Counter from './Counter.vue' + +// BAD: Testing implementation details +test('counter increments', async () => { + const wrapper = mount(Counter) + + // Accessing internal state directly + expect(wrapper.vm.count).toBe(0) + + // Calling internal method instead of simulating user action + wrapper.vm.increment() + + // Checking internal state instead of visible output + expect(wrapper.vm.count).toBe(1) +}) + +// BAD: Testing component structure +test('has increment button', () => { + const wrapper = mount(Counter) + + // Testing implementation detail - what if button becomes an anchor? + expect(wrapper.find('button').exists()).toBe(true) +}) +``` + +**Correct:** +```javascript +import { mount } from '@vue/test-utils' +import Counter from './Counter.vue' + +// CORRECT: Testing behavior like a user would +test('counter displays updated value after clicking increment', async () => { + const wrapper = mount(Counter, { + props: { max: 10 } + }) + + // Assert initial visible state + expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0') + + // Simulate user action + await wrapper.find('[data-testid="increment-button"]').trigger('click') + + // Assert visible result + expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('1') +}) + +// CORRECT: Testing emitted events (public API) +test('emits change event with new value when incremented', async () => { + const wrapper = mount(Counter) + + await wrapper.find('[data-testid="increment-button"]').trigger('click') + + expect(wrapper.emitted('change')).toHaveLength(1) + expect(wrapper.emitted('change')[0]).toEqual([1]) +}) +``` + +## Using @testing-library/vue for Better Blackbox Tests + +```javascript +import { render, screen, fireEvent } from '@testing-library/vue' +import Counter from './Counter.vue' + +// Testing Library encourages accessible, user-centric queries +test('increments counter on button click', async () => { + render(Counter) + + // Query by role - how screen readers see it + const button = screen.getByRole('button', { name: /increment/i }) + const display = screen.getByText('0') + + await fireEvent.click(button) + + expect(screen.getByText('1')).toBeInTheDocument() +}) +``` + +## What to Test vs What Not to Test + +### DO Test (Public Interface) +```javascript +// Props affect rendered output +test('shows title from props', () => { + const wrapper = mount(Card, { + props: { title: 'Hello World' } + }) + expect(wrapper.text()).toContain('Hello World') +}) + +// Slots render correctly +test('renders slot content', () => { + const wrapper = mount(Card, { + slots: { default: '

Slot content

' } + }) + expect(wrapper.text()).toContain('Slot content') +}) + +// Emitted events +test('emits close event when X clicked', async () => { + const wrapper = mount(Modal) + await wrapper.find('[data-testid="close-button"]').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() +}) +``` + +### DON'T Test (Implementation Details) +```javascript +// Don't test internal computed properties +// Don't test internal methods +// Don't test component options/setup internals +// Don't test that specific child components are rendered (unless critical) +// Don't rely exclusively on snapshot tests for correctness +``` + +## Reference +- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing) +- [Vue Test Utils - Testing Philosophy](https://test-utils.vuejs.org/guide/) +- [Testing Library Guiding Principles](https://testing-library.com/docs/guiding-principles) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-composables-helper-wrapper.md b/.agents/skills/vue-testing-best-practices/reference/testing-composables-helper-wrapper.md new file mode 100644 index 000000000..f6e4f0798 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-composables-helper-wrapper.md @@ -0,0 +1,238 @@ +--- +title: Test Complex Composables with Host Component Wrapper +impact: MEDIUM +impactDescription: Composables using lifecycle hooks or provide/inject fail when tested directly without a component context +type: capability +tags: [vue3, testing, composables, vitest, lifecycle-hooks, provide-inject] +--- + +# Test Complex Composables with Host Component Wrapper + +**Impact: MEDIUM** - Composables that use Vue lifecycle hooks (`onMounted`, `onUnmounted`) or dependency injection (`inject`) require a component context to function. Testing them directly will cause errors or incorrect behavior. + +Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context. + +## Task Checklist + +- [ ] Identify if composable uses lifecycle hooks or inject +- [ ] For simple composables (refs, computed only): test directly +- [ ] For complex composables: use `withSetup` helper pattern +- [ ] Clean up by unmounting the test app after each test +- [ ] Use `app.provide()` to mock injected dependencies + +**Simple Composable - Test Directly:** +```javascript +// composables/useCounter.js +import { ref, computed } from 'vue' + +export function useCounter(initialValue = 0) { + const count = ref(initialValue) + const doubled = computed(() => count.value * 2) + const increment = () => count.value++ + + return { count, doubled, increment } +} +``` + +```javascript +// useCounter.test.js +import { describe, it, expect } from 'vitest' +import { useCounter } from './useCounter' + +// CORRECT: Simple composable can be tested directly +describe('useCounter', () => { + it('initializes with default value', () => { + const { count } = useCounter() + expect(count.value).toBe(0) + }) + + it('increments count', () => { + const { count, increment } = useCounter() + increment() + expect(count.value).toBe(1) + }) + + it('computes doubled value', () => { + const { count, doubled, increment } = useCounter(5) + expect(doubled.value).toBe(10) + increment() + expect(doubled.value).toBe(12) + }) +}) +``` + +**Complex Composable - Use Host Wrapper:** +```javascript +// composables/useFetch.js +import { ref, onMounted, onUnmounted, inject } from 'vue' + +export function useFetch(url) { + const data = ref(null) + const error = ref(null) + const loading = ref(true) + let controller = null + + // Uses inject - needs component context + const apiClient = inject('apiClient') + + // Uses lifecycle hooks - needs component context + onMounted(async () => { + controller = new AbortController() + try { + const response = await apiClient.get(url, { signal: controller.signal }) + data.value = response.data + } catch (e) { + if (e.name !== 'AbortError') error.value = e + } finally { + loading.value = false + } + }) + + onUnmounted(() => { + controller?.abort() + }) + + return { data, error, loading } +} +``` + +```javascript +// test-utils.js +import { createApp } from 'vue' + +/** + * Helper to test composables that need component context + */ +export function withSetup(composable) { + let result + + const app = createApp({ + setup() { + result = composable() + // Return a render function to suppress warnings + return () => {} + } + }) + + app.mount(document.createElement('div')) + + return [result, app] +} +``` + +```javascript +// useFetch.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { withSetup } from './test-utils' +import { useFetch } from './useFetch' + +describe('useFetch', () => { + let app + const mockApiClient = { + get: vi.fn() + } + + afterEach(() => { + // IMPORTANT: Clean up to trigger onUnmounted + app?.unmount() + }) + + it('fetches data on mount', async () => { + mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } }) + + const [result, testApp] = withSetup(() => useFetch('/api/test')) + app = testApp + + // Provide mocked dependency + app.provide('apiClient', mockApiClient) + + // Wait for async operations + await flushPromises() + + expect(result.data.value).toEqual({ id: 1, name: 'Test' }) + expect(result.loading.value).toBe(false) + expect(result.error.value).toBeNull() + }) + + it('handles errors', async () => { + const testError = new Error('Network error') + mockApiClient.get.mockRejectedValue(testError) + + const [result, testApp] = withSetup(() => useFetch('/api/test')) + app = testApp + app.provide('apiClient', mockApiClient) + + await flushPromises() + + expect(result.error.value).toBe(testError) + expect(result.data.value).toBeNull() + }) +}) +``` + +## Enhanced withSetup Helper with Provide Support +```javascript +// test-utils.js +export function withSetup(composable, options = {}) { + let result + + const app = createApp({ + setup() { + result = composable() + return () => {} + } + }) + + // Apply global provides before mounting + if (options.provide) { + Object.entries(options.provide).forEach(([key, value]) => { + app.provide(key, value) + }) + } + + app.mount(document.createElement('div')) + + return [result, app] +} + +// Usage +const [result, app] = withSetup(() => useMyComposable(), { + provide: { + apiClient: mockApiClient, + currentUser: { id: 1, name: 'Test User' } + } +}) +``` + +## Testing with @vue/test-utils mount +```javascript +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { useFetch } from './useFetch' + +test('useFetch in component context', async () => { + const TestComponent = defineComponent({ + setup() { + const { data, loading } = useFetch('/api/users') + return { data, loading } + }, + template: '
{{ loading ? "Loading..." : data }}
' + }) + + const wrapper = mount(TestComponent, { + global: { + provide: { + apiClient: mockApiClient + } + } + }) + + await flushPromises() + expect(wrapper.text()).toContain('Test data') +}) +``` + +## Reference +- [Vue.js Testing Guide - Testing Composables](https://vuejs.org/guide/scaling-up/testing#testing-composables) +- [Vue Test Utils - Mounting Components](https://test-utils.vuejs.org/guide/) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-e2e-playwright-recommended.md b/.agents/skills/vue-testing-best-practices/reference/testing-e2e-playwright-recommended.md new file mode 100644 index 000000000..3df2ff0f8 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-e2e-playwright-recommended.md @@ -0,0 +1,242 @@ +--- +title: Use Playwright for E2E Testing - Cross-Browser Support and Better DX +impact: MEDIUM +impactDescription: Cypress has browser limitations and some features require paid subscriptions +type: best-practice +tags: [vue3, testing, e2e, playwright, cypress, end-to-end] +--- + +# Use Playwright for E2E Testing - Cross-Browser Support and Better DX + +**Impact: MEDIUM** - Playwright offers superior cross-browser testing (Chromium, WebKit, Firefox), excellent debugging tools, and is fully open source. Cypress has limitations with WebKit support and requires paid subscriptions for some features. + +Use Playwright for new E2E testing setups. Consider Cypress if team already has expertise or for its visual debugging UI. + +## Task Checklist + +- [ ] Install Playwright with browsers for your target platforms +- [ ] Configure for Vue dev server integration +- [ ] Set up projects for different browsers +- [ ] Use locator strategies that match component test patterns +- [ ] Configure CI for parallel test execution +- [ ] Use trace and screenshot features for debugging + +## Quick Setup + +```bash +# Install Playwright +npm init playwright@latest + +# This will create: +# - playwright.config.ts +# - tests/ directory +# - tests-examples/ directory +``` + +**playwright.config.ts:** +```typescript +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + // Base URL for navigation + baseURL: 'http://localhost:5173', + // Capture trace on first retry + trace: 'on-first-retry', + // Screenshot on failure + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + // Mobile viewports + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + + // Run local dev server before tests + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}) +``` + +## E2E Test Example + +```typescript +// e2e/user-flow.spec.ts +import { test, expect } from '@playwright/test' + +test.describe('User Authentication', () => { + test('user can log in and see dashboard', async ({ page }) => { + // Navigate to login + await page.goto('/login') + + // Fill login form + await page.getByLabel('Email').fill('user@example.com') + await page.getByLabel('Password').fill('password123') + await page.getByRole('button', { name: 'Sign In' }).click() + + // Verify redirect to dashboard + await expect(page).toHaveURL('/dashboard') + await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible() + }) + + test('shows error for invalid credentials', async ({ page }) => { + await page.goto('/login') + + await page.getByLabel('Email').fill('wrong@example.com') + await page.getByLabel('Password').fill('wrongpassword') + await page.getByRole('button', { name: 'Sign In' }).click() + + await expect(page.getByRole('alert')).toContainText('Invalid credentials') + await expect(page).toHaveURL('/login') + }) +}) +``` + +## Playwright vs Cypress Comparison + +| Feature | Playwright | Cypress | +|---------|------------|---------| +| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Electron (WebKit experimental) | +| Cross-browser | Full support | Limited | +| Parallelization | Built-in | Requires Cypress Cloud | +| Open source | Fully | Core only | +| Mobile testing | Device emulation | Limited | +| Debugging | Inspector, trace viewer | Time-travel UI | +| API testing | Built-in | Plugin required | +| Iframes | Full support | Limited | + +## Testing Vue Components with Data-Testid + +```typescript +// e2e/product-list.spec.ts +import { test, expect } from '@playwright/test' + +test('user can add product to cart', async ({ page }) => { + await page.goto('/products') + + // Use data-testid for reliable selectors + await page.getByTestId('product-card').first().click() + + // Verify product detail page + await expect(page.getByTestId('product-title')).toBeVisible() + + // Add to cart + await page.getByTestId('add-to-cart-button').click() + + // Verify cart updated + await expect(page.getByTestId('cart-count')).toHaveText('1') +}) +``` + +## Page Object Pattern for Vue Apps + +```typescript +// e2e/pages/LoginPage.ts +import { Page, Locator } from '@playwright/test' + +export class LoginPage { + readonly page: Page + readonly emailInput: Locator + readonly passwordInput: Locator + readonly submitButton: Locator + readonly errorMessage: Locator + + constructor(page: Page) { + this.page = page + this.emailInput = page.getByLabel('Email') + this.passwordInput = page.getByLabel('Password') + this.submitButton = page.getByRole('button', { name: 'Sign In' }) + this.errorMessage = page.getByRole('alert') + } + + async goto() { + await this.page.goto('/login') + } + + async login(email: string, password: string) { + await this.emailInput.fill(email) + await this.passwordInput.fill(password) + await this.submitButton.click() + } +} +``` + +```typescript +// e2e/auth.spec.ts +import { test, expect } from '@playwright/test' +import { LoginPage } from './pages/LoginPage' + +test('successful login', async ({ page }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login('user@example.com', 'password123') + + await expect(page).toHaveURL('/dashboard') +}) +``` + +## Visual Regression Testing + +```typescript +test('homepage visual regression', async ({ page }) => { + await page.goto('/') + + // Full page screenshot comparison + await expect(page).toHaveScreenshot('homepage.png') + + // Element-specific screenshot + await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png') +}) +``` + +## Running Tests + +```bash +# Run all tests +npx playwright test + +# Run in headed mode (see browser) +npx playwright test --headed + +# Run specific file +npx playwright test e2e/auth.spec.ts + +# Run in specific browser +npx playwright test --project=chromium + +# Debug mode +npx playwright test --debug + +# Generate test from actions +npx playwright codegen localhost:5173 +``` + +## Reference +- [Playwright Documentation](https://playwright.dev/) +- [Vue.js E2E Testing Recommendations](https://vuejs.org/guide/scaling-up/testing#e2e-testing) +- [Playwright Best Practices](https://playwright.dev/docs/best-practices) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-no-snapshot-only.md b/.agents/skills/vue-testing-best-practices/reference/testing-no-snapshot-only.md new file mode 100644 index 000000000..e44f43797 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-no-snapshot-only.md @@ -0,0 +1,197 @@ +--- +title: Avoid Snapshot-Only Tests - They Don't Prove Correctness +impact: MEDIUM +impactDescription: Snapshot tests verify structure but not functionality, leading to false confidence and brittle tests +type: best-practice +tags: [vue3, testing, snapshot, vitest, vue-test-utils, anti-pattern] +--- + +# Avoid Snapshot-Only Tests - They Don't Prove Correctness + +**Impact: MEDIUM** - Snapshot tests only verify that HTML structure hasn't changed - they don't verify that the component works correctly. Relying exclusively on snapshots leads to false confidence and tests that break on any refactoring, even when functionality is preserved. + +Use snapshots sparingly for regression detection. Prefer behavioral assertions that test what the component does. + +## Task Checklist + +- [ ] Don't use snapshots as the only assertion for component behavior +- [ ] Use snapshots for regression detection on stable UI components +- [ ] Always pair snapshots with behavioral assertions +- [ ] Keep snapshots small and focused (avoid full component snapshots) +- [ ] Review snapshot diffs carefully - don't blindly update +- [ ] Consider inline snapshots for small, critical structures + +**Incorrect:** +```javascript +import { mount } from '@vue/test-utils' +import UserCard from './UserCard.vue' + +// BAD: Snapshot-only test proves nothing about functionality +test('UserCard renders correctly', () => { + const wrapper = mount(UserCard, { + props: { user: { name: 'John', email: 'john@example.com' } } + }) + + expect(wrapper.html()).toMatchSnapshot() +}) + +// This test passes even if: +// - The email isn't clickable +// - The avatar doesn't load +// - User actions are completely broken +// - Accessibility is broken +``` + +**Correct:** +```javascript +import { mount } from '@vue/test-utils' +import UserCard from './UserCard.vue' + +// CORRECT: Test actual behavior +test('UserCard displays user information', () => { + const wrapper = mount(UserCard, { + props: { user: { name: 'John', email: 'john@example.com' } } + }) + + expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John') + expect(wrapper.find('[data-testid="user-email"]').text()).toBe('john@example.com') +}) + +test('UserCard email link is clickable', async () => { + const wrapper = mount(UserCard, { + props: { user: { name: 'John', email: 'john@example.com' } } + }) + + const emailLink = wrapper.find('a[href^="mailto:"]') + expect(emailLink.exists()).toBe(true) + expect(emailLink.attributes('href')).toBe('mailto:john@example.com') +}) + +test('UserCard emits select event when clicked', async () => { + const wrapper = mount(UserCard, { + props: { user: { id: 1, name: 'John' } } + }) + + await wrapper.trigger('click') + + expect(wrapper.emitted('select')).toBeTruthy() + expect(wrapper.emitted('select')[0]).toEqual([{ id: 1, name: 'John' }]) +}) +``` + +## When Snapshots ARE Useful + +### Regression Detection for Stable Components +```javascript +// ACCEPTABLE: Snapshot as additional check, not the only check +test('ErrorBoundary renders error message', () => { + const wrapper = mount(ErrorBoundary, { + props: { error: new Error('Something went wrong') } + }) + + // Primary assertions - verify behavior + expect(wrapper.find('.error-title').text()).toBe('Error') + expect(wrapper.find('.error-message').text()).toContain('Something went wrong') + + // Secondary snapshot - catches unexpected structural changes + expect(wrapper.find('.error-container').html()).toMatchSnapshot() +}) +``` + +### Inline Snapshots for Small Structures +```javascript +// ACCEPTABLE: Inline snapshot for small, critical structure +test('generates correct list markup', () => { + const wrapper = mount(ListItem, { props: { item: 'Test' } }) + + expect(wrapper.html()).toMatchInlineSnapshot(` + "
  • Test
  • " + `) +}) +``` + +### Complex SVG or Icon Output +```javascript +// ACCEPTABLE: Snapshot for complex generated content +test('renders correct chart SVG', () => { + const wrapper = mount(PieChart, { + props: { data: [30, 40, 30] } + }) + + // Verify key behavior + expect(wrapper.findAll('path').length).toBe(3) + + // Snapshot for full SVG structure + expect(wrapper.find('svg').html()).toMatchSnapshot() +}) +``` + +## Better Alternatives to Snapshots + +### Test Specific Elements +```javascript +// Instead of snapshotting entire component +test('renders product with all required fields', () => { + const wrapper = mount(ProductCard, { + props: { product: { name: 'Widget', price: 9.99, inStock: true } } + }) + + expect(wrapper.find('.product-name').text()).toBe('Widget') + expect(wrapper.find('.product-price').text()).toContain('9.99') + expect(wrapper.find('.in-stock-badge').exists()).toBe(true) +}) +``` + +### Test CSS Classes for Styling +```javascript +test('applies danger styling for errors', () => { + const wrapper = mount(Alert, { + props: { type: 'error', message: 'Failed!' } + }) + + expect(wrapper.classes()).toContain('alert-danger') + expect(wrapper.find('.alert-icon').classes()).toContain('icon-error') +}) +``` + +### Use Testing Library Queries +```javascript +import { render, screen } from '@testing-library/vue' + +test('form has accessible labels', () => { + render(LoginForm) + + // Testing Library queries verify accessibility + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument() +}) +``` + +## Snapshot Anti-Patterns + +```javascript +// ANTI-PATTERN: Giant component snapshot +test('page renders', () => { + const wrapper = mount(EntirePageComponent) + expect(wrapper.html()).toMatchSnapshot() // 500+ lines of HTML +}) + +// ANTI-PATTERN: Snapshot with dynamic content +test('shows current date', () => { + const wrapper = mount(DateDisplay) + expect(wrapper.html()).toMatchSnapshot() // Fails every day! +}) + +// ANTI-PATTERN: Snapshot after every test +test('button works', async () => { + const wrapper = mount(Counter) + await wrapper.find('button').trigger('click') + expect(wrapper.html()).toMatchSnapshot() // Redundant +}) +``` + +## Reference +- [Vue.js Testing Guide - What Not to Test](https://vuejs.org/guide/scaling-up/testing) +- [Effective Snapshot Testing](https://kentcdodds.com/blog/effective-snapshot-testing) +- [Vitest Snapshot Testing](https://vitest.dev/guide/snapshot.html) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-pinia-store-setup.md b/.agents/skills/vue-testing-best-practices/reference/testing-pinia-store-setup.md new file mode 100644 index 000000000..3f8d4400d --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-pinia-store-setup.md @@ -0,0 +1,228 @@ +--- +title: Configure Pinia Testing with createTestingPinia and setActivePinia +impact: HIGH +impactDescription: Missing Pinia configuration causes 'injection Symbol(pinia) not found' errors and failing tests +type: gotcha +tags: [vue3, testing, pinia, vitest, store, mocking, createTestingPinia] +--- + +# Configure Pinia Testing with createTestingPinia and setActivePinia + +**Impact: HIGH** - Testing components or composables that use Pinia stores without proper configuration results in "[Vue warn]: injection Symbol(pinia) not found" errors. Tests will fail or behave unexpectedly. + +Use `@pinia/testing` package with `createTestingPinia` for component tests and `setActivePinia(createPinia())` for unit testing stores directly. + +## Task Checklist + +- [ ] Install `@pinia/testing` as a dev dependency +- [ ] Use `createTestingPinia` in component tests with `global.plugins` +- [ ] Use `setActivePinia(createPinia())` in `beforeEach` for store unit tests +- [ ] Configure `createSpy: vi.fn` when NOT using `globals: true` in Vitest +- [ ] Initialize store inside each test to get fresh state +- [ ] Use `stubActions: false` when you need real action execution + +**Incorrect:** +```javascript +import { mount } from '@vue/test-utils' +import UserProfile from './UserProfile.vue' + +// BAD: Missing Pinia - causes injection error +test('displays user name', () => { + const wrapper = mount(UserProfile) // ERROR: injection "Symbol(pinia)" not found + expect(wrapper.text()).toContain('John') +}) +``` + +```javascript +import { useUserStore } from '@/stores/user' + +// BAD: No active Pinia instance +test('user store actions', () => { + const store = useUserStore() // ERROR: no active Pinia + store.login('john', 'password') +}) +``` + +**Correct - Component Testing:** +```javascript +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { vi } from 'vitest' +import UserProfile from './UserProfile.vue' +import { useUserStore } from '@/stores/user' + +// CORRECT: Provide testing pinia with stubbed actions +test('displays user name', () => { + const wrapper = mount(UserProfile, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, // Required if not using globals: true + initialState: { + user: { name: 'John', email: 'john@example.com' } + } + }) + ] + } + }) + + expect(wrapper.text()).toContain('John') +}) + +// CORRECT: Test with stubbed actions (default behavior) +test('calls logout action', async () => { + const wrapper = mount(UserProfile, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })] + } + }) + + // Get store AFTER mounting with createTestingPinia + const store = useUserStore() + + await wrapper.find('[data-testid="logout"]').trigger('click') + + // Actions are stubbed and wrapped in spies + expect(store.logout).toHaveBeenCalled() +}) +``` + +**Correct - Store Unit Testing:** +```javascript +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useUserStore } from '@/stores/user' + +describe('User Store', () => { + beforeEach(() => { + // Create fresh Pinia instance for each test + setActivePinia(createPinia()) + }) + + it('initializes with empty user', () => { + const store = useUserStore() + expect(store.user).toBeNull() + expect(store.isLoggedIn).toBe(false) + }) + + it('updates user on login', async () => { + const store = useUserStore() + + // Real action executes - not stubbed + await store.login('john', 'password') + + expect(store.user).toEqual({ name: 'John' }) + expect(store.isLoggedIn).toBe(true) + }) + + it('clears user on logout', () => { + const store = useUserStore() + store.user = { name: 'John' } // Set initial state + + store.logout() + + expect(store.user).toBeNull() + }) +}) +``` + +## Testing with Real Actions vs Stubbed Actions + +```javascript +import { createTestingPinia } from '@pinia/testing' + +// Stubbed actions (default) - for isolation +const wrapper = mount(Component, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + // stubActions: true (default) - actions are mocked + }) + ] + } +}) + +// Real actions - for integration testing +const wrapper = mount(Component, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false // Actions execute normally + }) + ] + } +}) +``` + +## Mocking Specific Action Implementations + +```javascript +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { vi } from 'vitest' +import { useCartStore } from '@/stores/cart' + +test('handles checkout failure', async () => { + const wrapper = mount(Checkout, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })] + } + }) + + const cartStore = useCartStore() + + // Mock specific action behavior + cartStore.checkout.mockRejectedValue(new Error('Payment failed')) + + await wrapper.find('[data-testid="checkout"]').trigger('click') + await flushPromises() + + expect(wrapper.find('.error').text()).toContain('Payment failed') +}) +``` + +## Spying on Actions with vi.spyOn + +```javascript +import { setActivePinia, createPinia } from 'pinia' +import { vi } from 'vitest' +import { useUserStore } from '@/stores/user' + +test('tracks action calls', async () => { + setActivePinia(createPinia()) + const store = useUserStore() + + const loginSpy = vi.spyOn(store, 'login') + loginSpy.mockResolvedValue({ success: true }) + + await store.login('john', 'password') + + expect(loginSpy).toHaveBeenCalledWith('john', 'password') +}) +``` + +## Testing Store $subscribe + +```javascript +import { setActivePinia, createPinia } from 'pinia' +import { useUserStore } from '@/stores/user' + +test('subscription triggers on state change', () => { + setActivePinia(createPinia()) + const store = useUserStore() + + const callback = vi.fn() + store.$subscribe(callback) + + store.user = { name: 'John' } + + expect(callback).toHaveBeenCalled() +}) +``` + +## Reference +- [Pinia Testing Guide](https://pinia.vuejs.org/cookbook/testing.html) +- [@pinia/testing Package](https://www.npmjs.com/package/@pinia/testing) +- [Vue Test Utils - Plugins](https://test-utils.vuejs.org/guide/advanced/plugins.html) diff --git a/.agents/skills/vue-testing-best-practices/reference/testing-suspense-async-components.md b/.agents/skills/vue-testing-best-practices/reference/testing-suspense-async-components.md new file mode 100644 index 000000000..f935533c0 --- /dev/null +++ b/.agents/skills/vue-testing-best-practices/reference/testing-suspense-async-components.md @@ -0,0 +1,229 @@ +--- +title: Wrap Async Setup Components in Suspense for Testing +impact: HIGH +impactDescription: Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors +type: gotcha +tags: [vue3, testing, suspense, async-setup, vue-test-utils, vitest] +--- + +# Wrap Async Setup Components in Suspense for Testing + +**Impact: HIGH** - Components using `async setup()` require a `` wrapper to function correctly. Testing them without Suspense causes the component to never render, leading to test failures and confusing errors. + +Create a test wrapper component with Suspense or use a `mountSuspense` helper function for testing async components. + +## Task Checklist + +- [ ] Identify components with async setup (uses `await` in `