Claude Skill

vue-ops

Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_vue-ops-3dfaf0b.zip · 37 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/vue-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Vue Operations

Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 4, and testing — production patterns with TypeScript throughout.

Vue 3 / Nuxt 4 ecosystem facts verified as of 2026-07-05.


Reactivity Decision Tree

What data do I need to make reactive?
│
├─ A single primitive (string, number, boolean)?
│   └─ ref()
│       const count = ref(0)
│       const name = ref('')
│
├─ A plain object or array with deep reactivity?
│   ├─ Will I destructure it or pass properties individually?
│   │   └─ reactive() — but use toRefs() when destructuring
│   └─ Will I replace the whole object at once?
│       └─ ref() — ref.value = newObject
│
├─ Derived/computed state from other reactive sources?
│   └─ computed()
│       const doubled = computed(() => count.value * 2)
│
├─ A large object where only top-level keys change?
│   └─ shallowRef() or shallowReactive()
│       const state = shallowRef({ nested: { big: 'data' } })
│
├─ Side effects that should run when dependencies change?
│   ├─ Don't need to know old value, auto-tracks dependencies?
│   │   └─ watchEffect(() => { ... })
│   └─ Need old/new values, explicit sources, or lazy execution?
│       └─ watch(source, (newVal, oldVal) => { ... })
│
└─ Data that should NOT be reactive (raw DOM, third-party instances)?
    └─ markRaw(obj) or shallowRef(obj)

Component Communication Decision Tree

How far does data need to travel?
│
├─ Parent → direct child?
│   └─ props (defineProps)
│       Direct, explicit, type-safe
│
├─ Child → parent (user action / data update)?
│   └─ emit (defineEmits)
│       defineEmits<{ change: [value: string] }>()
│
├─ Parent ↔ child bidirectional binding?
│   └─ v-model via defineModel() (Vue 3.4+)
│       const model = defineModel<string>()
│
├─ Ancestor → deep descendant (prop drilling problem)?
│   └─ provide / inject
│       Use InjectionKey<T> for type safety
│
├─ Siblings or unrelated components?
│   ├─ Simple/few shared values?
│   │   └─ provide / inject from a common ancestor
│   └─ Complex shared state or cross-tree communication?
│       └─ Pinia store
│
├─ Truly global state (user session, cart, preferences)?
│   └─ Pinia store
│       defineStore with setup syntax
│
└─ One-time events between distant components (rare)?
    └─ Pinia action + watch, or mitt event bus
        Avoid: Vue removed $emit on root in Vue 3

Composition API Quick Reference

<script setup> — the standard

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'

// Props — with TypeScript generics (no runtime declaration needed)
const props = defineProps<{
  title: string
  count?: number
}>()

// Props with defaults
const props = withDefaults(defineProps<{
  size: 'sm' | 'md' | 'lg'
  disabled?: boolean
}>(), {
  size: 'md',
  disabled: false,
})

// Emits — type-safe event signatures
const emit = defineEmits<{
  change: [value: string]        // named tuple syntax (Vue 3.3+)
  update: [id: number, data: object]
  close: []
}>()

// Reactive state
const count = ref(0)
const user = reactive({ name: '', email: '' })

// Computed
const doubled = computed(() => count.value * 2)

