Unplugin to auto-declare composables in Vue <script setup>
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>
pnpm add -D unplugin-auto-declare unplugin-auto-import
// 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] })
],
};
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 globalambients are project-wide — TypeScript can’t scope them to<script setup>. Referencing them elsewhere typechecks but fails at runtime.
A matcher describes which identifier(s) to look for and how to inject the composable. kind defaults to 'direct'.
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>
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.
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:
obj.$t{ $t: 'literal' }function fn ($t) {}, const { $t } = …, import { $t } … — treated as already-declaredThese look like template globals but aren’t — the composable is invoked in each component’s <script setup>, same as a hand-written call. So:
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.onUnmounted, watchers, and effect scopes attach to the calling component.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.
<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.$ to match Vue’s template-global convention ($t, $route, $attrs). Makes it obvious the binding wasn’t declared locally.MIT