unplugin-auto-declare

Unplugin to auto-declare composables in Vue <script setup>

1
0
1
TypeScript
public


unplugin-auto-declare

Auto-declare composable bindings in Vue <script setup> so you can use template-style globals for commonly used composables.

This plugin transforms code; it doesn’t import composables. Pair it with unplugin-auto-import to resolve composables.


without:

<script setup>
  const { t: $t } = useI18n();
  const greeting = computed(() => $t("hello"));
</script>

with:

<script setup>
  const greeting = computed(() => $t("hello"));
</script>

Install

pnpm add -D unplugin-auto-declare unplugin-auto-import

Usage

// vite.config.ts
import AutoImport from "unplugin-auto-import/vite";
import AutoDeclare from "unplugin-auto-declare/vite";
import { vueI18n } from "unplugin-auto-declare/presets";

export default {
  plugins: [
    AutoImport({ imports: ["vue-i18n"] }),
    AutoDeclare({ matchers: [vueI18n] })
  ],
};

Types

The bundled vue-i18n preset ships ambient types:

// tsconfig.json
{ "compilerOptions": { "types": ["unplugin-auto-declare/vue-i18n"] } }

For custom matchers, hand-roll a .d.ts:

import type { Store } from "pinia";
declare global {
  const $store: Store;
}
export {};

Caveat: declare global ambients are project-wide — TypeScript can’t scope them to <script setup>. Referencing them elsewhere typechecks but fails at runtime.

Custom matchers

A matcher describes which identifier(s) to look for and how to inject the composable. kind defaults to 'direct'.

Direct matcher (default)

One composable, one identifier:

AutoDeclare({
  matchers: [
    { identifier: "$config", composable: "useRuntimeConfig" },
  ],
});

becomes:

<script setup>
  // const $config = useRuntimeConfig(); — injected by the plugin
  const url = $config.public.apiBase;
</script>

Destructure matcher

One composable, several named identifiers, emitted as a single destructure. kind: 'destructure' is required.

AutoDeclare({
  matchers: [
    {
      kind: "destructure",
      identifiers: ["$t", "$n"],
      composable: "useI18n",
      mapping: { $t: "t: $t", $n: "n: $n" },
    },
  ],
});

becomes:

<script setup>
  // const { t: $t, n: $n } = useI18n(); — injected by the plugin
  const greeting = computed(() => $t("hello"));
  const count = computed(() => $n(42));
</script>

mapping[id] is the destructure entry (defaults to id itself). The bundled vue-i18n preset is a pre-filled destructure matcher. Mix direct and destructure matchers in the same array; one declaration line per matcher with hits, in matcher-list order.

Matching rules

The plugin matches any reference to a tracked identifier in <script setup> — calls ($t('hello')), property access ($config.public.foo), or shorthand props ({ $t }). It skips:

  • member-access labels: obj.$t
  • object literal keys: { $t: 'literal' }
  • bindings: function fn ($t) {}, const { $t } = …, import { $t } … — treated as already-declared

Behavior

Component-scoped, not global

These look like template globals but aren’t — the composable is invoked in each component’s <script setup>, same as a hand-written call. So:

  • Setup-only. Subject to the same rules as calling the composable manually. Don’t add a matcher for something that can’t run in setup.
  • Per-component instantiation. For composables that share state internally (useI18n, Pinia stores, useRuntimeConfig) the cost is one extra call per component returning a shared singleton. Composables that create per-call state give every component its own copy — use this for things you’d already call in setup, not to fake an app-wide singleton.
  • Lifecycle is per-component. onUnmounted, watchers, and effect scopes attach to the calling component.

Multiple instances

Safe to register more than once (e.g. a Nuxt module + a user project). Disjoint matcher sets stay independent; overlapping ones dedupe — the first instance injects, the second sees the binding as already-declared. Order doesn’t matter. Each instance parses + walks files containing tracked identifiers once; merge matchers into one registration if you’re stacking many.

Recommendations

  • Keep the matcher list small. Each identifier is effectively a global in <script setup>; cost compounds across every component a contributor reads. Reach for genuinely ambient bindings (i18n helpers, the active store, a logger); use ordinary imports for everything else.
  • Prefix identifiers with $ to match Vue’s template-global convention ($t, $route, $attrs). Makes it obvious the binding wasn’t declared locally.

License

MIT

v0.3.3[beta]