// Watch
watch(count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`)
})

// Lifecycle
onMounted(() => {
  console.log('component mounted')
})
</script>

defineModel — v-model binding (Vue 3.4+)

<!-- Child component: MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })

// Named v-model: <MyInput v-model:title="..." />
const title = defineModel<string>('title')

// With modifiers
const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

defineExpose — expose to parent refs

<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)

function focus() {
  inputRef.value?.focus()
}

// Expose public API for parent template refs
defineExpose({ focus })
</script>

defineOptions — component meta (Vue 3.3+)

<script setup lang="ts">
defineOptions({
  name: 'MyComponent',
  inheritAttrs: false,
})
</script>

defineSlots — type slots (Vue 3.3+)

<script setup lang="ts">
defineSlots<{
  default(props: { item: User }): any
  header(props: {}): any
}>()
</script>

Pinia Quick Start

Setup syntax (recommended — composable style)

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')

  // getters
  const doubled = computed(() => count.value * 2)

  // actions
  function increment() {
    count.value++
  }

  async function fetchData() {
    const data = await api.get('/data')
    count.value = data.total
  }

  return { count, name, doubled, increment, fetchData }
})

Options syntax

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2,
  },
  actions: {
    increment() { this.count++ },
  },
})

Using stores in components

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()

// storeToRefs preserves reactivity when destructuring state/getters
// Actions can be destructured directly (they're not reactive)
const { count, doubled } = storeToRefs(store)
const { increment } = store
</script>

Pinia plugins — persistence example

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// In store:
export const useAuthStore = defineStore('auth', () => { ... }, {
  persist: true, // or { storage: sessionStorage, paths: ['token'] }
})

Vue Router Quick Reference

Basic configuration

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue'), // lazy load
    },
    {
      path: '/users/:id',
      name: 'user',
      component: () => import('@/views/UserView.vue'),
      props: true,                    // passes :id as prop
      meta: { requiresAuth: true },
    },
    {
      path: '/admin',
      component: () => import('@/layouts/AdminLayout.vue'),
      children: [
        { path: '', component: () => import('@/views/admin/Dashboard.vue') },
        { path: 'users', component: () => import('@/views/admin/Users.vue') },
      ],
    },
    { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
  ],
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

export default router

Navigation guards

// Global guard — auth check
router.beforeEach((to, from) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})

// Per-route guard
{
  path: '/admin',
  beforeEnter: (to, from) => {
    if (!isAdmin()) return { name: 'forbidden' }
  },
}
<!-- In-component guard -->
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    return confirm('Leave without saving?')
  }
})
</script>

TypeScript meta typing

// router/index.ts — augment RouteMeta
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    breadcrumb?: string
  }
}

Nuxt 4 Decision Tree

Nuxt 4's flagship change over Nuxt 3 is the app/ source directory (app code separated from server/ and root config — see ./references/nuxt.md); the rendering strategies below are unchanged.

What rendering strategy does my app need?
│
├─ Public content (blogs, marketing, docs)?
│   ├─ Content rarely changes (< daily)?
│   │   └─ SSG — prerender: { routes: ['/', '/about'] }
│   └─ Content updated frequently?
│       └─ ISR — routeRules: { '/blog/**': { isr: 3600 } }
│
├─ Dynamic per-user content (dashboards, apps)?
│   └─ SSR — ssr: true (Nuxt default)
│       Best for SEO + authenticated data
│
├─ Admin panel / internal tool (no SEO needed)?
│   └─ SPA — ssr: false in nuxt.config.ts
│
├─ Mixed needs (marketing pages + app)?
│   └─ Hybrid — routeRules per path
│       routeRules: {
│         '/': { prerender: true },
│         '/blog/**': { isr: 3600 },
│         '/app/**': { ssr: true },
│         '/admin/**': { ssr: false },
│       }
│
└─ Deploying to...
    ├─ Cloudflare Workers/Pages → preset: 'cloudflare'
    ├─ Vercel → preset: 'vercel' (auto-detected)
    ├─ Netlify → preset: 'netlify' (auto-detected)
    └─ Node.js server → preset: 'node-server'

Rendering Performance Quick Wins

Technique When to Use
v-memo="[dep1, dep2]" Skip re-rendering a subtree (usually a v-for row) unless listed deps changed — only for measured hot lists
<KeepAlive> Cache component instances across tab/route switches; pair with onActivated/onDeactivated for refresh logic
Virtual scrolling Lists with hundreds+ of rows — vue-virtual-scroller or @tanstack/vue-virtual render only visible items
shallowRef / markRaw Large objects or third-party instances that don't need deep reactivity (see Reactivity Decision Tree)
<!-- v-memo: row re-renders only when item.id or selection state changes -->
<div
  v-for="item in list"
  :key="item.id"
  v-memo="[item.id, item.id === selectedId]"
>
  {{ item.name }} — {{ item.id === selectedId ? 'selected' : '' }}
</div>

Tip: before writing a composable, check VueUse — 200+ battle-tested composables (useLocalStorage, useIntersectionObserver, useDark, ...) that handle SSR and cleanup edge cases.

Common Gotchas

Gotcha Why Fix
Reactivity lost after destructuring reactive() Destructuring extracts plain values, not refs Use toRefs(state) when destructuring, or use ref() instead of reactive()
ref.value needed in <script>, not in <template> Template auto-unwraps top-level refs Access as count in template, count.value in script
watch doesn't fire on nested object changes Default is shallow watch Add { deep: true } or watch a specific nested path () => obj.nested.prop
Async setup breaks SSR in Nuxt await in setup() suspends the component Use useAsyncData or useFetch — never raw await fetch() in Nuxt setup
watchEffect runs immediately and tracks lazily Tracks dependencies at runtime, not statically Use watch with explicit sources when you need control over what's tracked
Template refs are null before mount ref() is null until component is mounted Access template refs inside onMounted or use watch with { immediate: false }
Pinia store state lost when destructuring State properties are not reactive when pulled out directly Always use storeToRefs(store) for state/getters; destructure actions directly
Props are readonly — mutating causes warning Vue enforces one-way data flow Emit event to parent and let parent update; or use defineModel() for two-way binding
computed setter not called on direct assignment Computed with no setter is read-only by default Define get and set: computed({ get: () => ..., set: (v) => ... })
v-model on component uses wrong prop/event name Default v-model uses modelValue prop and update:modelValue event Use defineModel() (Vue 3.4+) or manually wire modelValue prop + update:modelValue emit
provide value is not reactive Providing a raw value instead of a ref Provide ref() or reactive() so injectors see updates: provide('key', ref(value))
defineAsyncComponent error not caught Async component rejects without error boundary Add errorComponent option or wrap in <Suspense> with error slot

Reference Files

File When to Load
./references/composition-api.md Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns
./references/state-routing.md Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions)
./references/nuxt.md Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content
./references/testing.md Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils

Staleness Verifier

This skill encodes fast-moving facts (the Vue 3 minor-version gates, the Nuxt 4 meta-framework, the ecosystem package stack). scripts/check-vue-facts.py guards them against silent drift — internal consistency in PR CI, live major-version drift in the scheduled freshness job:

# Structural (PR CI, no network): every catalogued package + Vue version gate is
# still named in this skill's prose, and the currency note still carries a year.
python3 skills/vue-ops/scripts/check-vue-facts.py --offline        # exit 0 consistent, 10 drift

# Live (weekly freshness job, never blocks a PR): is any documented major
# now behind npm's latest dist-tag? (e.g. Nuxt 5 while the prose says Nuxt 4.)
python3 skills/vue-ops/scripts/check-vue-facts.py --live           # exit 10 a major moved ahead, 7 npm unreachable

The canonical fact list lives in assets/vue-facts.json; when you add or drop a recommendation or the prose stops naming one, update it to match or --offline fails CI.


See Also

  • typescript-ops — TypeScript generics, utility types, strict mode configuration
  • testing-ops — General testing patterns, TDD, mocking strategies, CI integration
  • tailwind-ops — Tailwind CSS with Vue component patterns, dark mode, responsive design
  • javascript-ops — Modern JS patterns used alongside Vue (async/await, modules, iterators)
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
    • vue-facts.json 1.7 KB
      {
        "_comment": "Canonical fast-moving facts the vue-ops skill encodes. scripts/check-vue-facts.py asserts SKILL.md + references name these consistently (--offline) and probes the npm registry for major-version drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping. documented_major is the major the skill's prose commits to (vue 3, nuxt 4) or the current tracked major for ecosystem libs the prose names without pinning a version.",
        "schema": "claude-mods.vue-ops.facts/v1",
        "as_of": "2026-07-05",
        "vue_major": 3,
        "version_gates": {
          "_comment": "Vue 3 minor-version gates the skill centers on. --offline asserts each token is named in SKILL.md/references prose; a missing token means the prose stopped teaching a feature it claims.",
          "define_model": "Vue 3.4",
          "define_options": "Vue 3.3",
          "use_template_ref": "Vue 3.5",
          "nuxt_major": "Nuxt 4"
        },
        "packages": {
          "vue":               { "documented_major": 3, "prose": ["Vue 3"],              "role": "core (Composition API, <script setup>)" },
          "nuxt":              { "documented_major": 4, "prose": ["Nuxt 4"],             "role": "meta-framework (SSR/SSG/ISR, app/ srcDir host)" },
          "pinia":             { "documented_major": 3, "prose": ["pinia"],              "role": "store" },
          "vue-router":        { "documented_major": 5, "prose": ["vue-router"],         "role": "routing" },
          "@vueuse/core":      { "documented_major": 14, "prose": ["VueUse"],            "role": "composable collection" },
          "@vue/test-utils":   { "documented_major": 2, "prose": ["@vue/test-utils"],    "role": "component testing" },
          "vitest":            { "documented_major": 4, "prose": ["vitest"],             "role": "test runner" }
        }
      }
      
  • references
    • composition-api.md 18.1 KB
      # Composition API Reference
      
      Deep-dive patterns for Vue 3 Composition API: composables, lifecycle, template refs, provide/inject, v-model, slots, transitions, Teleport, Suspense, and custom directives.
      
      ---
      
      ## Composables
      
      ### Naming and structure convention
      
      ```ts
      // composables/useCounter.ts
      import { ref, computed, onUnmounted } from 'vue'
      
      // Rule: always prefix with "use"
      export function useCounter(initialValue = 0) {
        // State: return refs so callers can destructure while keeping reactivity
        const count = ref(initialValue)
        const isNegative = computed(() => count.value < 0)
      
        function increment() { count.value++ }
        function decrement() { count.value-- }
        function reset() { count.value = initialValue }
      
        // Cleanup: always handle in onUnmounted if you register listeners/timers
        // (onUnmounted is a no-op when called outside a component)
      
        return { count, isNegative, increment, decrement, reset }
      }
      ```
      
      ### Accepting refs as arguments (reactive composable inputs)
      
      ```ts
      // composables/useDouble.ts
      import { computed, toRef, MaybeRefOrGetter, toValue } from 'vue'
      
      // toValue() (Vue 3.3+) unwraps ref, getter, or raw value
      export function useDouble(value: MaybeRefOrGetter<number>) {
        return computed(() => toValue(value) * 2)
      }
      
      // Usage: works with raw value, ref, or getter
      const x = ref(5)
      const doubled = useDouble(x)          // reactive
      const doubled2 = useDouble(5)         // static
      const doubled3 = useDouble(() => x.value + 1)  // getter
      ```
      
      ### useFetch — data fetching with cancellation
      
      ```ts
      // composables/useFetch.ts
      import { ref, watchEffect, toValue, MaybeRefOrGetter } from 'vue'
      
      export function useFetch<T>(url: MaybeRefOrGetter<string>) {
        const data = ref<T | null>(null)
        const error = ref<Error | null>(null)
        const pending = ref(false)
      
        watchEffect((onCleanup) => {
          const controller = new AbortController()
      
          // Register cleanup BEFORE the async work
          onCleanup(() => controller.abort())
      
          pending.value = true
          error.value = null
      
          fetch(toValue(url), { signal: controller.signal })
            .then((res) => {
              if (!res.ok) throw new Error(`HTTP ${res.status}`)
              return res.json()
            })
            .then((json) => { data.value = json })
            .catch((err) => {
              if (err.name !== 'AbortError') error.value = err
            })
            .finally(() => { pending.value = false })
        })
      
        return { data, error, pending }
      }
      ```
      
      ### useLocalStorage — synced persistent state
      
      ```ts
      // composables/useLocalStorage.ts
      import { ref, watch } from 'vue'
      
      export function useLocalStorage<T>(key: string, defaultValue: T) {
        const stored = localStorage.getItem(key)
        const initial = stored ? (JSON.parse(stored) as T) : defaultValue
        const state = ref<T>(initial)
      
        watch(
          state,
          (value) => localStorage.setItem(key, JSON.stringify(value)),
          { deep: true }
        )
      
        return state
      }
      
      // Usage
      const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
      ```
      
      ### useEventListener — safe event binding
      
      ```ts
      // composables/useEventListener.ts
      import { onMounted, onUnmounted, isRef, watch } from 'vue'
      import type { Ref } from 'vue'
      
      export function useEventListener<K extends keyof WindowEventMap>(
        target: Window | Document | Ref<HTMLElement | null>,
        event: K,
        handler: (e: WindowEventMap[K]) => void
      ) {
        if (isRef(target)) {
          watch(target, (el, _, onCleanup) => {
            el?.addEventListener(event, handler as EventListener)
            onCleanup(() => el?.removeEventListener(event, handler as EventListener))
          })
        } else {
          onMounted(() => target.addEventListener(event, handler as EventListener))
          onUnmounted(() => target.removeEventListener(event, handler as EventListener))
        }
      }
      
      // Usage
      useEventListener(window, 'resize', () => {
        console.log('window resized')
      })
      ```
      
      ### useDark — dark mode toggle
      
      ```ts
      // composables/useDark.ts
      import { ref, watch, onMounted } from 'vue'
      
      export function useDark() {
        const isDark = ref(false)
      
        onMounted(() => {
          isDark.value = document.documentElement.classList.contains('dark')
            || window.matchMedia('(prefers-color-scheme: dark)').matches
        })
      
        watch(isDark, (dark) => {
          document.documentElement.classList.toggle('dark', dark)
        })
      
        function toggle() { isDark.value = !isDark.value }
      
        return { isDark, toggle }
      }
      ```
      
      ### useIntersectionObserver — lazy loading / scroll tracking
      
      ```ts
      // composables/useIntersectionObserver.ts
      import { ref, onMounted, onUnmounted } from 'vue'
      import type { Ref } from 'vue'
      
      export function useIntersectionObserver(
        target: Ref<HTMLElement | null>,
        options: IntersectionObserverInit = {}
      ) {
        const isIntersecting = ref(false)
        let observer: IntersectionObserver | null = null
      
        onMounted(() => {
          observer = new IntersectionObserver(([entry]) => {
            isIntersecting.value = entry.isIntersecting
          }, options)
      
          if (target.value) observer.observe(target.value)
        })
      
        onUnmounted(() => observer?.disconnect())
      
        return { isIntersecting }
      }
      
      // Usage
      const el = ref<HTMLElement | null>(null)
      const { isIntersecting } = useIntersectionObserver(el, { threshold: 0.1 })
      ```
      
      ---
      
      ## Lifecycle Hooks
      
      ```ts
      import {
        onBeforeMount,   // before first render, DOM not yet created
        onMounted,       // after first render, DOM available
        onBeforeUpdate,  // before re-render triggered by reactive change
        onUpdated,       // after re-render (DOM updated)
        onBeforeUnmount, // before component teardown (still fully functional)
        onUnmounted,     // after component teardown
        onActivated,     // component re-activated inside <KeepAlive>
        onDeactivated,   // component deactivated inside <KeepAlive>
        onErrorCaptured, // error from descendant component
      } from 'vue'
      
      // Pattern: separate concerns into multiple onMounted calls
      onMounted(() => { initChart() })
      onMounted(() => { attachKeyboardListeners() })
      
      // KeepAlive lifecycle — fetch fresh data on each activation
      onActivated(() => { refreshData() })
      onDeactivated(() => { pauseAnimations() })
      
      // Error boundary at composable level
      onErrorCaptured((err, instance, info) => {
        logError(err)
        return false // prevent propagation
      })
      ```
      
      ---
      
      ## Template Refs
      
      ### Basic ref() approach
      
      ```vue
      <script setup lang="ts">
      import { ref, onMounted } from 'vue'
      
      const inputEl = ref<HTMLInputElement | null>(null)
      
      onMounted(() => {
        inputEl.value?.focus()
      })
      </script>
      
      <template>
        <input ref="inputEl" type="text" />
      </template>
      ```
      
      ### useTemplateRef() — Vue 3.5+
      
      ```vue
      <script setup lang="ts">
      import { useTemplateRef, onMounted } from 'vue'
      
      // String key matches the ref="..." attribute in template
      const input = useTemplateRef<HTMLInputElement>('inputEl')
      
      onMounted(() => {
        input.value?.focus()
      })
      </script>
      
      <template>
        <input ref="inputEl" type="text" />
      </template>
      ```
      
      ### Component refs — accessing exposed methods
      
      ```vue
      <!-- Parent -->
      <script setup lang="ts">
      import { ref } from 'vue'
      import type ChildComponent from './ChildComponent.vue'
      
      const child = ref<InstanceType<typeof ChildComponent> | null>(null)
      
      function focusChild() {
        child.value?.focus() // only works if child uses defineExpose
      }
      </script>
      
      <template>
        <ChildComponent ref="child" />
      </template>
      ```
      
      ```vue
      <!-- ChildComponent.vue -->
      <script setup lang="ts">
      import { ref } from 'vue'
      
      const inputEl = ref<HTMLInputElement | null>(null)
      
      function focus() {
        inputEl.value?.focus()
      }
      
      defineExpose({ focus })
      </script>
      ```
      
      ### Dynamic template refs in v-for
      
      ```vue
      <script setup lang="ts">
      import { ref } from 'vue'
      
      const itemRefs = ref<HTMLElement[]>([])
      const items = ref(['a', 'b', 'c'])
      </script>
      
      <template>
        <ul>
          <li
            v-for="item in items"
            :key="item"
            :ref="(el) => { if (el) itemRefs.push(el as HTMLElement) }"
          >
            {{ item }}
          </li>
        </ul>
      </template>
      ```
      
      ---
      
      ## provide / inject
      
      ### Typed injection keys (InjectionKey<T>)
      
      ```ts
      // keys/injection-keys.ts
      import { InjectionKey, Ref } from 'vue'
      
      export interface UserContext {
        user: Ref<User | null>
        logout: () => void
      }
      
      // The key carries the type — no casts needed at inject site
      export const UserContextKey: InjectionKey<UserContext> = Symbol('UserContext')
      ```
      
      ### Providing values (ancestor component)
      
      ```vue
      <!-- App.vue or layout component -->
      <script setup lang="ts">
      import { provide, ref, readonly } from 'vue'
      import { UserContextKey } from '@/keys/injection-keys'
      import type { User } from '@/types'
      
      const user = ref<User | null>(null)
      
      function logout() {
        user.value = null
      }
      
      // Wrap in readonly to prevent descendants from mutating directly
      provide(UserContextKey, { user: readonly(user), logout })
      </script>
      ```
      
      ### Injecting in descendants
      
      ```vue
      <script setup lang="ts">
      import { inject } from 'vue'
      import { UserContextKey } from '@/keys/injection-keys'
      
      // TypeScript knows the type from the InjectionKey
      const ctx = inject(UserContextKey)
      // ctx is UserContext | undefined — handle the undefined case
      
      // With default value (ensures non-null)
      const ctx2 = inject(UserContextKey, {
        user: ref(null),
        logout: () => {},
      })
      </script>
      ```
      
      ---
      
      ## v-model Patterns
      
      ### defineModel() — Vue 3.4+
      
      ```vue
      <!-- SimpleInput.vue -->
      <script setup lang="ts">
      // Single v-model — replaces modelValue prop + update:modelValue emit
      const model = defineModel<string>({ required: true })
      </script>
      
      <template>
        <input :value="model" @input="model = ($event.target as HTMLInputElement).value" />
      </template>
      ```
      
      ```vue
      <!-- Parent usage -->
      <SimpleInput v-model="username" />
      ```
      
      ### Multiple v-models
      
      ```vue
      <!-- RangeInput.vue -->
      <script setup lang="ts">
      const min = defineModel<number>('min', { default: 0 })
      const max = defineModel<number>('max', { default: 100 })
      </script>
      
      <template>
        <input type="number" :value="min" @input="min = +($event.target as HTMLInputElement).value" />
        <input type="number" :value="max" @input="max = +($event.target as HTMLInputElement).value" />
      </template>
      ```
      
      ```vue
      <!-- Parent usage -->
      <RangeInput v-model:min="rangeMin" v-model:max="rangeMax" />
      ```
      
      ### v-model with modifiers
      
      ```vue
      <!-- UpperInput.vue -->
      <script setup lang="ts">
      const [model, modifiers] = defineModel<string, 'uppercase' | 'trim'>({
        set(value) {
          if (modifiers.trim) value = value.trim()
          if (modifiers.uppercase) value = value.toUpperCase()
          return value
        }
      })
      </script>
      ```
      
      ```vue
      <!-- Parent usage -->
      <UpperInput v-model.uppercase.trim="text" />
      ```
      
      ---
      
      ## Slots
      
      ### Named slots with TypeScript types
      
      ```vue
      <!-- DataTable.vue -->
      <script setup lang="ts">
      defineSlots<{
        default?: (props: {}) => any
        header?: (props: { title: string }) => any
        row: (props: { item: User; index: number }) => any
        empty?: (props: {}) => any
      }>()
      
      const props = defineProps<{ items: User[] }>()
      </script>
      
      <template>
        <div>
          <slot name="header" :title="'Users'" />
          <div v-if="props.items.length === 0">
            <slot name="empty" />
          </div>
          <div v-for="(item, index) in props.items" :key="item.id">
            <slot name="row" :item="item" :index="index" />
          </div>
          <slot />
        </div>
      </template>
      ```
      
      ```vue
      <!-- Parent usage — scoped slot destructuring -->
      <DataTable :items="users">
        <template #header="{ title }">
          <h2>{{ title }}</h2>
        </template>
        <template #row="{ item, index }">
          <div>{{ index + 1 }}. {{ item.name }}</div>
        </template>
        <template #empty>
          <p>No users found.</p>
        </template>
      </DataTable>
      ```
      
      ### Renderless components
      
      ```vue
      <!-- Renderless: MouseTracker.vue -->
      <script setup lang="ts">
      import { ref } from 'vue'
      import { useEventListener } from '@/composables/useEventListener'
      
      const x = ref(0)
      const y = ref(0)
      
      useEventListener(window, 'mousemove', (e) => {
        x.value = e.clientX
        y.value = e.clientY
      })
      </script>
      
      <template>
        <!-- Only renders what's in the default scoped slot -->
        <slot :x="x" :y="y" />
      </template>
      ```
      
      ```vue
      <!-- Usage -->
      <MouseTracker v-slot="{ x, y }">
        Cursor: {{ x }}, {{ y }}
      </MouseTracker>
      ```
      
      ### useSlots() in composables
      
      ```ts
      import { useSlots, computed } from 'vue'
      
      // Check if a named slot is provided
      export function useHasSlot(name: string) {
        const slots = useSlots()
        return computed(() => !!slots[name])
      }
      ```
      
      ---
      
      ## Transitions
      
      ### CSS transitions
      
      ```vue
      <script setup lang="ts">
      import { ref } from 'vue'
      const show = ref(true)
      </script>
      
      <template>
        <button @click="show = !show">Toggle</button>
      
        <Transition name="fade">
          <div v-if="show" class="box">Hello</div>
        </Transition>
      </template>
      
      <style scoped>
      .fade-enter-active,
      .fade-leave-active {
        transition: opacity 0.3s ease;
      }
      
      .fade-enter-from,
      .fade-leave-to {
        opacity: 0;
      }
      </style>
      ```
      
      ### JavaScript hooks (GSAP / Web Animations API)
      
      ```vue
      <template>
        <Transition
          @before-enter="onBeforeEnter"
          @enter="onEnter"
          @leave="onLeave"
          :css="false"
        >
          <div v-if="show" />
        </Transition>
      </template>
      
      <script setup lang="ts">
      import gsap from 'gsap'
      
      function onBeforeEnter(el: Element) {
        gsap.set(el, { opacity: 0, y: -20 })
      }
      
      function onEnter(el: Element, done: () => void) {
        gsap.to(el, { opacity: 1, y: 0, duration: 0.4, onComplete: done })
      }
      
      function onLeave(el: Element, done: () => void) {
        gsap.to(el, { opacity: 0, y: 20, duration: 0.3, onComplete: done })
      }
      </script>
      ```
      
      ### TransitionGroup — list animations
      
      ```vue
      <template>
        <TransitionGroup name="list" tag="ul">
          <li v-for="item in items" :key="item.id">
            {{ item.name }}
          </li>
        </TransitionGroup>
      </template>
      
      <style>
      .list-enter-active,
      .list-leave-active {
        transition: all 0.3s ease;
      }
      .list-enter-from {
        opacity: 0;
        transform: translateX(-30px);
      }
      .list-leave-to {
        opacity: 0;
        transform: translateX(30px);
      }
      /* Animate position changes of remaining items */
      .list-move {
        transition: transform 0.3s ease;
      }
      /* Ensure leaving items take up no space during animation */
      .list-leave-active {
        position: absolute;
      }
      </style>
      ```
      
      ---
      
      ## Teleport
      
      ### Modal pattern
      
      ```vue
      <!-- Modal.vue -->
      <script setup lang="ts">
      defineProps<{ open: boolean }>()
      const emit = defineEmits<{ close: [] }>()
      </script>
      
      <template>
        <Teleport to="body">
          <Transition name="fade">
            <div v-if="open" class="modal-overlay" @click.self="emit('close')">
              <div class="modal-content" role="dialog" aria-modal="true">
                <slot />
                <button @click="emit('close')">Close</button>
              </div>
            </div>
          </Transition>
        </Teleport>
      </template>
      ```
      
      ### Disabling Teleport conditionally
      
      ```vue
      <!-- Disable teleport in SSR or based on prop -->
      <Teleport to="#modals" :disabled="!isMounted">
        <div>Content</div>
      </Teleport>
      ```
      
      ---
      
      ## Suspense
      
      ### Async setup with Suspense
      
      ```vue
      <!-- AsyncUserProfile.vue — top-level await allowed in <script setup> -->
      <script setup lang="ts">
      const { data: user } = await useFetch<User>('/api/user')
      //     ^ Component is now async — must be wrapped in <Suspense>
      </script>
      
      <template>
        <div>{{ user?.name }}</div>
      </template>
      ```
      
      ```vue
      <!-- Parent wraps async component -->
      <template>
        <Suspense>
          <template #default>
            <AsyncUserProfile />
          </template>
          <template #fallback>
            <div class="skeleton" aria-busy="true">Loading...</div>
          </template>
        </Suspense>
      </template>
      ```
      
      ### Error handling with Suspense
      
      ```vue
      <script setup lang="ts">
      import { ref } from 'vue'
      
      const error = ref<Error | null>(null)
      
      function handleError(e: Error) {
        error.value = e
      }
      </script>
      
      <template>
        <div v-if="error">Error: {{ error.message }}</div>
        <Suspense v-else @resolve="onResolved" @fallback="onFallback" @pending="onPending">
          <AsyncComponent />
          <template #fallback>Loading...</template>
        </Suspense>
      </template>
      ```
      
      ---
      
      ## Custom Directives
      
      ### vFocus — auto-focus on mount
      
      ```ts
      // directives/vFocus.ts
      import type { Directive } from 'vue'
      
      export const vFocus: Directive<HTMLElement> = {
        mounted(el) {
          el.focus()
        }
      }
      ```
      
      ```vue
      <script setup lang="ts">
      import { vFocus } from '@/directives/vFocus'
      // Directives imported in <script setup> are automatically available
      </script>
      
      <template>
        <input v-focus type="text" />
      </template>
      ```
      
      ### vClickOutside — dismiss on outside click
      
      ```ts
      // directives/vClickOutside.ts
      import type { Directive } from 'vue'
      
      type ClickOutsideHandler = (event: MouseEvent) => void
      
      export const vClickOutside: Directive<HTMLElement, ClickOutsideHandler> = {
        mounted(el, binding) {
          el._clickOutside = (event: MouseEvent) => {
            if (!el.contains(event.target as Node)) {
              binding.value(event)
            }
          }
          document.addEventListener('click', el._clickOutside)
        },
        unmounted(el) {
          document.removeEventListener('click', el._clickOutside)
          delete el._clickOutside
        },
      }
      ```
      
      ### vIntersect — visibility tracking
      
      ```ts
      // directives/vIntersect.ts
      import type { Directive } from 'vue'
      
      interface IntersectBinding {
        handler: (isIntersecting: boolean) => void
        options?: IntersectionObserverInit
      }
      
      export const vIntersect: Directive<HTMLElement, IntersectBinding> = {
        mounted(el, { value }) {
          const observer = new IntersectionObserver(
            ([entry]) => value.handler(entry.isIntersecting),
            value.options
          )
          observer.observe(el)
          el._intersectObserver = observer
        },
        unmounted(el) {
          el._intersectObserver?.disconnect()
        },
      }
      ```
      
      ### Registering directives globally
      
      ```ts
      // main.ts
      import { createApp } from 'vue'
      import { vFocus } from '@/directives/vFocus'
      import { vClickOutside } from '@/directives/vClickOutside'
      
      const app = createApp(App)
      app.directive('focus', vFocus)
      app.directive('click-outside', vClickOutside)
      app.mount('#app')
      ```
      
      ### Directive lifecycle hooks reference
      
      ```ts
      const myDirective: Directive = {
        created(el, binding, vnode) {},       // before component attrs/events applied
        beforeMount(el, binding, vnode) {},   // before element inserted into DOM
        mounted(el, binding, vnode) {},       // after element inserted, children mounted
        beforeUpdate(el, binding, vnode, prevVnode) {},  // before parent component updates
        updated(el, binding, vnode, prevVnode) {},        // after parent and children updated
        beforeUnmount(el, binding, vnode) {},  // before element removed
        unmounted(el, binding, vnode) {},     // after element removed
      }
      
      // binding object shape:
      // binding.value   — value passed to directive (v-my-dir="value")
      // binding.oldValue — previous value (updated hook only)
      // binding.arg    — argument (v-my-dir:arg)
      // binding.modifiers — object { lazy: true } for v-my-dir.lazy
      // binding.instance — component instance
      ```
      
    • nuxt.md 20.2 KB
      # Nuxt 4 Reference
      
      Production patterns for Nuxt 4: directory structure, rendering modes, data fetching, server routes, middleware, plugins, modules, SEO, deployment, and Nuxt Content.
      
      ---
      
      ## Architecture Overview
      
      Nuxt 4 is built on:
      - **Nitro** — universal server engine (runs on Node, Cloudflare Workers, Deno, Bun, etc.)
      - **Vite** — fast dev server and build tool
      - **Vue 3** — Composition API throughout (Nuxt 4.4+ ships Vue Router v5)
      - **Auto-imports** — no need to import `ref`, `computed`, `useFetch`, etc. — Nuxt imports them automatically
      - **File-based routing** — `app/pages/` directory maps to routes
      
      ---
      
      ## Directory Structure (the Nuxt 4 change)
      
      Application code lives under `app/` (the srcDir); server code and config stay at the root. This separates app-environment code from server-environment code — faster dev-server boot, better IDE type inference per environment.
      
      ```
      my-app/
      ├── app/                  # srcDir — everything that runs in the Vue app
      │   ├── assets/
      │   ├── components/
      │   ├── composables/
      │   ├── layouts/
      │   ├── middleware/       # route middleware (client navigation)
      │   ├── pages/
      │   ├── plugins/
      │   ├── utils/
      │   ├── app.vue
      │   └── error.vue
      ├── public/
      ├── server/               # stays at root — Nitro (api/, routes/, middleware/)
      ├── shared/               # code shared by app/ and server/ — import via #shared
      ├── nuxt.config.ts
      └── package.json
      ```
      
      Key rules:
      - **`~/` and `@/` resolve to `app/`**, not the project root. Helpers that sat at root level and were imported with `~/` need to move into `app/` (or `shared/`) or get an explicit alias.
      - **`shared/`** is the sanctioned home for code both environments import (types, validators, constants) via the `#shared` alias — server code must not import from `app/`.
      - **Auto-imports scan `app/`** — `app/composables/`, `app/components/`, `app/utils/` work exactly as their root-level Nuxt 3 counterparts did.
      
      **Nuxt 3 differences**: in Nuxt 3 all of these directories sat at the project root and `~/` pointed to the root. Nuxt 4 auto-detects the old flat layout and keeps working with it, so migration is mechanical — move the app directories (plus `app.vue` / `error.vue`) into `app/` and fix any `~/` imports that referenced root-level files.
      
      ---
      
      ## Rendering Modes
      
      ### nuxt.config.ts — rendering configuration
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        // SSR (default) — server renders each request
        ssr: true,
      
        // SPA mode — no server rendering
        // ssr: false,
      
        // Hybrid rendering — per-route rules (most powerful)
        routeRules: {
          '/': { prerender: true },                    // SSG — render at build time
          '/blog': { prerender: true },
          '/blog/**': { isr: 3600 },                   // ISR — regenerate every hour
          '/shop/**': { swr: 600 },                    // SWR — stale-while-revalidate 10min
          '/app/**': { ssr: true },                    // SSR — always server rendered
          '/admin/**': { ssr: false },                 // SPA — client-only
          '/api/**': { cors: true, headers: { 'cache-control': 's-maxage=0' } },
        },
      })
      ```
      
      ### Prerendering specific routes
      
      ```ts
      export default defineNuxtConfig({
        nitro: {
          prerender: {
            routes: ['/', '/about', '/contact'],
            crawlLinks: true,         // follow all <a> links and prerender them too
            ignore: ['/admin'],
          },
        },
      })
      ```
      
      ---
      
      ## Data Fetching
      
      ### useFetch — SSR-safe primary fetching
      
      Nuxt 4 semantics: `data`/`error` default to `undefined` (Nuxt 3 used `null`), results are
      shallow-reactive by default (`deep: false`), requests sharing a key are deduplicated and
      their state is cleaned up when the last consuming component unmounts. The `pending` ref is
      deprecated — branch on `status` (`'idle' | 'pending' | 'success' | 'error'`) instead.
      
      ```vue
      <script setup lang="ts">
      interface Post { id: number; title: string; body: string }
      
      // Automatically de-duplicates on server/client, serializes for hydration
      const { data: post, status, error, refresh } = await useFetch<Post>(
        '/api/posts/1',
        {
          key: 'post-1',                      // deduplicate key (auto-generated if omitted)
          server: true,                       // fetch on server (default)
          lazy: false,                        // await before rendering (default)
          default: () => ({ id: 0, title: '', body: '' } as Post),
          transform: (data) => data,          // transform response before storing
          pick: ['id', 'title'],              // pick only these fields (reduces payload)
          watch: [userId],                    // re-fetch when these refs change
        }
      )
      
      // Re-fetch manually
      async function reload() {
        await refresh()
      }
      </script>
      ```
      
      ### useFetch with dynamic URL
      
      ```vue
      <script setup lang="ts">
      const route = useRoute()
      
      // Reactive URL — re-fetches when route param changes
      const { data: user } = await useFetch(() => `/api/users/${route.params.id}`)
      </script>
      ```
      
      ### useAsyncData — custom async logic
      
      ```vue
      <script setup lang="ts">
      // When you need more than a simple fetch (multiple sources, custom logic)
      const { data: stats } = await useAsyncData('dashboard-stats', async () => {
        const [users, orders, revenue] = await Promise.all([
          $fetch<User[]>('/api/users'),
          $fetch<Order[]>('/api/orders'),
          $fetch<number>('/api/revenue'),
        ])
        return { users, orders, revenue }
      })
      </script>
      ```
      
      ### $fetch — client-side / server-to-server fetching
      
      ```ts
      // Use $fetch for:
      // - Actions triggered by user interaction (form submit, button click)
      // - Server route handlers
      // - Inside useAsyncData when you need to compose data
      
      // In a component action (not in setup):
      async function submitForm(data: FormData) {
        const result = await $fetch('/api/submit', {
          method: 'POST',
          body: data,
        })
      }
      
      // With error handling
      try {
        const user = await $fetch<User>('/api/user', {
          headers: useRequestHeaders(['cookie']),  // forward cookies for auth
        })
      } catch (error) {
        if (error.statusCode === 401) {
          await navigateTo('/login')
        }
      }
      ```
      
      ### Lazy fetching — render immediately, load async
      
      ```vue
      <script setup lang="ts">
      // lazy: true — don't block render, data loads async
      const { data: comments, status } = useFetch('/api/comments', { lazy: true })
      </script>
      
      <template>
        <div v-if="status === 'pending'" class="skeleton">Loading comments...</div>
        <CommentList v-else :comments="comments" />
      </template>
      ```
      
      ---
      
      ## Server Routes
      
      ```
      server/
      ├── api/              # Accessible at /api/*
      │   ├── users/
      │   │   ├── index.get.ts     # GET  /api/users
      │   │   ├── index.post.ts    # POST /api/users
      │   │   └── [id].get.ts      # GET  /api/users/:id
      │   └── auth/
      │       ├── login.post.ts
      │       └── logout.post.ts
      ├── routes/           # Accessible at any path
      │   └── sitemap.xml.get.ts   # GET /sitemap.xml
      └── middleware/       # Runs on every server request
          └── auth.ts
      ```
      
      ### Basic API route
      
      ```ts
      // server/api/users/index.get.ts
      import { defineEventHandler, getQuery, H3Event } from 'h3'
      
      export default defineEventHandler(async (event: H3Event) => {
        const query = getQuery(event)
        const page = Number(query.page ?? 1)
        const limit = Number(query.limit ?? 20)
      
        const users = await db.users.findMany({
          skip: (page - 1) * limit,
          take: limit,
        })
      
        return users // automatically serialized as JSON
      })
      ```
      
      ### POST with validation (zod)
      
      ```ts
      // server/api/users/index.post.ts
      import { defineEventHandler, readBody } from 'h3'
      import { z } from 'zod'
      
      const CreateUserSchema = z.object({
        name: z.string().min(2).max(100),
        email: z.email(),
        role: z.enum(['user', 'admin']).default('user'),
      })
      
      export default defineEventHandler(async (event) => {
        const body = await readBody(event)
      
        // Validate — throws H3Error 400 on failure
        const data = await CreateUserSchema.parseAsync(body).catch(() => {
          throw createError({ statusCode: 400, statusMessage: 'Invalid request body' })
        })
      
        const user = await db.users.create({ data })
        setResponseStatus(event, 201)
        return user
      })
      ```
      
      ### Dynamic route parameter
      
      ```ts
      // server/api/users/[id].get.ts
      import { defineEventHandler, getRouterParam } from 'h3'
      
      export default defineEventHandler(async (event) => {
        const id = getRouterParam(event, 'id')
      
        if (!id) throw createError({ statusCode: 400, statusMessage: 'ID required' })
      
        const user = await db.users.findUnique({ where: { id: Number(id) } })
      
        if (!user) throw createError({ statusCode: 404, statusMessage: 'User not found' })
      
        return user
      })
      ```
      
      ### Server middleware — authentication
      
      ```ts
      // server/middleware/auth.ts
      import { defineEventHandler, getCookie, createError } from 'h3'
      
      export default defineEventHandler(async (event) => {
        // Only run auth check on /api/protected/* routes
        if (!event.node.req.url?.startsWith('/api/protected')) return
      
        const token = getCookie(event, 'auth_token')
          ?? getHeader(event, 'authorization')?.replace('Bearer ', '')
      
        if (!token) {
          throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
        }
      
        const user = await verifyToken(token)
        event.context.user = user  // attach to context for route handlers
      })
      ```
      
      ---
      
      ## Nuxt Middleware
      
      ### Route middleware (client-side navigation)
      
      ```ts
      // middleware/auth.ts
      export default defineNuxtRouteMiddleware((to, from) => {
        const auth = useAuthStore()
      
        if (!auth.isLoggedIn) {
          return navigateTo({
            path: '/login',
            query: { redirect: to.fullPath },
          })
        }
      })
      ```
      
      ### Using middleware in pages
      
      ```vue
      <script setup lang="ts">
      // Named middleware — run auth.ts middleware
      definePageMeta({
        middleware: ['auth'],
        // Or inline:
        // middleware: (to, from) => { ... }
      })
      </script>
      ```
      
      ### Global middleware (runs on every navigation)
      
      ```ts
      // middleware/analytics.global.ts  ← '.global' suffix makes it run always
      export default defineNuxtRouteMiddleware((to) => {
        if (import.meta.client) {
          trackPageView(to.fullPath)
        }
      })
      ```
      
      ### Server middleware (every HTTP request)
      
      ```ts
      // server/middleware/logger.ts
      export default defineEventHandler((event) => {
        console.log(`[${new Date().toISOString()}] ${event.node.req.method} ${event.node.req.url}`)
      })
      ```
      
      ---
      
      ## Plugins
      
      ### Client and server plugins
      
      ```ts
      // plugins/my-plugin.ts — runs on both server and client
      export default defineNuxtPlugin((nuxtApp) => {
        // Provide a helper to all components and composables
        return {
          provide: {
            formatDate: (date: Date) => date.toLocaleDateString(),
          },
        }
      })
      ```
      
      ```ts
      // plugins/sentry.client.ts — client-only (filename convention)
      import * as Sentry from '@sentry/vue'
      
      export default defineNuxtPlugin((nuxtApp) => {
        Sentry.init({
          app: nuxtApp.vueApp,
          dsn: useRuntimeConfig().public.sentryDsn,
        })
      })
      ```
      
      ```ts
      // plugins/db.server.ts — server-only
      import { PrismaClient } from '@prisma/client'
      
      let prisma: PrismaClient
      
      export default defineNuxtPlugin(() => {
        if (!prisma) prisma = new PrismaClient()
        return { provide: { prisma } }
      })
      ```
      
      ### Accessing provided values
      
      ```vue
      <script setup lang="ts">
      const { $formatDate, $prisma } = useNuxtApp()
      </script>
      ```
      
      ---
      
      ## Modules
      
      ### Using published modules
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        modules: [
          '@nuxtjs/tailwindcss',
          '@pinia/nuxt',
          '@nuxt/content',
          '@nuxt/image',
          '@nuxtjs/i18n',
          'nuxt-icon',
        ],
      
        // Module configuration
        pinia: {
          autoImports: ['defineStore', 'storeToRefs'],
        },
      })
      ```
      
      ### Building a custom module
      
      ```ts
      // modules/feature-flags/index.ts
      import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit'
      
      interface ModuleOptions {
        flags: Record<string, boolean>
      }
      
      export default defineNuxtModule<ModuleOptions>({
        meta: {
          name: 'feature-flags',
          configKey: 'featureFlags',
        },
        defaults: {
          flags: {},
        },
        setup(options, nuxt) {
          const resolver = createResolver(import.meta.url)
      
          // Add runtime config
          nuxt.options.runtimeConfig.public.featureFlags = options.flags
      
          // Add a plugin
          addPlugin(resolver.resolve('./runtime/plugin'))
      
          // Add auto-imports
          addImports({
            name: 'useFeatureFlag',
            from: resolver.resolve('./runtime/composables'),
          })
      
          // Hook into build process
          nuxt.hook('build:before', () => {
            console.log('Feature flags module: build starting')
          })
        },
      })
      ```
      
      ---
      
      ## State Management in Nuxt
      
      ### useState — SSR-safe shared state
      
      ```ts
      // composables/useSharedState.ts
      // useState() is SSR-safe: same key = same state across components in same request
      export const useTheme = () => useState<'light' | 'dark'>('theme', () => 'light')
      export const useUser = () => useState<User | null>('user', () => null)
      ```
      
      ```vue
      <script setup lang="ts">
      const theme = useTheme()
      // Reactive and synced — changing in one component updates all others
      </script>
      ```
      
      ### Pinia with Nuxt (recommended for complex state)
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        modules: ['@pinia/nuxt'],
        pinia: { autoImports: ['defineStore', 'storeToRefs'] },
      })
      ```
      
      ```ts
      // stores/user.ts — works in Nuxt with SSR hydration
      export const useUserStore = defineStore('user', () => {
        const user = ref<User | null>(null)
      
        // In Nuxt: fetch on server, hydrate on client
        async function fetchUser() {
          user.value = await $fetch<User>('/api/user')
        }
      
        return { user, fetchUser }
      })
      ```
      
      ---
      
      ## Runtime Config & Environment Variables
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        runtimeConfig: {
          // Private — only available on server (server routes, server-only plugins)
          databaseUrl: process.env.DATABASE_URL,
          jwtSecret: process.env.JWT_SECRET,
      
          // Public — exposed to client via useRuntimeConfig().public
          public: {
            apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
            sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
            appVersion: process.env.npm_package_version,
          },
        },
      })
      ```
      
      ```ts
      // app.config.ts — UI configuration (not secrets, bundled into client)
      export default defineAppConfig({
        ui: {
          primary: 'blue',
          notifications: { position: 'top-right' },
        },
      })
      ```
      
      ```vue
      <script setup lang="ts">
      // Client and server: public config
      const config = useRuntimeConfig()
      const apiBase = config.public.apiBase
      
      // App config
      const appConfig = useAppConfig()
      const primaryColor = appConfig.ui.primary
      </script>
      ```
      
      ---
      
      ## SEO
      
      ### useHead and useSeoMeta
      
      ```vue
      <script setup lang="ts">
      // useHead — full control
      useHead({
        title: 'My Page',
        titleTemplate: '%s — My Site',
        meta: [
          { name: 'description', content: 'Page description' },
          { property: 'og:type', content: 'website' },
        ],
        link: [
          { rel: 'canonical', href: 'https://mysite.com/page' },
        ],
        bodyAttrs: { class: 'dark-mode' },
      })
      
      // useSeoMeta — typed, tree-shakeable (preferred for meta tags)
      useSeoMeta({
        title: 'My Page',
        ogTitle: 'My Page',
        description: 'Page description for SEO',
        ogDescription: 'Page description for social sharing',
        ogImage: 'https://mysite.com/og-image.png',
        twitterCard: 'summary_large_image',
      })
      </script>
      ```
      
      ### defineOgImage — dynamic OG images
      
      ```vue
      <script setup lang="ts">
      // @nuxtjs/og-image module
      defineOgImage({
        component: 'MyOgImageTemplate',
        props: { title: 'My Page', description: 'Description' },
      })
      </script>
      ```
      
      ### Dynamic head in layouts
      
      ```vue
      <!-- layouts/default.vue -->
      <script setup lang="ts">
      useHead({
        titleTemplate: (title) => title ? `${title} — My App` : 'My App',
        htmlAttrs: { lang: 'en' },
        link: [
          { rel: 'icon', href: '/favicon.ico' },
        ],
      })
      </script>
      ```
      
      ---
      
      ## Error Handling
      
      ### Error page (error.vue)
      
      ```vue
      <!-- app/error.vue — replaces app.vue on error -->
      <script setup lang="ts">
      const props = defineProps<{
        error: {
          statusCode: number
          statusMessage: string
          message: string
        }
      }>()
      
      function handleError() {
        clearError({ redirect: '/' })
      }
      </script>
      
      <template>
        <div>
          <h1>{{ error.statusCode }}</h1>
          <p>{{ error.statusMessage }}</p>
          <button @click="handleError">Go Home</button>
        </div>
      </template>
      ```
      
      ### NuxtErrorBoundary — catch errors in subtree
      
      ```vue
      <template>
        <NuxtErrorBoundary @error="onError">
          <AsyncComponent />
          <template #error="{ error, clearError }">
            <div>
              <p>Something went wrong: {{ error.message }}</p>
              <button @click="clearError()">Retry</button>
            </div>
          </template>
        </NuxtErrorBoundary>
      </template>
      ```
      
      ### Throwing errors in server routes
      
      ```ts
      // server/api/users/[id].get.ts
      export default defineEventHandler(async (event) => {
        const user = await db.findUser(getRouterParam(event, 'id'))
      
        if (!user) {
          throw createError({
            statusCode: 404,
            statusMessage: 'User not found',
            data: { id: getRouterParam(event, 'id') },
          })
        }
      
        return user
      })
      ```
      
      ---
      
      ## Deployment
      
      ### Cloudflare Workers / Pages
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        nitro: {
          preset: 'cloudflare-pages', // or 'cloudflare'
        },
      })
      ```
      
      ```toml
      # wrangler.toml (if using Workers)
      name = "my-nuxt-app"
      main = ".output/server/index.mjs"
      compatibility_date = "2024-01-01"
      compatibility_flags = ["nodejs_compat"]
      
      [[kv_namespaces]]
      binding = "KV"
      id = "your-kv-namespace-id"
      ```
      
      ### Vercel (auto-detected)
      
      ```ts
      // nuxt.config.ts — Vercel detects automatically, no preset needed
      // But you can be explicit:
      export default defineNuxtConfig({
        nitro: { preset: 'vercel' },
      })
      ```
      
      ### Node.js server
      
      ```bash
      # Build
      npx nuxi build
      
      # Run
      node .output/server/index.mjs
      
      # With PM2
      pm2 start .output/server/index.mjs --name my-app
      ```
      
      ### Static hosting (full SSG)
      
      ```bash
      # Generate static files
      npx nuxi generate
      
      # Output in .output/public/ — deploy to any static host
      ```
      
      ```ts
      // nuxt.config.ts for full static
      export default defineNuxtConfig({
        ssr: true,
        nitro: {
          prerender: {
            crawlLinks: true,
            routes: ['/sitemap.xml'],
          },
        },
      })
      ```
      
      ---
      
      ## Nuxt Content
      
      ### Setup
      
      ```bash
      npx nuxi module add content
      ```
      
      ```ts
      // nuxt.config.ts
      export default defineNuxtConfig({
        modules: ['@nuxt/content'],
        content: {
          highlight: {
            theme: 'github-dark',
            langs: ['ts', 'vue', 'bash'],
          },
          markdown: {
            anchorLinks: true,
          },
        },
      })
      ```
      
      ### Querying content
      
      > The examples below use the Nuxt Content v2 `queryContent()` API. Nuxt Content v3
      > replaces it with typed collections (`content.config.ts` + `queryCollection()`); check
      > which major the project uses before reaching for either API.
      
      ```vue
      <!-- app/pages/blog/[slug].vue -->
      <script setup lang="ts">
      const route = useRoute()
      
      // Query a single document
      const { data: post } = await useAsyncData(
        `blog-${route.params.slug}`,
        () => queryContent('blog').where({ _path: `/blog/${route.params.slug}` }).findOne()
      )
      
      if (!post.value) throw createError({ statusCode: 404 })
      
      // SEO from frontmatter
      useSeoMeta({
        title: post.value.title,
        description: post.value.description,
      })
      </script>
      
      <template>
        <!-- Renders markdown with MDC components -->
        <ContentRenderer :value="post" />
      </template>
      ```
      
      ```vue
      <!-- Blog listing page -->
      <script setup lang="ts">
      const { data: posts } = await useAsyncData('blog-list', () =>
        queryContent('blog')
          .where({ published: true })
          .sort({ date: -1 })
          .only(['_path', 'title', 'description', 'date'])
          .find()
      )
      </script>
      ```
      
      ### MDC — Markdown Components
      
      ```md
      <!-- content/blog/my-post.md -->
      ---
      title: My Post
      description: Post description
      date: 2024-01-15
      published: true
      ---
      
      Regular markdown with **bold** and `code`.
      
      ::alert{type="warning"}
      This renders the Alert.vue component from components/content/
      ::
      
      :MyInlineComponent{prop="value"}
      ```
      
      ---
      
      ## Performance Patterns
      
      ### Component islands (selective hydration)
      
      ```vue
      <!-- Heavy chart that only runs client-side -->
      <template>
        <NuxtIsland name="HeavyChart" :props="{ data: chartData }" />
      </template>
      ```
      
      ### Payload optimization
      
      ```vue
      <script setup lang="ts">
      // clearNuxtData removes payload after navigation (saves memory)
      onBeforeRouteLeave(() => {
        clearNuxtData('heavy-data-key')
      })
      </script>
      ```
      
      ### Client-only components
      
      ```vue
      <template>
        <!-- Only renders on client — no SSR attempt -->
        <ClientOnly>
          <MapComponent />
          <template #fallback>
            <div class="map-skeleton" />
          </template>
        </ClientOnly>
      </template>
      ```
      
    • state-routing.md 16.2 KB
      # State Management & Routing Reference
      
      Advanced Pinia patterns and Vue Router configuration with TypeScript.
      
      ---
      
      ## Pinia — Setup Syntax (Recommended)
      
      The setup syntax mirrors `<script setup>` and is the preferred approach — full TypeScript inference, composables allowed inside, no `this` binding.
      
      ```ts
      // stores/auth.ts
      import { defineStore } from 'pinia'
      import { ref, computed } from 'vue'
      import type { User } from '@/types'
      
      export const useAuthStore = defineStore('auth', () => {
        // --- state (refs) ---
        const user = ref<User | null>(null)
        const token = ref<string | null>(null)
        const loading = ref(false)
      
        // --- getters (computed) ---
        const isLoggedIn = computed(() => !!token.value)
        const isAdmin = computed(() => user.value?.role === 'admin')
        const displayName = computed(() => user.value?.name ?? 'Guest')
      
        // --- actions (functions) ---
        async function login(email: string, password: string) {
          loading.value = true
          try {
            const res = await $fetch<{ user: User; token: string }>('/api/auth/login', {
              method: 'POST',
              body: { email, password },
            })
            user.value = res.user
            token.value = res.token
          } finally {
            loading.value = false
          }
        }
      
        function logout() {
          user.value = null
          token.value = null
        }
      
        return { user, token, loading, isLoggedIn, isAdmin, displayName, login, logout }
      })
      ```
      
      ---
      
      ## Pinia — Options Syntax
      
      ```ts
      // stores/cart.ts
      import { defineStore } from 'pinia'
      import type { CartItem, Product } from '@/types'
      
      export const useCartStore = defineStore('cart', {
        state: () => ({
          items: [] as CartItem[],
          discount: 0,
        }),
      
        getters: {
          // Getter with argument — return a function
          itemById: (state) => (id: string) =>
            state.items.find((item) => item.id === id),
      
          total: (state): number =>
            state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      
          discountedTotal(): number {
            // Can reference other getters via this
            return this.total * (1 - this.discount)
          },
        },
      
        actions: {
          addItem(product: Product) {
            const existing = this.itemById(product.id)
            if (existing) {
              existing.quantity++
            } else {
              this.items.push({ ...product, quantity: 1 })
            }
          },
      
          removeItem(id: string) {
            this.items = this.items.filter((item) => item.id !== id)
          },
      
          clearCart() {
            // $reset() is available in options syntax to reset to initial state
            this.$reset()
          },
        },
      })
      ```
      
      ---
      
      ## Store Composition — Using One Store Inside Another
      
      ```ts
      // stores/orders.ts
      import { defineStore } from 'pinia'
      import { computed } from 'vue'
      import { useAuthStore } from './auth'
      
      export const useOrdersStore = defineStore('orders', () => {
        const auth = useAuthStore()
      
        // Reactive dependency on auth store state
        const userOrders = computed(() =>
          allOrders.value.filter((o) => o.userId === auth.user?.id)
        )
      
        // Cross-store action
        async function placeOrder(items: CartItem[]) {
          if (!auth.isLoggedIn) throw new Error('Must be logged in')
          return await $fetch('/api/orders', {
            method: 'POST',
            body: { userId: auth.user!.id, items },
          })
        }
      
        return { userOrders, placeOrder }
      })
      ```
      
      ---
      
      ## storeToRefs — Destructuring Without Losing Reactivity
      
      ```vue
      <script setup lang="ts">
      import { storeToRefs } from 'pinia'
      import { useAuthStore } from '@/stores/auth'
      
      const auth = useAuthStore()
      
      // storeToRefs wraps state/getters in refs — safe to destructure
      const { user, isLoggedIn, displayName } = storeToRefs(auth)
      
      // Actions are plain functions — destructure directly from store
      const { login, logout } = auth
      
      // BAD — loses reactivity:
      // const { user } = auth   // user is now a plain value, not reactive
      </script>
      ```
      
      ---
      
      ## Pinia Plugins
      
      ### Persistence plugin (pinia-plugin-persistedstate)
      
      ```ts
      // main.ts
      import { createApp } from 'vue'
      import { createPinia } from 'pinia'
      import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
      import App from './App.vue'
      
      const pinia = createPinia()
      pinia.use(piniaPluginPersistedstate)
      
      createApp(App).use(pinia).mount('#app')
      ```
      
      ```ts
      // Store with selective persistence
      export const usePreferencesStore = defineStore('preferences', () => {
        const theme = ref<'light' | 'dark'>('light')
        const language = ref('en')
        const notifications = ref(true)
      
        return { theme, language, notifications }
      }, {
        persist: {
          paths: ['theme', 'language'],     // only persist these
          storage: localStorage,
          serializer: {
            deserialize: JSON.parse,
            serialize: JSON.stringify,
          },
        },
      })
      ```
      
      ### Custom plugin — logging
      
      ```ts
      // plugins/pinia-logger.ts
      import type { PiniaPluginContext } from 'pinia'
      
      export function PiniaLogger({ store }: PiniaPluginContext) {
        store.$onAction(({ name, args, after, onError }) => {
          console.group(`[Pinia] ${store.$id}.${name}`)
          console.log('args:', args)
      
          after((result) => {
            console.log('result:', result)
            console.groupEnd()
          })
      
          onError((error) => {
            console.error('error:', error)
            console.groupEnd()
          })
        })
      }
      ```
      
      ### Custom plugin — undo/redo
      
      ```ts
      // plugins/pinia-history.ts
      import { ref } from 'vue'
      import type { PiniaPluginContext } from 'pinia'
      
      export function PiniaHistory({ store }: PiniaPluginContext) {
        const history: string[] = []
        let historyIndex = -1
      
        // Snapshot state on every change
        store.$subscribe((mutation, state) => {
          // Drop future history on new action
          history.splice(historyIndex + 1)
          history.push(JSON.stringify(state))
          historyIndex = history.length - 1
        })
      
        store.undo = () => {
          if (historyIndex > 0) {
            historyIndex--
            store.$patch(JSON.parse(history[historyIndex]))
          }
        }
      
        store.redo = () => {
          if (historyIndex < history.length - 1) {
            historyIndex++
            store.$patch(JSON.parse(history[historyIndex]))
          }
        }
      }
      ```
      
      ---
      
      ## Pinia SSR — State Hydration
      
      ```ts
      // Nuxt: state is automatically serialized and hydrated via useNuxtApp().$pinia
      // For custom SSR with Vite/Express:
      
      // server.ts
      import { createPinia } from 'pinia'
      
      export async function render(url: string) {
        const pinia = createPinia()
        const app = createApp(App)
        app.use(pinia)
      
        await renderToString(app)
      
        // Serialize state to embed in HTML
        const state = JSON.stringify(pinia.state.value)
        return { state }
      }
      
      // client.ts
      import { createPinia } from 'pinia'
      
      const pinia = createPinia()
      
      // Hydrate from server-serialized state
      if (window.__INITIAL_STATE__) {
        pinia.state.value = JSON.parse(
          decodeURIComponent(atob(window.__INITIAL_STATE__))
        )
      }
      
      createApp(App).use(pinia).mount('#app')
      ```
      
      ---
      
      ## Pinia Store Subscriptions
      
      ```ts
      const store = useCartStore()
      
      // Subscribe to state changes
      const unsubscribe = store.$subscribe((mutation, state) => {
        // mutation.type: 'direct' | 'patch object' | 'patch function'
        // mutation.storeId: store id
        // mutation.payload: patch object (if type is 'patch object')
        console.log('state changed', state)
      })
      
      // Subscribe to actions
      store.$onAction(({ name, store, args, after, onError }) => {
        after((result) => { /* action succeeded */ })
        onError((error) => { /* action threw */ })
      })
      
      // Cleanup
      onUnmounted(unsubscribe)
      ```
      
      ---
      
      ## Vue Router — Full Configuration
      
      ```ts
      // router/index.ts
      import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
      import type { RouteRecordRaw } from 'vue-router'
      
      // TypeScript meta augmentation
      declare module 'vue-router' {
        interface RouteMeta {
          requiresAuth?: boolean
          roles?: string[]
          title?: string
          breadcrumb?: string
          transition?: string
          keepAlive?: boolean
        }
      }
      
      const routes: RouteRecordRaw[] = [
        {
          path: '/',
          name: 'home',
          component: () => import('@/views/HomeView.vue'),
          meta: { title: 'Home' },
        },
        {
          path: '/about',
          name: 'about',
          // Route-level code splitting — this route is lazy loaded
          component: () => import('@/views/AboutView.vue'),
        },
        {
          path: '/users/:id(\\d+)',      // only match numeric ids
          name: 'user',
          component: () => import('@/views/UserView.vue'),
          props: true,                    // route params passed as props
          meta: { requiresAuth: true, title: 'User Profile' },
        },
        {
          path: '/users/:id/settings',
          name: 'user-settings',
          component: () => import('@/views/UserSettingsView.vue'),
          props: (route) => ({ id: Number(route.params.id) }), // transform params
        },
        {
          path: '/blog/:slug?',           // optional param
          name: 'blog-post',
          component: () => import('@/views/BlogView.vue'),
        },
        {
          path: '/admin',
          redirect: '/admin/dashboard',
          component: () => import('@/layouts/AdminLayout.vue'),
          meta: { requiresAuth: true, roles: ['admin'] },
          children: [
            {
              path: 'dashboard',
              name: 'admin-dashboard',
              component: () => import('@/views/admin/DashboardView.vue'),
            },
            {
              path: 'users',
              name: 'admin-users',
              component: () => import('@/views/admin/UsersView.vue'),
              alias: '/users-admin',       // accessible at both paths
            },
          ],
        },
        {
          path: '/:pathMatch(.*)*',
          name: 'not-found',
          component: () => import('@/views/NotFoundView.vue'),
        },
      ]
      
      export const router = createRouter({
        history: createWebHistory(import.meta.env.BASE_URL),
        // history: createWebHashHistory() — for hash-based routing (#/path)
        routes,
        scrollBehavior(to, from, savedPosition) {
          if (savedPosition) {
            // Restore scroll position when using browser back/forward
            return savedPosition
          }
          if (to.hash) {
            return { el: to.hash, behavior: 'smooth', top: 80 }
          }
          // Scroll to top on navigation, but only if path changed
          if (to.path !== from.path) {
            return { top: 0 }
          }
        },
      })
      ```
      
      ---
      
      ## Navigation Guards
      
      ### Global guards — auth and title
      
      ```ts
      // router/guards.ts
      import { router } from './index'
      import { useAuthStore } from '@/stores/auth'
      
      router.beforeEach(async (to, from) => {
        // Set page title
        document.title = to.meta.title ? `${to.meta.title} — MyApp` : 'MyApp'
      
        const auth = useAuthStore()
      
        // Wait for auth to initialize (e.g., token check from localStorage)
        if (!auth.initialized) {
          await auth.initialize()
        }
      
        // Auth guard
        if (to.meta.requiresAuth && !auth.isLoggedIn) {
          return {
            name: 'login',
            query: { redirect: to.fullPath },
          }
        }
      
        // Role guard
        if (to.meta.roles?.length && !to.meta.roles.includes(auth.user?.role ?? '')) {
          return { name: 'forbidden' }
        }
      })
      
      router.afterEach((to, from, failure) => {
        if (!failure) {
          // Analytics, etc.
          trackPageView(to.fullPath)
        }
      })
      ```
      
      ### Per-route beforeEnter guard
      
      ```ts
      {
        path: '/checkout',
        name: 'checkout',
        component: () => import('@/views/CheckoutView.vue'),
        beforeEnter: [
          // Multiple guards as array — executed in order
          requireAuth,
          requireNonEmptyCart,
        ],
      }
      
      function requireAuth(to, from) {
        const auth = useAuthStore()
        if (!auth.isLoggedIn) return { name: 'login', query: { redirect: to.fullPath } }
      }
      
      function requireNonEmptyCart(to, from) {
        const cart = useCartStore()
        if (cart.items.length === 0) return { name: 'cart' }
      }
      ```
      
      ### In-component guards (Composition API)
      
      ```vue
      <script setup lang="ts">
      import {
        onBeforeRouteLeave,
        onBeforeRouteUpdate,
        useRoute,
        useRouter,
      } from 'vue-router'
      import { ref, watch } from 'vue'
      
      const route = useRoute()
      const router = useRouter()
      const isDirty = ref(false)
      
      // Guard: prevent navigating away with unsaved changes
      onBeforeRouteLeave((to, from) => {
        if (isDirty.value) {
          const confirmed = window.confirm('Leave without saving?')
          if (!confirmed) return false
        }
      })
      
      // Guard: refetch data when param changes (e.g., /users/1 → /users/2)
      onBeforeRouteUpdate(async (to, from) => {
        if (to.params.id !== from.params.id) {
          await fetchUser(to.params.id as string)
        }
      })
      
      // Alternative: watch route params reactively
      watch(() => route.params.id, async (newId) => {
        if (newId) await fetchUser(newId as string)
      }, { immediate: true })
      </script>
      ```
      
      ---
      
      ## Dynamic Routes
      
      ```ts
      // Programmatic navigation
      const router = useRouter()
      
      // Navigate to named route
      router.push({ name: 'user', params: { id: 42 } })
      
      // Navigate with query params
      router.push({ name: 'search', query: { q: 'vue', page: 2 } })
      
      // Replace current history entry (no back button)
      router.replace({ name: 'login' })
      
      // Navigate back/forward
      router.go(-1)
      router.back()
      router.forward()
      ```
      
      ```ts
      // Adding routes dynamically (e.g., from plugin or feature flag)
      const removeRoute = router.addRoute({
        path: '/feature-x',
        name: 'feature-x',
        component: () => import('@/views/FeatureX.vue'),
      })
      
      // Remove the route when feature is disabled
      removeRoute()
      ```
      
      ### useRoute — accessing route state
      
      ```vue
      <script setup lang="ts">
      import { useRoute } from 'vue-router'
      import { computed } from 'vue'
      
      const route = useRoute()
      
      // Params — always strings or arrays of strings
      const userId = computed(() => Number(route.params.id))
      
      // Query params
      const search = computed(() => route.query.q as string ?? '')
      const page = computed(() => Number(route.query.page ?? 1))
      
      // Route meta
      const pageTitle = computed(() => route.meta.title)
      
      // Full path and matched routes (breadcrumb data)
      const breadcrumbs = computed(() =>
        route.matched.map((r) => ({ name: r.name, label: r.meta.breadcrumb }))
      )
      </script>
      ```
      
      ---
      
      ## Route Transitions
      
      ### Per-route transition names
      
      ```vue
      <!-- App.vue -->
      <script setup lang="ts">
      import { useRoute } from 'vue-router'
      const route = useRoute()
      </script>
      
      <template>
        <RouterView v-slot="{ Component, route }">
          <Transition :name="route.meta.transition ?? 'fade'" mode="out-in">
            <component :is="Component" :key="route.path" />
          </Transition>
        </RouterView>
      </template>
      
      <style>
      .fade-enter-active,
      .fade-leave-active { transition: opacity 0.2s ease; }
      .fade-enter-from,
      .fade-leave-to { opacity: 0; }
      
      .slide-enter-active,
      .slide-leave-active { transition: transform 0.3s ease; }
      .slide-enter-from { transform: translateX(100%); }
      .slide-leave-to { transform: translateX(-100%); }
      </style>
      ```
      
      ```ts
      // Route definition with transition
      {
        path: '/users',
        component: () => import('@/views/UsersView.vue'),
        meta: { transition: 'slide' },
      }
      ```
      
      ### View Transitions API (Chrome 111+)
      
      ```ts
      router.beforeEach(() => {
        if (!document.startViewTransition) return
      
        return new Promise((resolve) => {
          document.startViewTransition(resolve)
        })
      })
      ```
      
      ---
      
      ## Lazy Loading
      
      ### Route-level code splitting
      
      ```ts
      // Each () => import() creates a separate chunk
      const routes = [
        { path: '/dashboard', component: () => import('@/views/Dashboard.vue') },
        { path: '/settings', component: () => import('@/views/Settings.vue') },
      ]
      ```
      
      ### defineAsyncComponent with loading and error states
      
      ```ts
      import { defineAsyncComponent } from 'vue'
      import Spinner from '@/components/Spinner.vue'
      import ErrorDisplay from '@/components/ErrorDisplay.vue'
      
      const AsyncHeavyChart = defineAsyncComponent({
        loader: () => import('@/components/HeavyChart.vue'),
        loadingComponent: Spinner,
        errorComponent: ErrorDisplay,
        delay: 200,           // show loading after 200ms (avoids flash)
        timeout: 10000,       // error if not loaded within 10s
        onError(error, retry, fail, attempts) {
          if (attempts <= 3) retry()  // retry up to 3 times
          else fail()
        },
      })
      ```
      
      ### Grouping chunks with magic comments
      
      ```ts
      // Vite: chunks are auto-split, but you can group with same chunk name
      const UserProfile = () => import(/* @vite-ignore */ '@/views/UserProfile.vue')
      
      // Prefetch on hover (manual)
      function prefetchDashboard() {
        import('@/views/Dashboard.vue')
      }
      ```
      
      ---
      
      ## Scroll Behavior Patterns
      
      ```ts
      scrollBehavior(to, from, savedPosition) {
        // 1. Browser back/forward → restore exact position
        if (savedPosition) return savedPosition
      
        // 2. Hash link → scroll to element
        if (to.hash) {
          return {
            el: to.hash,
            top: 80,              // offset for sticky header
            behavior: 'smooth',
          }
        }
      
        // 3. New page → scroll to top
        return { top: 0, left: 0 }
      }
      ```
      
      ### Async scroll (wait for transition)
      
      ```ts
      scrollBehavior(to, from, savedPosition) {
        return new Promise((resolve) => {
          // Wait for page transition to complete
          setTimeout(() => {
            resolve(savedPosition ?? { top: 0 })
          }, 300)
        })
      }
      ```
      
    • testing.md 20.8 KB
      # Testing Reference
      
      Vue 3 testing with Vitest, Vue Test Utils, Pinia, Vue Router, MSW, Playwright, and Nuxt test utils.
      
      ---
      
      ## Vitest Setup for Vue
      
      ### Installation
      
      ```bash
      npm install -D vitest @vue/test-utils happy-dom @vitest/coverage-v8
      # Or jsdom:
      npm install -D jsdom
      ```
      
      ### vitest.config.ts
      
      ```ts
      import { defineConfig } from 'vitest/config'
      import vue from '@vitejs/plugin-vue'
      import { fileURLToPath } from 'node:url'
      
      export default defineConfig({
        plugins: [vue()],
        test: {
          environment: 'happy-dom',  // or 'jsdom'
          globals: true,             // describe/it/expect without importing
          setupFiles: ['./tests/setup.ts'],
          coverage: {
            provider: 'v8',
            reporter: ['text', 'lcov', 'html'],
            thresholds: {
              lines: 80,
              branches: 75,
              functions: 80,
            },
            exclude: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'],
          },
        },
        resolve: {
          alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url)),
          },
        },
      })
      ```
      
      ### tests/setup.ts — global test setup
      
      ```ts
      import { config } from '@vue/test-utils'
      import { createTestingPinia } from '@pinia/testing'
      
      // Global component stubs
      config.global.stubs = {
        RouterLink: true,
        RouterView: true,
        Teleport: true,
      }
      
      // Suppress Vue warnings in tests (optional — often better to fix them)
      // config.global.config.warnHandler = () => null
      ```
      
      ### Auto-imports with unplugin-auto-import
      
      ```ts
      // vitest.config.ts — if using auto-imports in app
      import AutoImport from 'unplugin-auto-import/vite'
      
      export default defineConfig({
        plugins: [
          vue(),
          AutoImport({
            imports: ['vue', 'vue-router', 'pinia'],
            dts: true,
          }),
        ],
        test: { globals: true },
      })
      ```
      
      ---
      
      ## Vue Test Utils — Mounting
      
      ### mount vs shallowMount
      
      ```ts
      import { mount, shallowMount } from '@vue/test-utils'
      import UserCard from '@/components/UserCard.vue'
      
      // mount — renders the full component tree (children included)
      const wrapper = mount(UserCard, {
        props: { user: { id: 1, name: 'Alice' } },
      })
      
      // shallowMount — stubs child components (faster, more isolated)
      // WARNING: can hide integration bugs; prefer mount for most cases
      const wrapper = shallowMount(UserCard, {
        props: { user: { id: 1, name: 'Alice' } },
      })
      ```
      
      ### Mounting options
      
      ```ts
      const wrapper = mount(MyComponent, {
        props: {
          title: 'Hello',
          items: [1, 2, 3],
        },
        slots: {
          default: '<p>Default slot content</p>',
          header: '<h2>Header slot</h2>',
        },
        global: {
          plugins: [router, createTestingPinia()],
          stubs: {
            'FontAwesomeIcon': true,         // stub by name
            ChildComponent: { template: '<div class="child-stub" />' },
          },
          mocks: {
            $t: (key: string) => key,        // mock i18n
          },
          provide: {
            theme: ref('dark'),
          },
        },
        attachTo: document.body,             // needed for focus tests
      })
      ```
      
      ---
      
      ## Component Testing Patterns
      
      ### Rendering and querying the DOM
      
      ```ts
      import { mount } from '@vue/test-utils'
      import { describe, it, expect } from 'vitest'
      import UserList from '@/components/UserList.vue'
      
      const users = [
        { id: 1, name: 'Alice', role: 'admin' },
        { id: 2, name: 'Bob', role: 'user' },
      ]
      
      describe('UserList', () => {
        it('renders a list of users', () => {
          const wrapper = mount(UserList, { props: { users } })
      
          // Text content
          expect(wrapper.text()).toContain('Alice')
      
          // Element exists
          expect(wrapper.find('[data-testid="user-list"]').exists()).toBe(true)
      
          // Count elements
          expect(wrapper.findAll('.user-card')).toHaveLength(2)
      
          // Check attribute
          expect(wrapper.find('input').attributes('disabled')).toBeDefined()
      
          // Check CSS class
          expect(wrapper.find('.user-card').classes()).toContain('admin')
        })
      
        it('renders empty state when no users', () => {
          const wrapper = mount(UserList, { props: { users: [] } })
          expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
        })
      })
      ```
      
      ### User interactions
      
      ```ts
      import { mount, flushPromises } from '@vue/test-utils'
      import SearchInput from '@/components/SearchInput.vue'
      import { nextTick } from 'vue'
      
      describe('SearchInput', () => {
        it('emits search event when user types and submits', async () => {
          const wrapper = mount(SearchInput)
      
          // Fill input
          await wrapper.find('input').setValue('vue testing')
      
          // Click button
          await wrapper.find('button[type="submit"]').trigger('click')
      
          // Check emitted events
          expect(wrapper.emitted('search')).toBeTruthy()
          expect(wrapper.emitted('search')![0]).toEqual(['vue testing'])
        })
      
        it('clears input on escape key', async () => {
          const wrapper = mount(SearchInput)
          await wrapper.find('input').setValue('hello')
          await wrapper.find('input').trigger('keydown', { key: 'Escape' })
      
          expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
        })
      })
      ```
      
      ### Async behavior
      
      ```ts
      import { mount, flushPromises } from '@vue/test-utils'
      import { vi } from 'vitest'
      import PostList from '@/components/PostList.vue'
      
      describe('PostList', () => {
        it('shows loading then content after fetch', async () => {
          // Mock fetch
          vi.spyOn(global, 'fetch').mockResolvedValueOnce({
            ok: true,
            json: async () => [{ id: 1, title: 'Post One' }],
          } as Response)
      
          const wrapper = mount(PostList)
      
          // Initially shows loading
          expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
      
          // Wait for all promises to resolve
          await flushPromises()
      
          // Now shows content
          expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
          expect(wrapper.text()).toContain('Post One')
        })
      })
      ```
      
      ### Slot testing
      
      ```ts
      import { mount } from '@vue/test-utils'
      import Card from '@/components/Card.vue'
      
      describe('Card slots', () => {
        it('renders named slots', () => {
          const wrapper = mount(Card, {
            slots: {
              header: '<h2 data-testid="card-header">My Title</h2>',
              default: '<p>Card body</p>',
              footer: '<button>Action</button>',
            },
          })
      
          expect(wrapper.find('[data-testid="card-header"]').text()).toBe('My Title')
          expect(wrapper.find('p').text()).toBe('Card body')
        })
      
        it('renders scoped slot with data', () => {
          const wrapper = mount(DataTable, {
            props: { items: [{ id: 1, name: 'Alice' }] },
            slots: {
              row: `<template #row="{ item }">
                <span data-testid="row-name">{{ item.name }}</span>
              </template>`,
            },
          })
      
          expect(wrapper.find('[data-testid="row-name"]').text()).toBe('Alice')
        })
      })
      ```
      
      ---
      
      ## Testing Composables
      
      ### Simple composable test
      
      ```ts
      // tests/composables/useCounter.test.ts
      import { describe, it, expect } from 'vitest'
      import { useCounter } from '@/composables/useCounter'
      
      describe('useCounter', () => {
        it('starts at initial value', () => {
          const { count } = useCounter(5)
          expect(count.value).toBe(5)
        })
      
        it('increments and decrements', () => {
          const { count, increment, decrement } = useCounter(0)
          increment()
          increment()
          expect(count.value).toBe(2)
          decrement()
          expect(count.value).toBe(1)
        })
      
        it('resets to initial value', () => {
          const { count, increment, reset } = useCounter(10)
          increment()
          reset()
          expect(count.value).toBe(10)
        })
      })
      ```
      
      ### Composable requiring component context (lifecycle hooks)
      
      ```ts
      // tests/composables/useEventListener.test.ts
      import { describe, it, expect, vi } from 'vitest'
      import { defineComponent, ref } from 'vue'
      import { mount } from '@vue/test-utils'
      import { useEventListener } from '@/composables/useEventListener'
      
      describe('useEventListener', () => {
        it('adds and removes event listener with component lifecycle', async () => {
          const handler = vi.fn()
      
          // Wrap in a component to get lifecycle
          const TestComponent = defineComponent({
            setup() {
              useEventListener(window, 'resize', handler)
            },
            template: '<div />',
          })
      
          const wrapper = mount(TestComponent)
      
          // Trigger event
          window.dispatchEvent(new Event('resize'))
          expect(handler).toHaveBeenCalledTimes(1)
      
          // Unmount — listener should be removed
          wrapper.unmount()
          window.dispatchEvent(new Event('resize'))
          expect(handler).toHaveBeenCalledTimes(1) // still 1, not 2
        })
      })
      ```
      
      ### Composable with mocked fetch
      
      ```ts
      // tests/composables/useFetch.test.ts
      import { describe, it, expect, vi, beforeEach } from 'vitest'
      import { defineComponent, ref } from 'vue'
      import { mount, flushPromises } from '@vue/test-utils'
      import { useFetch } from '@/composables/useFetch'
      
      const mockData = { id: 1, name: 'Alice' }
      
      describe('useFetch', () => {
        beforeEach(() => {
          vi.spyOn(global, 'fetch').mockResolvedValue({
            ok: true,
            json: async () => mockData,
          } as Response)
        })
      
        it('fetches data and updates refs', async () => {
          const TestComponent = defineComponent({
            setup() {
              const { data, pending, error } = useFetch<typeof mockData>('/api/user')
              return { data, pending, error }
            },
            template: '<div />',
          })
      
          const wrapper = mount(TestComponent)
          expect(wrapper.vm.pending).toBe(true)
      
          await flushPromises()
          expect(wrapper.vm.pending).toBe(false)
          expect(wrapper.vm.data).toEqual(mockData)
          expect(wrapper.vm.error).toBeNull()
        })
      })
      ```
      
      ---
      
      ## Pinia Testing
      
      ### createTestingPinia — mock store
      
      ```ts
      import { mount } from '@vue/test-utils'
      import { createTestingPinia } from '@pinia/testing'
      import { vi } from 'vitest'
      import UserProfile from '@/components/UserProfile.vue'
      import { useUserStore } from '@/stores/user'
      
      describe('UserProfile', () => {
        it('displays user name from store', () => {
          const wrapper = mount(UserProfile, {
            global: {
              plugins: [
                createTestingPinia({
                  initialState: {
                    user: { currentUser: { id: 1, name: 'Alice', role: 'admin' } },
                  },
                }),
              ],
            },
          })
      
          expect(wrapper.text()).toContain('Alice')
        })
      
        it('calls logout action when button clicked', async () => {
          const wrapper = mount(UserProfile, {
            global: {
              plugins: [
                createTestingPinia({
                  createSpy: vi.fn,          // makes all actions spies
                }),
              ],
            },
          })
      
          const store = useUserStore()
          await wrapper.find('[data-testid="logout-btn"]').trigger('click')
          expect(store.logout).toHaveBeenCalledOnce()
        })
      
        it('can stub specific action', async () => {
          const wrapper = mount(UserProfile, {
            global: {
              plugins: [
                createTestingPinia({
                  createSpy: vi.fn,
                  stubActions: false,        // let real actions run
                }),
              ],
            },
          })
      
          const store = useUserStore()
          // Override specific action
          store.logout = vi.fn().mockResolvedValue(undefined)
        })
      })
      ```
      
      ### Testing store in isolation
      
      ```ts
      import { setActivePinia, createPinia } from 'pinia'
      import { beforeEach, describe, it, expect, vi } from 'vitest'
      import { useCartStore } from '@/stores/cart'
      
      describe('useCartStore', () => {
        beforeEach(() => {
          // Create a fresh pinia before each test
          setActivePinia(createPinia())
        })
      
        it('adds item to cart', () => {
          const cart = useCartStore()
          const product = { id: '1', name: 'Widget', price: 9.99 }
      
          cart.addItem(product)
          expect(cart.items).toHaveLength(1)
          expect(cart.total).toBe(9.99)
        })
      
        it('increments quantity for duplicate item', () => {
          const cart = useCartStore()
          const product = { id: '1', name: 'Widget', price: 9.99 }
      
          cart.addItem(product)
          cart.addItem(product)
      
          expect(cart.items).toHaveLength(1)
          expect(cart.items[0].quantity).toBe(2)
        })
      
        it('calls API when placing order', async () => {
          const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
            ok: true,
            json: async () => ({ orderId: 'abc123' }),
          } as Response)
      
          const cart = useCartStore()
          await cart.checkout()
      
          expect(fetchSpy).toHaveBeenCalledWith('/api/orders', expect.any(Object))
        })
      })
      ```
      
      ---
      
      ## Vue Router Testing
      
      ### Router mock for navigation testing
      
      ```ts
      import { mount, RouterLinkStub } from '@vue/test-utils'
      import { createRouter, createMemoryHistory } from 'vue-router'
      import NavBar from '@/components/NavBar.vue'
      
      describe('NavBar navigation', () => {
        it('has correct links', () => {
          const wrapper = mount(NavBar, {
            global: {
              stubs: { RouterLink: RouterLinkStub },
            },
          })
      
          const links = wrapper.findAllComponents(RouterLinkStub)
          expect(links.some((l) => l.props('to') === '/')).toBe(true)
          expect(links.some((l) => l.props('to') === '/about')).toBe(true)
        })
      
        it('navigates on click', async () => {
          const router = createRouter({
            history: createMemoryHistory(),
            routes: [
              { path: '/', component: { template: '<div>Home</div>' } },
              { path: '/about', component: { template: '<div>About</div>' } },
            ],
          })
      
          const wrapper = mount(NavBar, {
            global: { plugins: [router] },
          })
      
          await router.isReady()
          await wrapper.find('[data-testid="about-link"]').trigger('click')
          await router.isReady()
      
          expect(router.currentRoute.value.path).toBe('/about')
        })
      })
      ```
      
      ### Testing components that use useRoute/useRouter
      
      ```ts
      import { mount } from '@vue/test-utils'
      import { createRouter, createMemoryHistory } from 'vue-router'
      import UserView from '@/views/UserView.vue'
      
      describe('UserView', () => {
        it('loads user from route param', async () => {
          const router = createRouter({
            history: createMemoryHistory(),
            routes: [{ path: '/users/:id', component: UserView }],
          })
      
          await router.push('/users/42')
          await router.isReady()
      
          const wrapper = mount(UserView, {
            global: { plugins: [router] },
          })
      
          await flushPromises()
          // UserView reads route.params.id = '42'
          expect(wrapper.text()).toContain('User 42')
        })
      })
      ```
      
      ---
      
      ## API Mocking with MSW
      
      ### Setup
      
      ```bash
      npm install -D msw
      npx msw init public/
      ```
      
      ```ts
      // tests/mocks/handlers.ts
      import { http, HttpResponse } from 'msw'
      import type { User } from '@/types'
      
      export const handlers = [
        http.get('/api/users', () => {
          return HttpResponse.json<User[]>([
            { id: 1, name: 'Alice', email: 'alice@example.com' },
            { id: 2, name: 'Bob', email: 'bob@example.com' },
          ])
        }),
      
        http.get('/api/users/:id', ({ params }) => {
          const user = { id: Number(params.id), name: 'Alice', email: 'alice@example.com' }
          return HttpResponse.json(user)
        }),
      
        http.post('/api/users', async ({ request }) => {
          const body = await request.json() as Partial<User>
          return HttpResponse.json({ ...body, id: 999 }, { status: 201 })
        }),
      ]
      ```
      
      ```ts
      // tests/setup.ts — global MSW setup
      import { setupServer } from 'msw/node'
      import { handlers } from './mocks/handlers'
      
      const server = setupServer(...handlers)
      
      beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
      afterEach(() => server.resetHandlers())
      afterAll(() => server.close())
      ```
      
      ```ts
      // Override handlers per test
      import { http, HttpResponse } from 'msw'
      
      it('shows error when API fails', async () => {
        server.use(
          http.get('/api/users', () => {
            return HttpResponse.json({ message: 'Server Error' }, { status: 500 })
          })
        )
        // ... test error state
      })
      ```
      
      ---
      
      ## Snapshot Testing
      
      ```ts
      import { mount } from '@vue/test-utils'
      import { describe, it, expect } from 'vitest'
      import Button from '@/components/Button.vue'
      
      describe('Button', () => {
        it('matches snapshot', () => {
          const wrapper = mount(Button, {
            props: { variant: 'primary', size: 'md' },
            slots: { default: 'Click me' },
          })
      
          // HTML snapshot
          expect(wrapper.html()).toMatchSnapshot()
        })
      
        it('matches inline snapshot', () => {
          const wrapper = mount(Button, {
            props: { variant: 'danger' },
            slots: { default: 'Delete' },
          })
      
          expect(wrapper.html()).toMatchInlineSnapshot(`
            "<button class="btn btn-danger">Delete</button>"
          `)
        })
      })
      ```
      
      ---
      
      ## E2E with Playwright
      
      ### Setup
      
      ```bash
      npm install -D @playwright/test
      npx playwright install
      ```
      
      ### Page Object pattern for Vue apps
      
      ```ts
      // tests/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.getByTestId('login-error')
        }
      
        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()
        }
      }
      ```
      
      ```ts
      // tests/e2e/auth.spec.ts
      import { test, expect } from '@playwright/test'
      import { LoginPage } from './pages/LoginPage'
      
      test.describe('Authentication', () => {
        test('successful login redirects to dashboard', async ({ page }) => {
          const loginPage = new LoginPage(page)
          await loginPage.goto()
          await loginPage.login('alice@example.com', 'password123')
      
          await expect(page).toHaveURL('/dashboard')
          await expect(page.getByTestId('welcome-message')).toContainText('Alice')
        })
      
        test('invalid credentials shows error', async ({ page }) => {
          const loginPage = new LoginPage(page)
          await loginPage.goto()
          await loginPage.login('bad@example.com', 'wrong')
      
          await expect(loginPage.errorMessage).toBeVisible()
          await expect(loginPage.errorMessage).toContainText('Invalid credentials')
        })
      })
      ```
      
      ### playwright.config.ts
      
      ```ts
      import { defineConfig, devices } from '@playwright/test'
      
      export default defineConfig({
        testDir: './tests/e2e',
        fullyParallel: true,
        forbidOnly: !!process.env.CI,
        retries: process.env.CI ? 2 : 0,
        workers: process.env.CI ? 1 : undefined,
        reporter: 'html',
        use: {
          baseURL: 'http://localhost:5173',
          trace: 'on-first-retry',
        },
        projects: [
          { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
          { name: 'Mobile Safari', use: { ...devices['iPhone 13'] } },
        ],
        webServer: {
          command: 'npm run dev',
          url: 'http://localhost:5173',
          reuseExistingServer: !process.env.CI,
        },
      })
      ```
      
      ---
      
      ## Nuxt Testing
      
      ### Setup with @nuxt/test-utils
      
      ```bash
      npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom
      ```
      
      ```ts
      // vitest.config.ts for Nuxt
      import { defineVitestConfig } from '@nuxt/test-utils/config'
      
      export default defineVitestConfig({
        test: {
          environment: 'nuxt',    // uses Nuxt-aware environment
          environmentOptions: {
            nuxt: {
              rootDir: '.',
              overrides: {
                ssr: false,      // disable SSR for component tests
              },
            },
          },
        },
      })
      ```
      
      ### Testing Nuxt components with renderSuspended
      
      ```ts
      import { describe, it, expect } from 'vitest'
      import { renderSuspended } from '@nuxt/test-utils/runtime'
      import { screen } from '@testing-library/vue'
      import MyComponent from '@/components/MyComponent.vue'
      
      describe('MyComponent', () => {
        it('renders correctly', async () => {
          await renderSuspended(MyComponent, {
            props: { title: 'Hello Nuxt' },
          })
      
          expect(screen.getByText('Hello Nuxt')).toBeDefined()
        })
      })
      ```
      
      ### Testing Nuxt composables
      
      ```ts
      import { describe, it, expect } from 'vitest'
      import { mountSuspended } from '@nuxt/test-utils/runtime'
      import { defineComponent } from 'vue'
      
      describe('useMyNuxtComposable', () => {
        it('works with Nuxt context', async () => {
          const TestComponent = defineComponent({
            setup() {
              const state = useState('test', () => 'initial')
              return { state }
            },
            template: '<div>{{ state }}</div>',
          })
      
          const wrapper = await mountSuspended(TestComponent)
          expect(wrapper.text()).toBe('initial')
        })
      })
      ```
      
      ---
      
      ## Common Testing Pitfalls
      
      | Pitfall | Problem | Fix |
      |---------|---------|-----|
      | Not awaiting `nextTick` | DOM not updated after reactive change | `await nextTick()` or `await wrapper.vm.$nextTick()` after triggering updates |
      | Not awaiting `flushPromises` | Async operations still pending | `await flushPromises()` after triggering async actions |
      | Using `shallowMount` exclusively | Child component bugs hidden | Default to `mount()`, use `shallowMount` only for focused unit tests |
      | Testing implementation details | Brittle tests that break on refactor | Test behavior and output, not internal refs or methods |
      | Missing `setActivePinia` in store tests | Pinia has no active instance | Call `setActivePinia(createPinia())` in `beforeEach` |
      | Forgetting `router.isReady()` | Navigation not complete | Await `router.isReady()` after `router.push()` in tests |
      | Not cleaning up global mocks | Tests pollute each other | Use `afterEach(() => vi.restoreAllMocks())` or `vi.resetAllMocks()` |
      | Querying by text that changes | Brittle to copy changes | Use `data-testid` attributes or ARIA roles for stable selectors |
      
  • scripts
    • .gitkeep 0 B · in bundle
    • check-vue-facts.py 11 KB
      #!/usr/bin/env python3
      """Staleness verifier for vue-ops: the Vue 3 / Nuxt 3 facts the skill encodes
      must stay real and named in the prose.
      
      vue-ops centers on Vue 3 (defineModel in 3.4, defineOptions/defineSlots in 3.3,
      useTemplateRef in 3.5) and Nuxt 3, and names an ecosystem stack (Pinia, Vue
      Router, VueUse, Vitest, @vue/test-utils). That is exactly the fact that drifts
      silently (SKILL-RESOURCE-PROTOCOL.md §7): Vue or Nuxt ships a new major, or the
      prose stops mentioning a package the catalog lists, and nobody notices for
      months. Two modes guard it:
      
        --offline (default, safe for PR CI): structural consistency, no network.
          * assets/vue-facts.json parses and every package + version gate is named
            somewhere in the skill prose (SKILL.md / references/*.md) — the catalog
            can't drift from the docs
          * SKILL.md still carries a dated "as of <year>" currency note
        --live (scheduled freshness.yml, never a PR gate): query the npm registry for
          each package's latest dist-tag; flag DRIFT when the live major is newer than
          the documented major (the skill is now behind — e.g. Nuxt 4 while the prose
          still says "Nuxt 3"), or when a package is gone (404). Transient registry
          failure is UNAVAILABLE (exit 7), never a failure.
      
      Usage:   check-vue-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S]
      Input:   argv flags only (no stdin).
      Output:  stdout = findings (plain rows, or a --json envelope). Data only.
      Stderr:  the verdict line, notices, errors.
      Exit:    0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable,
               7 npm registry unreachable (live, advisory — never a real failure),
               10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone)
      
      Examples:
        check-vue-facts.py --offline                 # PR CI: catalog ⇆ prose consistency
        check-vue-facts.py --live                    # weekly: is any documented major behind npm?
        check-vue-facts.py --offline --json | jq '.data[]'
      """
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import sys
      import urllib.error
      import urllib.parse
      import urllib.request
      from pathlib import Path
      
      EX_OK = 0
      EX_USAGE = 2
      EX_NOTFOUND = 3
      EX_UNPARSEABLE = 4
      EX_UNAVAILABLE = 7
      EX_DRIFT = 10
      
      SCHEMA = "claude-mods.vue-ops.facts/v1"
      HERE = Path(__file__).resolve().parent
      DEFAULT_FACTS = HERE.parent / "assets" / "vue-facts.json"
      DEFAULT_SKILL = HERE.parent
      REGISTRY = "https://registry.npmjs.org"
      CURRENCY_RE = re.compile(r"as of 20\d\d")
      
      
      class Term:
          """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
          bound stream's TTY + encoding so piped data stays plain ASCII."""
      
          _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"}
      
          def __init__(self, stream=sys.stderr):
              enc = (getattr(stream, "encoding", "") or "").lower()
              self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
              if os.environ.get("FORCE_COLOR"):
                  self.color = True
              elif (os.environ.get("NO_COLOR") is not None
                    or os.environ.get("TERM") == "dumb"
                    or not getattr(stream, "isatty", lambda: False)()):
                  self.color = False
              else:
                  self.color = True
      
          def c(self, name, text):
              return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
      
          def mark(self, ok):
              g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
              return self.c("green" if ok else "red", g)
      
      
      def load_facts(path: Path) -> dict:
          if not path.is_file():
              print(f"error: facts catalog not found: {path}", file=sys.stderr)
              raise SystemExit(EX_NOTFOUND)
          try:
              data = json.loads(path.read_text(encoding="utf-8"))
              if data.get("schema") != SCHEMA:
                  raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}")
              if not isinstance(data.get("packages"), dict) or not data["packages"]:
                  raise ValueError("'packages' must be a non-empty object")
              for name, info in data["packages"].items():
                  if not isinstance(info, dict) or "documented_major" not in info:
                      raise ValueError(f"package {name!r} missing documented_major")
                  if not isinstance(info.get("prose"), list) or not info["prose"]:
                      raise ValueError(f"package {name!r} missing prose tokens")
              return data
          except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
              print(f"error: could not parse facts {path}: {exc}", file=sys.stderr)
              raise SystemExit(EX_UNPARSEABLE)
      
      
      def read_corpus(skill_dir: Path) -> tuple[str, str]:
          """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
          doc = skill_dir / "SKILL.md"
          if not doc.is_file():
              print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
              raise SystemExit(EX_NOTFOUND)
          skill_md = doc.read_text(encoding="utf-8", errors="replace")
          parts = [skill_md]
          for ref in sorted((skill_dir / "references").glob("*.md")):
              parts.append(ref.read_text(encoding="utf-8", errors="replace"))
          return skill_md, "\n".join(parts)
      
      
      def check_offline(facts: dict, skill_dir: Path) -> list[dict]:
          skill_md, corpus = read_corpus(skill_dir)
          findings: list[dict] = []
          for name, info in facts["packages"].items():
              for token in info["prose"]:
                  if token not in corpus:
                      findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"})
          for key, token in facts.get("version_gates", {}).items():
              if key == "_comment":
                  continue
              if str(token) not in corpus:
                  findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"})
          if not CURRENCY_RE.search(skill_md):
              findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"})
          return findings
      
      
      def npm_latest(name: str, timeout: float) -> tuple[str, object]:
          """Return (resolved|notfound|unavailable, version-string-or-status)."""
          url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest"
          req = urllib.request.Request(url, method="GET",
                                       headers={"User-Agent": "claude-mods-vue-ops-check/1",
                                                "Accept": "application/json"})
          try:
              with urllib.request.urlopen(req, timeout=timeout) as resp:
                  manifest = json.loads(resp.read().decode("utf-8"))
                  return ("resolved", manifest.get("version", ""))
          except urllib.error.HTTPError as exc:
              if exc.code in (404, 410):
                  return ("notfound", exc.code)
              return ("unavailable", exc.code)
          except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
              return ("unavailable", str(getattr(exc, "reason", exc)))
      
      
      def major_of(version: str) -> int | None:
          m = re.match(r"\d+", version.strip())
          return int(m.group(0)) if m else None
      
      
      def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]:
          drift: list[dict] = []
          unreachable: list[dict] = []
          for name, info in facts["packages"].items():
              documented = info["documented_major"]
              status, info2 = npm_latest(name, timeout)
              if status == "notfound":
                  drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"})
              elif status == "unavailable":
                  unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"})
              else:
                  live = major_of(str(info2))
                  if live is None:
                      unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"})
                  elif live > documented:
                      drift.append({"package": name,
                                    "issue": f"live major {live} ({info2}) ahead of documented major {documented}"})
          return drift, unreachable
      
      
      def main(argv: list[str]) -> int:
          p = argparse.ArgumentParser(
              prog="check-vue-facts.py",
              description="Verify vue-ops' Vue 3 / Nuxt 3 facts stay named (offline) and current on npm (live).",
          )
          mode = p.add_mutually_exclusive_group()
          mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
          mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented")
          p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON")
          p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
          p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
          p.add_argument("--json", action="store_true", help="emit a JSON envelope")
          try:
              args = p.parse_args(argv)
          except SystemExit as exc:
              return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
      
          facts = load_facts(Path(args.facts))
          live = args.live and not args.offline
          t = Term(sys.stderr)
      
          if live:
              drift, unreachable = check_live(facts, args.timeout)
              findings = drift + unreachable
              if args.json:
                  print(json.dumps({
                      "data": findings,
                      "meta": {"mode": "live", "packages_checked": len(facts["packages"]),
                               "drift": len(drift), "unreachable": len(unreachable),
                               "registry": REGISTRY, "schema": SCHEMA},
                  }, indent=2))
              else:
                  for f in findings:
                      kind = "DRIFT" if f in drift else "UNREACH"
                      print(f"{kind}  {f['package']}: {f['issue']}")
              if drift:
                  print(f"{t.mark(False)} vue-facts/live: {len(drift)} package(s) drifted "
                        f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr)
                  return EX_DRIFT
              if unreachable:
                  print(f"{t.mark(False)} vue-facts/live: npm unreachable for "
                        f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}",
                        file=sys.stderr)
                  return EX_UNAVAILABLE
              print(f"{t.mark(True)} vue-facts/live: all {len(facts['packages'])} package(s) "
                    f"at or below documented major", file=sys.stderr)
              return EX_OK
      
          # offline (default)
          findings = check_offline(facts, Path(args.skill))
          if args.json:
              print(json.dumps({
                  "data": findings,
                  "meta": {"mode": "offline", "packages_checked": len(facts["packages"]),
                           "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
              }, indent=2))
          else:
              for f in findings:
                  print(f"DRIFT  {f['package']}: {f['issue']}")
          ok = not findings
          print(f"{t.mark(ok)} vue-facts/offline: {len(facts['packages'])} package(s) + "
                f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, "
                f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr)
          return EX_DRIFT if findings else EX_OK
      
      
      if __name__ == "__main__":
          sys.exit(main(sys.argv[1:]))
      
  • tests
    • run.sh 3.5 KB
      #!/usr/bin/env bash
      # Offline self-test for the vue-ops skill — structure, frontmatter, and the
      # staleness-verifier contract (SKILL-RESOURCE-PROTOCOL.md §7, §10).
      #
      # Offline-deterministic (no network, no Vue install). Resolves paths relative
      # to itself so it works in the repo and once installed to ~/.claude/skills/.
      #
      # Usage:   bash tests/run.sh
      # Input:   none (self-contained; no network)
      # Output:  TAP-ish progress on stderr; final PASS/FAIL line.
      # Exit:    0 all pass, 1 any failure
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      DOC="$SKILL/SKILL.md"
      
      PASS=0; FAIL=0
      ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1" >&2; }
      no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1" >&2; }
      
      # Resolve a *working* python (python3, else python). The bare `command -v` is
      # not enough on Windows, where `python3` is a Microsoft Store stub that exits
      # nonzero. Skip the whole verifier block if none works.
      PY=""
      for c in python3 python py; do
        if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi
      done
      
      echo "=== vue-ops self-test ===" >&2
      
      # ── SKILL.md frontmatter ───────────────────────────────────────────────────
      [[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ===" >&2; exit 1; }
      doc="$(cat "$DOC")"
      case "$doc" in *"name: vue-ops"*) ok "frontmatter declares name: vue-ops";; *) no "frontmatter name != vue-ops";; esac
      case "$doc" in *"license: MIT"*) ok "frontmatter declares license: MIT";; *) no "missing license: MIT";; esac
      case "$doc" in *"as of 20"*) ok "currency note carries a year";; *) no "no dated 'as of <year>' currency note";; esac
      
      # ── resources present + cited ──────────────────────────────────────────────
      for res in assets/vue-facts.json scripts/check-vue-facts.py; do
        [[ -f "$SKILL/$res" ]] && ok "resource present: $res" || no "missing resource: $res"
      done
      case "$doc" in *"scripts/check-vue-facts.py"*) ok "verifier cited from SKILL.md";; *) no "verifier uncited";; esac
      
      # ── staleness verifier: offline contract (§7) ───────────────────────────────
      if [[ -n "$PY" ]]; then
        V="$SKILL/scripts/check-vue-facts.py"
        ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
               [[ "$got" == "$want" ]] && ok "$lbl (exit $got)" || no "$lbl (want $want got $got)"; }
        TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
        ec 0 "py_compile"            "$PY" -m py_compile "$V"
        ec 0 "--help"                "$PY" "$V" --help
        ec 0 "--offline consistent"  "$PY" "$V" --offline
        ec 2 "bad flag -> 2"         "$PY" "$V" --bogus
        ec 2 "conflicting modes -> 2" "$PY" "$V" --offline --live
        jout="$("$PY" "$V" --offline --json 2>/dev/null)"
        case "$jout" in *"claude-mods.vue-ops.facts/v1"*) ok "--json envelope schema";; *) no "--json envelope schema missing";; esac
        ec 3 "missing facts -> 3"    "$PY" "$V" --offline --facts "$TMP/nope.json"
        printf '{"schema":"claude-mods.vue-ops.facts/v1","packages":{"zzz":{"documented_major":1,"prose":["zzznotreal"]}}}' > "$TMP/drift.json"
        ec 10 "uncited package -> 10" "$PY" "$V" --offline --facts "$TMP/drift.json"
      else
        no "no working python to exercise the verifier"
      fi
      
      echo "=== $PASS passed, $FAIL failed ===" >&2
      [[ "$FAIL" -eq 0 ]] || exit 1
      
  • SKILL.md 15.3 KB
    ---
    name: vue-ops
    description: "Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: typescript-ops, testing-ops, tailwind-ops, javascript-ops
    ---
    
    # Vue Operations
    
    Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 4, and testing — production patterns with TypeScript throughout.
    
    > Vue 3 / Nuxt 4 ecosystem facts verified as of 2026-07-05.
    
    ---
    
    ## Reactivity Decision Tree
    
    ```
    What data do I need to make reactive?
    │
    ├─ A single primitive (string, number, boolean)?
    │   └─ ref()
    │       const count = ref(0)
    │       const name = ref('')
    │
    ├─ A plain object or array with deep reactivity?
    │   ├─ Will I destructure it or pass properties individually?
    │   │   └─ reactive() — but use toRefs() when destructuring
    │   └─ Will I replace the whole object at once?
    │       └─ ref() — ref.value = newObject
    │
    ├─ Derived/computed state from other reactive sources?
    │   └─ computed()
    │       const doubled = computed(() => count.value * 2)
    │
    ├─ A large object where only top-level keys change?
    │   └─ shallowRef() or shallowReactive()
    │       const state = shallowRef({ nested: { big: 'data' } })
    │
    ├─ Side effects that should run when dependencies change?
    │   ├─ Don't need to know old value, auto-tracks dependencies?
    │   │   └─ watchEffect(() => { ... })
    │   └─ Need old/new values, explicit sources, or lazy execution?
    │       └─ watch(source, (newVal, oldVal) => { ... })
    │
    └─ Data that should NOT be reactive (raw DOM, third-party instances)?
        └─ markRaw(obj) or shallowRef(obj)
    ```
    
    ---
    
    ## Component Communication Decision Tree
    
    ```
    How far does data need to travel?
    │
    ├─ Parent → direct child?
    │   └─ props (defineProps)
    │       Direct, explicit, type-safe
    │
    ├─ Child → parent (user action / data update)?
    │   └─ emit (defineEmits)
    │       defineEmits<{ change: [value: string] }>()
    │
    ├─ Parent ↔ child bidirectional binding?
    │   └─ v-model via defineModel() (Vue 3.4+)
    │       const model = defineModel<string>()
    │
    ├─ Ancestor → deep descendant (prop drilling problem)?
    │   └─ provide / inject
    │       Use InjectionKey<T> for type safety
    │
    ├─ Siblings or unrelated components?
    │   ├─ Simple/few shared values?
    │   │   └─ provide / inject from a common ancestor
    │   └─ Complex shared state or cross-tree communication?
    │       └─ Pinia store
    │
    ├─ Truly global state (user session, cart, preferences)?
    │   └─ Pinia store
    │       defineStore with setup syntax
    │
    └─ One-time events between distant components (rare)?
        └─ Pinia action + watch, or mitt event bus
            Avoid: Vue removed $emit on root in Vue 3
    ```
    
    ---
    
    ## Composition API Quick Reference
    
    ### `<script setup>` — the standard
    
    ```vue
    <script setup lang="ts">
    import { ref, computed, watch, onMounted } from 'vue'
    
    // Props — with TypeScript generics (no runtime declaration needed)
    const props = defineProps<{
      title: string
      count?: number
    }>()
    
    // Props with defaults
    const props = withDefaults(defineProps<{
      size: 'sm' | 'md' | 'lg'
      disabled?: boolean
    }>(), {
      size: 'md',
      disabled: false,
    })
    
    // Emits — type-safe event signatures
    const emit = defineEmits<{
      change: [value: string]        // named tuple syntax (Vue 3.3+)
      update: [id: number, data: object]
      close: []
    }>()
    
    // Reactive state
    const count = ref(0)
    const user = reactive({ name: '', email: '' })
    
    // Computed
    const doubled = computed(() => count.value * 2)
    
    // Watch
    watch(count, (newVal, oldVal) => {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    })
    
    // Lifecycle
    onMounted(() => {
      console.log('component mounted')
    })
    </script>
    ```
    
    ### `defineModel` — v-model binding (Vue 3.4+)
    
    ```vue
    <!-- Child component: MyInput.vue -->
    <script setup lang="ts">
    const model = defineModel<string>({ required: true })
    
    // Named v-model: <MyInput v-model:title="..." />
    const title = defineModel<string>('title')
    
    // With modifiers
    const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
    </script>
    
    <template>
      <input :value="model" @input="model = $event.target.value" />
    </template>
    ```
    
    ### `defineExpose` — expose to parent refs
    
    ```vue
    <script setup lang="ts">
    const inputRef = ref<HTMLInputElement | null>(null)
    
    function focus() {
      inputRef.value?.focus()
    }
    
    // Expose public API for parent template refs
    defineExpose({ focus })
    </script>
    ```
    
    ### `defineOptions` — component meta (Vue 3.3+)
    
    ```vue
    <script setup lang="ts">
    defineOptions({
      name: 'MyComponent',
      inheritAttrs: false,
    })
    </script>
    ```
    
    ### `defineSlots` — type slots (Vue 3.3+)
    
    ```vue
    <script setup lang="ts">
    defineSlots<{
      default(props: { item: User }): any
      header(props: {}): any
    }>()
    </script>
    ```
    
    ---
    
    ## Pinia Quick Start
    
    ### Setup syntax (recommended — composable style)
    
    ```ts
    // stores/counter.ts
    import { defineStore } from 'pinia'
    import { ref, computed } from 'vue'
    
    export const useCounterStore = defineStore('counter', () => {
      // state
      const count = ref(0)
      const name = ref('Counter')
    
      // getters
      const doubled = computed(() => count.value * 2)
    
      // actions
      function increment() {
        count.value++
      }
    
      async function fetchData() {
        const data = await api.get('/data')
        count.value = data.total
      }
    
      return { count, name, doubled, increment, fetchData }
    })
    ```
    
    ### Options syntax
    
    ```ts
    export const useCounterStore = defineStore('counter', {
      state: () => ({ count: 0 }),
      getters: {
        doubled: (state) => state.count * 2,
      },
      actions: {
        increment() { this.count++ },
      },
    })
    ```
    
    ### Using stores in components
    
    ```vue
    <script setup lang="ts">
    import { storeToRefs } from 'pinia'
    import { useCounterStore } from '@/stores/counter'
    
    const store = useCounterStore()
    
    // storeToRefs preserves reactivity when destructuring state/getters
    // Actions can be destructured directly (they're not reactive)
    const { count, doubled } = storeToRefs(store)
    const { increment } = store
    </script>
    ```
    
    ### Pinia plugins — persistence example
    
    ```ts
    // main.ts
    import { createPinia } from 'pinia'
    import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
    
    const pinia = createPinia()
    pinia.use(piniaPluginPersistedstate)
    
    // In store:
    export const useAuthStore = defineStore('auth', () => { ... }, {
      persist: true, // or { storage: sessionStorage, paths: ['token'] }
    })
    ```
    
    ---
    
    ## Vue Router Quick Reference
    
    ### Basic configuration
    
    ```ts
    // router/index.ts
    import { createRouter, createWebHistory } from 'vue-router'
    
    const router = createRouter({
      history: createWebHistory(import.meta.env.BASE_URL),
      routes: [
        {
          path: '/',
          name: 'home',
          component: () => import('@/views/HomeView.vue'), // lazy load
        },
        {
          path: '/users/:id',
          name: 'user',
          component: () => import('@/views/UserView.vue'),
          props: true,                    // passes :id as prop
          meta: { requiresAuth: true },
        },
        {
          path: '/admin',
          component: () => import('@/layouts/AdminLayout.vue'),
          children: [
            { path: '', component: () => import('@/views/admin/Dashboard.vue') },
            { path: 'users', component: () => import('@/views/admin/Users.vue') },
          ],
        },
        { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
      ],
      scrollBehavior(to, from, savedPosition) {
        if (savedPosition) return savedPosition
        if (to.hash) return { el: to.hash, behavior: 'smooth' }
        return { top: 0 }
      },
    })
    
    export default router
    ```
    
    ### Navigation guards
    
    ```ts
    // Global guard — auth check
    router.beforeEach((to, from) => {
      const auth = useAuthStore()
      if (to.meta.requiresAuth && !auth.isLoggedIn) {
        return { name: 'login', query: { redirect: to.fullPath } }
      }
    })
    
    // Per-route guard
    {
      path: '/admin',
      beforeEnter: (to, from) => {
        if (!isAdmin()) return { name: 'forbidden' }
      },
    }
    ```
    
    ```vue
    <!-- In-component guard -->
    <script setup lang="ts">
    import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
    
    onBeforeRouteLeave((to, from) => {
      if (hasUnsavedChanges.value) {
        return confirm('Leave without saving?')
      }
    })
    </script>
    ```
    
    ### TypeScript meta typing
    
    ```ts
    // router/index.ts — augment RouteMeta
    declare module 'vue-router' {
      interface RouteMeta {
        requiresAuth?: boolean
        title?: string
        breadcrumb?: string
      }
    }
    ```
    
    ---
    
    ## Nuxt 4 Decision Tree
    
    Nuxt 4's flagship change over Nuxt 3 is the `app/` source directory (app code separated
    from `server/` and root config — see [./references/nuxt.md](./references/nuxt.md)); the
    rendering strategies below are unchanged.
    
    ```
    What rendering strategy does my app need?
    │
    ├─ Public content (blogs, marketing, docs)?
    │   ├─ Content rarely changes (< daily)?
    │   │   └─ SSG — prerender: { routes: ['/', '/about'] }
    │   └─ Content updated frequently?
    │       └─ ISR — routeRules: { '/blog/**': { isr: 3600 } }
    │
    ├─ Dynamic per-user content (dashboards, apps)?
    │   └─ SSR — ssr: true (Nuxt default)
    │       Best for SEO + authenticated data
    │
    ├─ Admin panel / internal tool (no SEO needed)?
    │   └─ SPA — ssr: false in nuxt.config.ts
    │
    ├─ Mixed needs (marketing pages + app)?
    │   └─ Hybrid — routeRules per path
    │       routeRules: {
    │         '/': { prerender: true },
    │         '/blog/**': { isr: 3600 },
    │         '/app/**': { ssr: true },
    │         '/admin/**': { ssr: false },
    │       }
    │
    └─ Deploying to...
        ├─ Cloudflare Workers/Pages → preset: 'cloudflare'
        ├─ Vercel → preset: 'vercel' (auto-detected)
        ├─ Netlify → preset: 'netlify' (auto-detected)
        └─ Node.js server → preset: 'node-server'
    ```
    
    ---
    
    ## Rendering Performance Quick Wins
    
    | Technique | When to Use |
    |-----------|-------------|
    | `v-memo="[dep1, dep2]"` | Skip re-rendering a subtree (usually a `v-for` row) unless listed deps changed — only for measured hot lists |
    | `<KeepAlive>` | Cache component instances across tab/route switches; pair with `onActivated`/`onDeactivated` for refresh logic |
    | Virtual scrolling | Lists with hundreds+ of rows — `vue-virtual-scroller` or `@tanstack/vue-virtual` render only visible items |
    | `shallowRef` / `markRaw` | Large objects or third-party instances that don't need deep reactivity (see Reactivity Decision Tree) |
    
    ```vue
    <!-- v-memo: row re-renders only when item.id or selection state changes -->
    <div
      v-for="item in list"
      :key="item.id"
      v-memo="[item.id, item.id === selectedId]"
    >
      {{ item.name }} — {{ item.id === selectedId ? 'selected' : '' }}
    </div>
    ```
    
    **Tip:** before writing a composable, check [VueUse](https://vueuse.org/) — 200+ battle-tested composables (`useLocalStorage`, `useIntersectionObserver`, `useDark`, ...) that handle SSR and cleanup edge cases.
    
    ## Common Gotchas
    
    | Gotcha | Why | Fix |
    |--------|-----|-----|
    | Reactivity lost after destructuring `reactive()` | Destructuring extracts plain values, not refs | Use `toRefs(state)` when destructuring, or use `ref()` instead of `reactive()` |
    | `ref.value` needed in `<script>`, not in `<template>` | Template auto-unwraps top-level refs | Access as `count` in template, `count.value` in script |
    | `watch` doesn't fire on nested object changes | Default is shallow watch | Add `{ deep: true }` or watch a specific nested path `() => obj.nested.prop` |
    | Async setup breaks SSR in Nuxt | `await` in `setup()` suspends the component | Use `useAsyncData` or `useFetch` — never raw `await fetch()` in Nuxt setup |
    | `watchEffect` runs immediately and tracks lazily | Tracks dependencies at runtime, not statically | Use `watch` with explicit sources when you need control over what's tracked |
    | Template refs are `null` before mount | `ref()` is null until component is mounted | Access template refs inside `onMounted` or use `watch` with `{ immediate: false }` |
    | Pinia store state lost when destructuring | State properties are not reactive when pulled out directly | Always use `storeToRefs(store)` for state/getters; destructure actions directly |
    | Props are readonly — mutating causes warning | Vue enforces one-way data flow | Emit event to parent and let parent update; or use `defineModel()` for two-way binding |
    | `computed` setter not called on direct assignment | Computed with no setter is read-only by default | Define `get` and `set`: `computed({ get: () => ..., set: (v) => ... })` |
    | `v-model` on component uses wrong prop/event name | Default v-model uses `modelValue` prop and `update:modelValue` event | Use `defineModel()` (Vue 3.4+) or manually wire `modelValue` prop + `update:modelValue` emit |
    | `provide` value is not reactive | Providing a raw value instead of a ref | Provide `ref()` or `reactive()` so injectors see updates: `provide('key', ref(value))` |
    | `defineAsyncComponent` error not caught | Async component rejects without error boundary | Add `errorComponent` option or wrap in `<Suspense>` with error slot |
    
    ---
    
    ## Reference Files
    
    | File | When to Load |
    |------|-------------|
    | [./references/composition-api.md](./references/composition-api.md) | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns |
    | [./references/state-routing.md](./references/state-routing.md) | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) |
    | [./references/nuxt.md](./references/nuxt.md) | Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content |
    | [./references/testing.md](./references/testing.md) | Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils |
    
    ## Staleness Verifier
    
    This skill encodes fast-moving facts (the Vue 3 minor-version gates, the Nuxt 4
    meta-framework, the ecosystem package stack). [`scripts/check-vue-facts.py`](scripts/check-vue-facts.py)
    guards them against silent drift — internal consistency in PR CI, live
    major-version drift in the scheduled freshness job:
    
    ```bash
    # Structural (PR CI, no network): every catalogued package + Vue version gate is
    # still named in this skill's prose, and the currency note still carries a year.
    python3 skills/vue-ops/scripts/check-vue-facts.py --offline        # exit 0 consistent, 10 drift
    
    # Live (weekly freshness job, never blocks a PR): is any documented major
    # now behind npm's latest dist-tag? (e.g. Nuxt 5 while the prose says Nuxt 4.)
    python3 skills/vue-ops/scripts/check-vue-facts.py --live           # exit 10 a major moved ahead, 7 npm unreachable
    ```
    
    The canonical fact list lives in [`assets/vue-facts.json`](assets/vue-facts.json); when you add or drop a recommendation or the prose stops naming one, update it to match or `--offline` fails CI.
    
    ---
    
    ## See Also
    
    - **typescript-ops** — TypeScript generics, utility types, strict mode configuration
    - **testing-ops** — General testing patterns, TDD, mocking strategies, CI integration
    - **tailwind-ops** — Tailwind CSS with Vue component patterns, dark mode, responsive design
    - **javascript-ops** — Modern JS patterns used alongside Vue (async/await, modules, iterators)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related