Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 04:31
· 46 commits to main since this release
0d2813f

Released 0.6.0 across the coordinated @comvi/* release train.

The GitHub Release asset comvi-v0.6.0.tar.gz is the single archive for this release. It contains the packed npm tarballs plus manifest.json and SHA256SUMS.

Comvi i18n 0.6.0 changes how the in-context editor reaches a page: the Comvi browser extension no longer carries it, and devtools() loads it when the extension asks. The release also formats # in plurals for the locale and fixes the editor. Twelve packages ship at one version.

Before you upgrade

  • This is a minor release, 0.6.0, not a patch. Three changes can need action in an app: Next.js apps compose discovery themselves now, # in a plural prints a formatted number, and a flat catalog you pass is kept by reference instead of copied. Each is described below.
  • Next.js: add .use(devtools()). createNextI18n no longer attaches browser-extension discovery, so without the call the extension does not see the app.
  • A site with a Content Security Policy: allow https://cdn.jsdelivr.net in script-src if translators use the extension on it. style-src needs no change in current browsers.
  • # in a plural prints a formatted number. Write {count} where you want the raw digits.
  • Generated types: regenerate once. A plain {name} is now typed string | number, so if your generated file has a plain or select parameter, it differs from fresh output and comvi typegen --check fails in CI until you run comvi typegen again. comviTypes() in a Vite app rewrites it on the next vite dev or vite build.
  • Upgrade the site before the extension. Extension 0.6.0 loads the editor only onto a site on @comvi/core 0.6.0 or newer; on any version it activates an editor the site bundles itself. Both paths need an instance the extension can discover, which is what devtools() publishes. The 0.5.0 extension keeps working with a 0.6.0 site.

devtools() loads the in-context editor

  • What it does. .use(devtools()) still publishes the i18n instance for the Comvi browser extension. It now also installs the loader the extension calls. When a translator presses Enable editor, the extension calls the loader, which adds one <script> for https://cdn.jsdelivr.net/npm/@comvi/plugin-in-context-editor@latest/dist/standalone.iife.js. In the normal flow the loader runs only when the extension asks. The loader slot is a page global, so any script on the page could call it too; that grants nothing a page script cannot already do, since it can add the same <script> itself. The slot and its cached load promise stay on the page after destroy().
  • Pin the editor with devtools({ editorVersion: "0.6.0" }). It takes an exact version: SemVer major.minor.patch with no leading zeros, optionally with a prerelease such as 0.6.0-rc.1; build metadata is not accepted. "latest" is accepted too, and is what the option left out gives you. Any other value fails closed: nothing is loaded, the loader's load() answers the failure code invalid-editor-version, the extension's popup says the pin must be an exact version like 1.2.3, and development builds warn. A rejected pin never falls back to latest. The first devtools() on a page installs the loader and later instances reuse it, so a pin, valid or not, on a second instance does not change what the first one installed (development builds warn when the pins differ). On latest the URL carries a query that changes every hour, which keeps a browser's own copy at most an hour old. The CDN is a separate cache: unless the purge the release requests goes through, jsDelivr's edge can serve the previous latest for 12 hours after a release by the cache header it sends today, and jsDelivr documents up to 7 days for a version alias.
  • EditorLoadResult, what the loader's load() resolves to, declares editorVersion, code and host as always present, holding undefined where they do not apply.
  • Size. devtools() costs about 1.2 kB min+gzip over the base i18n instance: @comvi/core alone measures 5,607 bytes, and 6,870 with .use(devtools()), measured on the 0.6.0 build with pnpm size (the core-base and core-base-devtools rows). About 0.7 kB of that is the editor loader, new in 0.6.0; the rest is the discovery devtools() already did in 0.5.0. An app on the ESM packages that does not call devtools() carries none of it; the <script> global build composes it on its own, see Behaviour changes.
  • Content Security Policy. Add https://cdn.jsdelivr.net to script-src. A site that enforces Trusted Types needs two more things: a default policy whose createScriptURL accepts that script URL, because the loader assigns script.src as a plain string, and vue in its trusted-types directive, the policy the editor's Vue runtime creates for the HTML it renders. style-src needs no change in current browsers, because the editor adopts a constructable stylesheet.
  • An app that bundles the standalone editor itself is activated as it is, and nothing is loaded.

Behaviour changes

  • @comvi/next: createNextI18n no longer composes discovery. Until 0.6.0 it published every Next app on window.__COMVI__ and gave it an instanceId. Now the app is not visible to the extension, and i18n.instanceId is undefined, until you compose discovery as in every other binding:

    -import { createNextI18n } from "@comvi/next";
    +import { createNextI18n, devtools } from "@comvi/next";
    
    -export const { i18n, routing } = createNextI18n({ ...routing });
    +export const { i18n, routing } = createNextI18n({ ...routing }).use(devtools());

    devtools is also exported from @comvi/next/client and @comvi/next/server. On the server it publishes nothing. instanceId and exposeGlobal go to the installer: devtools({ instanceId: "app" }).

  • # in plural and selectordinal is formatted for the message's locale, as ICU MessageFormat defines it. {count, plural, other {# items}} renders 1,234 items in en and 1.234 items in de; 0.5.0 inserted the raw digits. # follows the Intl.NumberFormat defaults, so a fractional count is rounded to three fraction digits (1.23456 renders 1.235). Exact selectors (=0, =1) still compare the number itself, a count of negative zero renders 0 without a sign, and {count} is unchanged.

  • icuCompiler no longer reads an argument whose only comma sits inside a nested pair of braces as a parameter. {{,}\t} used to become a parameter named {,}, and {{count, plural, …}} a parameter named after the whole inner plural. Such an argument now fails like any unsupported ICU syntax, as the default compiler already did: E_ICU_SYNTAX at ingestion in development, and in production the braced text renders literally and is reported through onError when the message is compiled, so a cached render does not report it again and a recompilation after the template cache evicts it can.

  • The <script> global build of @comvi/core gains the editor loader with no code change. ComviCore.I18n and ComviCore.createI18n (dist/comvi-core.global.prod.js, the file unpkg and jsDelivr serve for the bare package name) compose discovery themselves, so from 0.6.0 they install the loader as well, always on latest: the global takes no editorVersion. In the normal flow nothing is requested until a translator with the extension presses Enable editor. The loader adds about 0.7 kB min+gzip to that composition; with the two performance changes below, core-full-composite went from 9,608 to 10,575 bytes on the 0.6.0 build (pnpm size). exposeGlobal: false stops the loader, and discovery with it, from being installed on the page; the code stays in comvi-core.global.prod.js. To ship without those bytes, use the ESM packages and compose what you need. The ESM entries are not affected: createI18n from @comvi/core and from every binding builds the base i18n instance, which gets the loader only through .use(devtools()). One more place composes discovery for you: inContextEditor() in a development build, which therefore installs the loader too; its production entry does nothing.

  • A flat catalog you pass is kept, not copied. @comvi/core never writes to it, and getMessages() returns that same object until an addMessages() merges into that locale and namespace, which builds a fresh one. Edits you make to the object after passing it are read by t() on the next call without a namespaceLoaded or configChanged event, so call addMessages() when bindings should re-render, or pass a copy when you want a snapshot. Only a non-empty string is a translation: constructor, toString and __proto__ inherited from Object.prototype never resolve, while your own string under such a key does. Non-enumerable properties, accessors, Proxies and symbol keys are outside the supported catalog shape, and enumerable keys added to Object.prototype after the catalog is passed are not guarded against.

  • A function passed for a {placeholder} renders as a missing parameter: the literal {name} by default, with a development warning that says the parameter is a function, or an empty string under missingParam: "drop". 0.5.0 printed the function's source code. This is what a message hits when it says {link} where it needs <link>…</link>. Tag handlers and every other parameter value are unchanged.

Performance

  • t() is about 1.9x faster on a warm cache. Compiled templates are now partitioned by a numeric key (message compiler, syntax extensions, plural # context) instead of a string built on every lookup, still bounded by one global 1000-entry FIFO. The biggest gain is on a static text, more than 8x; an ICU plural is about 1.5x and a cold screen render about 1.6x, measured on the 0.6.0 build as the median of three runs per side on the benchmark stand's 512-key catalog against @comvi/core 0.5.0 from npm. This also fixes a template-cache collision between message compilers once more than 65535 exist in one process; the development warning about that limit is removed because compiler ids no longer wrap.
  • Creating an instance from a flat catalog no longer copies it. A flat catalog whose values are all strings is kept by reference, whether its prototype is Object.prototype or null; nested catalogs, non-string leaves and any other prototype are still flattened into a copy. new I18n({ messages }) is about 2x faster whether the catalog comes from JSON.parse or is an object literal in the bundle, measured on the 0.6.0 build with a fresh catalog per construction, as the median of three runs per side on the same 512-key catalog against @comvi/core 0.5.0 from npm. See Behaviour changes for what this means for the object you pass.

Chrome extension 0.6.0

  • The extension no longer bundles the editor. It asks the site's devtools() loader for it, so loading the editor needs @comvi/core 0.6.0 or newer with .use(devtools()) on the i18n instance; a site that bundles the editor itself is activated as it is, at any version.
  • A site on 0.5.0 gets "Comvi i18n v0.5.0 found, but it cannot load the editor" in the popup. Upgrade the site's @comvi/* packages to 0.6.0 and reload.
  • The 0.5.0 extension keeps working with a 0.6.0 site: it activates the editor it bundles.
  • When the editor cannot start, the popup names the reason and the remedy: no devtools() on the page, an SDK older than 0.6.0, a Content Security Policy that blocks cdn.jsdelivr.net, a failed or slow download.
  • The extension requests no new permission.

Fixes

  • Generated types accept a number for a plain parameter. comvi typegen and comviTypes() from @comvi/vite-plugin type a bare {name} and a select variable as string | number, so t("errors.count", { count: 3 }) against "Reported misses: {count}" passes tsc; 0.5.0 typed it string, although the runtime always rendered the number. A plural or selectordinal count stays number. If the file has such a parameter, regenerate it once after upgrading, or comvi typegen --check reports it as outdated.
  • In-context editor: translation text no longer shifts to the right when you start editing a field. The "Languages" picker shows all of its languages instead of letting the first translation field draw over them. The source language is listed first with a neutral Source marker instead of an orange tint.
  • In-context editor on a strict style-src: the editor renders on a site whose policy allows neither inline styles nor a nonce.
  • In-context editor, standalone build: ComviInContextEditor.version is the package version, and getStatus().hook reports the devtools() loader slot the editor read at boot, which is not necessarily what started it.
  • @comvi/core: in development, the "parameter is a function" warning and the "missing parameter" warning no longer silence each other for the same message and placeholder.
  • @comvi/core: a # after a tag handler that calls t() and catches what that render throws resolves to its own plural's count, not to the count of the nested plural that failed.
  • @comvi/nuxt: the build-time check of localeDetection.resolveAcceptLanguage uses vm.Script, so the module no longer contains new Function. The source is parsed and never executed.
  • @comvi/next/proxy is the entry for Next.js 16's proxy.ts file convention, which replaces middleware.ts. It exports the same createMiddleware as @comvi/next/middleware, which stays for Next.js 15. Next 16 runs proxy.ts on the Node.js runtime.
  • @comvi/next docs: the <I18nProvider> JSDoc example names the hand-written "use client" wrapper ClientProvider. <ComviProvider> means the export from @comvi/next/server, which makes that wrapper unnecessary under withComvi.

Requirements

  • Unchanged from 0.5.0. Install the same version of every @comvi/* package.

Documentation: https://comvi.io/docs/i18n

@comvi/core

Minor Changes

  • 1fb2046: Keep flat plain-object catalogs by reference instead of copying them. A catalog passed to messages, addMessages() or returned by a loader is eligible when every leaf is a string and its prototype is either null or exactly Object.prototype — the latter only while Object.prototype itself carries no enumerable key. It is validated with one pass over its enumerable keys and then stored as the object you passed. Everything else (a nested value, a non-string leaf, any other prototype, or a polluted Object.prototype) is still flattened into a fresh null-prototype copy.

    Comvi never writes your catalog object, and getMessages() returns that same object until an addMessages() merges into that locale and namespace: a merge builds a fresh null-prototype object, so identity holds for the first adoption only. Your later edits to it are read by t() without a namespaceLoaded or configChanged event: use addMessages() when bindings must be notified, and pass a copy when you want the instance isolated from later edits.

    A lookup value is a translation only if it is a non-empty string. Members inherited from Object.prototype (constructor, toString, __proto__) therefore never resolve, while an own string property under such a key does.

    Outside the supported catalog shape: non-enumerable own properties, accessor properties, Proxies and symbol keys (a lookup may see what SSR serialization omits). Enumerable keys added to Object.prototype after a catalog was ingested are not guarded against.

  • 1fb2046: # inside plural and selectordinal is now formatted for the message's locale, as ICU MessageFormat defines it: {count, plural, other {# items}} renders 1,234 items in en and 1.234 items in de. Previously # inserted the raw digits. # follows the Intl.NumberFormat defaults, so a fractional count is rounded to three fraction digits (1.23456 renders 1.235). Exact-match selectors (=0, =1) still compare the number itself. A count of negative zero renders 0, without a sign. Use {count} instead of # where the raw digits are wanted.

Patch Changes

  • 1fb2046: Reduce template-cache lookup overhead: compiled templates are now partitioned by a numeric key (message compiler, syntax extensions, plural # context) instead of a string built on every lookup, still bounded by one global 1000-entry FIFO.

    Fix a template-cache collision between message compilers when more than 65535 exist in one process. The overflow warning is removed because compiler ids no longer wrap.

  • 1fb2046: devtools() installs a page-level editor loader the Comvi browser extension uses to load the in-context editor from jsDelivr; new editorVersion option pins the editor. The EditorLoadResult type declares editorVersion, code and host as always present, holding undefined where they do not apply.

  • 1fb2046: devtools({ editorVersion }) fails closed. An explicit value that is not an exact version (SemVer major.minor.patch with no leading zeros, optionally with a prerelease such as 0.6.0-rc.1; build metadata is not accepted) no longer falls back to latest: the editor loader is still installed so the Comvi browser extension can explain the problem, but it requests nothing, and its load() answers the new failure code invalid-editor-version. The extension's popup tells the translator that the pin must be an exact version like 1.2.3, and development builds warn as before. Leaving editorVersion out, or passing "latest", still loads latest.

  • 1fb2046: A {name} placeholder whose parameter is a function no longer prints the function's source code. It now renders as a missing parameter does: the literal {name} by default, with a development warning that says the parameter is a function, or an empty string under missingParam: "drop". This is what a translator hits by typing {link} where the message needs <link>…</link>, both in t() with a tag handler and in <T components={{ link }}>. Tag handlers themselves, and every other parameter value, are unchanged.

  • 1fb2046: In development, the "parameter is a function" warning and the "missing parameter" warning no longer silence each other for the same message and placeholder, and the warning always describes the value that was rendered.

  • 1fb2046: # in a plural branch resolves to that plural's count even after a tag handler inside the branch calls t() and catches what that render throws. Until now the count the failed inner plural had set stayed behind, and the outer # printed it.

  • 1fb2046: icuCompiler no longer reads an argument whose only comma sits inside a nested pair of braces as a parameter. {{,}\t} used to become a parameter named {,}, and {{count, plural, …}} a parameter named after the whole inner plural. Such an argument now fails like any unsupported ICU syntax, as the default compiler already did: E_ICU_SYNTAX at ingestion in development, and in production the braced text renders literally and is reported once through onError.

@comvi/cli

Patch Changes

  • 1fb2046: Generated types accept a number for a bare {name} and for a select variable: such a parameter is now string | number, not string. t("errors.count", { count: 3 }) against "Reported misses: {count}" no longer fails tsc; the runtime always rendered it. A plural or selectordinal count stays number. comvi typegen and comviTypes() still write byte-identical files, so a file with such a parameter has to be regenerated once after upgrading, or comvi typegen --check reports it as stale.

@comvi/next

Minor Changes

  • 1fb2046: Behaviour change: createNextI18n no longer composes browser-extension discovery. Until now it attached discovery to every Next app on its own, so the i18n instance was published on window.__COMVI__ and got an auto-generated instanceId. Now a Next app is not visible to the Comvi browser extension, and i18n.instanceId is undefined, until the app composes discovery itself, as with every other binding.

    To get the previous behaviour, add .use(devtools()) to the result in the shared config module:

    -import { createNextI18n } from "@comvi/next";
    +import { createNextI18n, devtools } from "@comvi/next";
    
    -export const { i18n, routing } = createNextI18n({ ...routing });
    +export const { i18n, routing } = createNextI18n({ ...routing }).use(devtools());

    devtools is also exported from @comvi/next/client and @comvi/next/server. On the server, where there is no window, it publishes nothing and assigns no id. createNextI18n never took instanceId or exposeGlobal (passing either is a type error, and the factory does not forward them); both go to the installer: devtools({ instanceId: "app" }).

Patch Changes

  • 1fb2046: The <I18nProvider> JSDoc example names the hand-written "use client" wrapper of the manual setup ClientProvider, and says that <ComviProvider> from @comvi/next/server makes it unnecessary under withComvi.
  • 1fb2046: Add @comvi/next/proxy for Next.js 16's proxy.ts file convention. Next 16 renamed middleware.ts to proxy.ts with the same request handler, so @comvi/next/proxy exports the same createMiddleware as @comvi/next/middleware: write export default createMiddleware(routing) in src/proxy.ts on Next 16. @comvi/next/middleware stays for Next 15's middleware.ts, and both subpaths hand out one function. Next 16 runs proxy.ts on the Node.js runtime and rejects a runtime option there; createMiddleware runs on either runtime.
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
    • @comvi/core@0.6.0
    • @comvi/react@0.6.0

@comvi/nuxt

Patch Changes

  • 1fb2046: Check localeDetection.resolveAcceptLanguage at build time with vm.Script instead of new Function, so the module no longer uses new Function. The source is compiled to check that it parses and is never executed.
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
  • Updated dependencies [1fb2046]
    • @comvi/core@0.6.0
    • @comvi/vue@0.6.0

@comvi/plugin-fetch-loader

0.5.0

Minor Changes

  • 09029b0: BREAKING (breaking change on a 0.x minor): the API host option is baseUrl, and only baseUrl. apiBaseUrl is removed from FetchLoaderOptions, so passing it is a type error. Precedence: baseUrl > build-time env overrides (VITE_API_BASE_URL / NEXT_PUBLIC_COMVI_API_URL) > the new comviPreset export, which carries the vendor defaults (https://api.comvi.io / https://cdn.comvi.io). Internally, the 948-line src/index.ts is split into options / http / cache / loader modules with an unchanged public surface.

  • 09029b0: BREAKING (breaking change on a 0.x minor): the raw plugin factories are renamed to fetchLoaderPlugin and cdnLoaderPlugin. They used to differ from their installers by letter case alone — fetchLoader composed the capabilities and registered the plugin, FetchLoader built a plugin and composed nothing — so one mistyped capital silently changed what a chain did, and the failure surfaced as E_USE_NOT_INSTALLER or a missing-capability error rather than as a typo. The Plugin suffix says which name is which.

    -import { FetchLoader } from "@comvi/plugin-fetch-loader";
    -import { CdnLoader } from "@comvi/plugin-fetch-loader";
    +import { fetchLoaderPlugin } from "@comvi/plugin-fetch-loader/api";
    +import { cdnLoaderPlugin } from "@comvi/plugin-fetch-loader";
    
    -i18n.registerPlugin(CdnLoader({ cdnUrl }));
    +i18n.registerPlugin(cdnLoaderPlugin({ cdnUrl }));

    fetchLoader and cdnLoader — the installers you pass to .use(…) — keep their names, and so does every option type (FetchLoaderOptions, CdnLoaderOptions), every installer type and I18nPluginFactory. Behaviour is unchanged on both names. The old names are removed, with no deprecated aliases.

  • 09029b0: cdnLoader is the package root; fetchLoader and the HTTP helpers moved from the root to
    @comvi/plugin-fetch-loader/api.

    import { createI18n } from "@comvi/core";
    import { cdnLoader } from "@comvi/plugin-fetch-loader";
    
    const i18n = createI18n({ locale: "en" }).use(
      cdnLoader({ cdnUrl: "https://cdn.comvi.io/51db17c3d52f4e7eba810b7bb9b6576b" }),
    );

    cdnLoader(options) and cdnLoaderPlugin(options) are the CDN half of the fetch loader on its
    own. fetchLoader() decides between the CDN and the Comvi API at runtime, from the i18n
    instance's apiKey, so it carries the whole API request path: the project-info cache, the legacy
    export chain and the retry policy. The package root does not contain that path, so the CDN path
    stays the lighter entry.

    Everything the CDN mode does, cdnLoader does: one request per locale:namespace with concurrent
    callers deduplicated, a timeout that covers the response body, the fallback import map,
    cdnLayout, SSR cache options, and the abort when the i18n instance clears a scope that is still
    loading.

    CdnLoaderOptions is the CDN subset of FetchLoaderOptions: baseUrl and retry only
    describe the API request path, so naming one here is a compile error rather than a setting
    that quietly does nothing.

    Import fetchLoader() and fetchLoaderPlugin() (0.4's FetchLoader()) from
    @comvi/plugin-fetch-loader/api wherever the i18n instance carries an apiKey. Both entries run
    one shared CDN request path, so the two cannot drift.

  • 09029b0: Loads land through addMessages(), and the late-compose diagnostic now recommends reloadMessages(), matching the renamed @comvi/core catalog methods.

  • 09029b0: CDN_BASE_URL is not exported. Nothing in the package read it: cdnUrl is a required option with no default and no environment override, so the constant and its VITE_CDN_BASE_URL / NEXT_PUBLIC_COMVI_CDN_URL reads answered a question the loader never asks. API_BASE_URL stays, since the API path really does fall back to it, and the hosted CDN default is readable as comviPreset.cdnBaseUrl.

  • 09029b0: The project-info request is retried, within bounds. A 429, a 5xx or a
    transport failure on /v1/project now gets up to two more attempts instead of
    failing the whole load on one bad moment.

    Only that request is retried. The CDN namespace load and the API translations
    request are not: core's fallback-locale loop already sits behind them, and
    retrying there would multiply what a setLocale costs on a flapping CDN.

    The policy is deliberately narrow:

    • a timeout is never retried, so a stalled server still fails as fast as
      timeout says and core's fallback-locale path runs on schedule;
    • a 404 is never retried — on the API path that is how the legacy export URL
      gets selected, not a failure;
    • an abort during the wait ends it immediately, with no further request;
    • Retry-After, in seconds, is honoured, and the waits between attempts never
      add more than five seconds
      ; each attempt still runs under its own timeout.
      A server asking for a longer wait than the budget has left is taken at its
      word and not retried at all;
    • everyone joining one in-flight project-info lookup shares one sequence of
      attempts, exactly as they already shared one request.

    Pass retry: false to the installer to switch it off:

    i18n.use(fetchLoader({ cdnUrl, retry: false }));
  • 09029b0: Added: fetchLoader(options) — the bare-name .use(…) installer. @comvi/core is the
    base instance now, so async loading and the plugins capability are imports you add; the installer does
    both in one call and then registers the plugin you already know.

    import { createI18n } from "@comvi/core";
    import { fetchLoader } from "@comvi/plugin-fetch-loader/api";
    
    const i18n = createI18n({ locale: "en" }).use(fetchLoader({ cdnUrl }));
    await i18n.init();

    That replaces createI18n({ … }).use(attachLoader).use(attachPlugins) followed by
    i18n.registerPlugin(fetchLoaderPlugin({ … })).

    fetchLoaderPlugin behaves exactly as FetchLoader did (the rename is in its own
    changeset), and the installer re-implements no lifecycle: required,
    timeout, onError, cleanup registration and LIFO destroy keep running inside the plugins capability,
    because the installer's last act is registerPlugin. It composes @comvi/core/loader and
    @comvi/core/plugins idempotently, so an instance that already has either keeps everything registered
    on it. One timing difference is deliberate: fetchLoader builds the plugin while it composes, so
    a missing cdnUrl throws at COMPOSITION rather than at init(). The exported installer type is
    FetchLoaderInstaller.

    Putting one in the other's slot is rejected: both cross-uses are type errors, and at runtime
    registerPlugin(fetchLoader(…)) fails at init() with E_INSTALLER_AS_PLUGIN, on the
    installer's first ensure-step, naming fetchLoader and the .use form, while
    .use(fetchLoaderPlugin(…)) runs the plugin against an instance that has neither capability — and on
    one that has both it throws E_USE_NOT_INSTALLER, because an installer widens the instance in
    place and hands that same instance back, and a plugin never does.

  • 09029b0: .use(installer) is the ONE composition verb. 0.5 shipped two verbs for two contracts — .with(installer), a synchronous widening pipe on the base instance, and .use(plugin), a deferred queue on the plugins capability. Two verbs one letter apart, with opposite timing, is a coin toss at every call site, and with needed explaining every time. use does not: Vue's app.use(plugin) calls install(app) synchronously and hands the app back for chaining, and Express's app.use and i18next's .use(module) are synchronous registration too. So use is now the only verb, and it means what every reader already thinks it means.

    import { createI18n, icuCompiler, definePlugin } from "@comvi/vue";
    import { cdnLoader } from "@comvi/plugin-fetch-loader";
    import { localeDetector } from "@comvi/plugin-locale-detector";
    
    const i18n = createI18n({ locale: "en", fallbackLocale: "en", compiler: icuCompiler })
      .use(cdnLoader({ cdnUrl }))
      .use(localeDetector({ order: ["querystring", "navigator"] }))
      .use(
        definePlugin((i18n) => {
          /* my own plugin */
        }),
      );
    
    createApp(App).use(i18n).mount("#app"); // vue's own `use`, unchanged

    The renames

    0.5 (before) 0.5 (now) What it is
    i18n.with(installer) i18n.use(installer) the composition pipe, on the BASE instance
    VueI18n.with(installer) VueI18n.use(installer) the same pipe on vue's wrapper, running on i18n.core
    i18n.use(plugin, options) i18n.registerPlugin(plugin, options) the deferred plugin queue, on the plugins capability
    — definePlugin(fn, options?) NEW: registers your own inline plugin through .use
    E_WITH_HOST_IDENTITY E_USE_INSTANCE_IDENTITY vue: the installer returned a different object
    WithHostIdentityError UseInstanceIdentityError the exported type for that error
    E_INSTALLER_NESTED_USE E_INSTALLER_AS_PLUGIN an installer was registered as a plugin
    — E_USE_NOT_INSTALLER NEW: .use(f) got something that is not an installer

    registerPlugin lines up with the neighbours it sits beside on that capability — registerLoader, registerLocaleDetector, registerPostProcessor — and it is no longer a composition verb, so nothing reads as if it belonged in a .use chain. definePlugin(fn) is attachPlugins(instance).registerPlugin(fn, options) wrapped as an installer: it attaches the plugins capability if the chain has not already and queues exactly one plugin, so an app never touches the raw queue. It is exported from @comvi/core/plugins and re-exported by every binding root (@comvi/react, @comvi/solid, @comvi/svelte, @comvi/vue, @comvi/next/client, @comvi/next/server) beside plugins; the low-level attachPlugins stays on @comvi/core/plugins alone. plugins() attaches the plugins CAPABILITY; definePlugin(fn) registers ONE plugin on it.

    E_USE_NOT_INSTALLER is what makes one verb safe. The rule is IDENTITY: an installer widens the instance in place and returns the instance it was given, so .use(f) throws unless f handed back that same object. A shape probe ("did it return an object?") would not have been enough — fetchLoaderPlugin(…) and friends are async functions, so calling one returns a Promise, which is an object; the plugin would have run at composition time with its rejection unhandled. Identity catches sync and async raw plugins alike, and .use — core's and vue's alike — also swallows the rejection an already-invoked async plugin left behind, so the actionable error is the only thing you see. In development the message states the rule, NAMES what came back (typeof, and whether an object was thenable — which is what separates "you passed an async plugin" from "you passed nothing"), and names both ways out; in production it is the bare code. This is the same rule vue's wrapper has always enforced over i18n.core, reported there under the wrapper's own code and with the same development wording, so the branchable code set is now three: E_ICU_SYNTAX, E_USE_INSTANCE_IDENTITY, E_USE_NOT_INSTALLER.

    The rule is also stated in the TYPE: use<T extends this>(installer: (instance: this) => T): T, so all three misuses — a derived object, an async installer, an installer that returns nothing — are compile errors, while every widening installer still infers and widens. The runtime guard stays for JS and any-typed callers. Vue's use keeps its T extends WrapperI18nInstance<D> bound, which already refuses the same three shapes.

    One consequence for hand-written installers: a step that returned a DERIVED object (.use((i18n) => ({ i18n, dispose }))) is no longer a valid installer. Widen in place and return the instance, and keep whatever else you built beside the chain.

    Everything else keeps its name: loader(), plugins(), devtools(), attachLoader, attachPlugins, attachDevtools, fetchLoader(), cdnLoader(), localeDetector(), inContextEditor(), icuCompiler, the compiler: option, useI18n(), useI18nLoader(), useI18nPlugins(), createI18nFromCore, createNextI18nFromInstance, Nuxt's i18nModule. (@comvi/vue's createCore alias is gone — see vue-drop-create-core.md; attachPlugins / attachDevtools keep their names on @comvi/core/plugins / @comvi/core/devtools, off the binding roots — see roots-drop-attach-pair.md.) (createNextI18n(...).use becomes the same installer pipe in wave 2 — see the @comvi/next entry.) Vue's app.use(i18n) and Nuxt's nuxtApp.use were never ours and are untouched.

    Migration

    - import { createI18n, icuCompiler, loader, plugins } from "@comvi/react";
    - import { LocaleDetector } from "@comvi/plugin-locale-detector";
    + import { createI18n, icuCompiler, loader, definePlugin } from "@comvi/react";
    + import { localeDetector } from "@comvi/plugin-locale-detector";
    
      export const i18n = createI18n({ locale: "en", compiler: icuCompiler })
    -   .with(loader({ uk: () => import("./uk.json") }))
    -   .with(plugins())
    -   .use(LocaleDetector({ order: ["navigator"] }))
    -   .use((i18n) => {
    -     track(i18n.locale);
    -   });
    +   .use(loader({ uk: () => import("./uk.json") }))
    +   .use(localeDetector({ order: ["navigator"] }))
    +   .use(
    +     definePlugin((i18n) => {
    +       track(i18n.locale);
    +     }),
    +   );

    Three mechanical steps, in this order:

    1. .with( → .use( everywhere, including on vue's VueI18n.
    2. Every remaining i18n.use(rawPlugin) → i18n.registerPlugin(rawPlugin). A "raw plugin" is a function that returns nothing or a cleanup function: your own inline plugin, or a *Plugin factory's result (localeDetectorPlugin(), fetchLoaderPlugin(), inContextEditorPlugin()).
    3. Prefer composing instead of queuing: an inline plugin becomes .use(definePlugin(fn)), and a plugin package's *Plugin factory becomes its bare-name installer in the chain (localeDetector(), fetchLoader(), cdnLoader(), inContextEditor()).

    No with alias survives anywhere, and the old error identities are gone: a catch that branched on E_WITH_HOST_IDENTITY or E_INSTALLER_NESTED_USE, or a type import of WithHostIdentityError, must be updated. Step 2 has no compile error on an instance typed as any, and a raw plugin left in .use() now throws E_USE_NOT_INSTALLER at composition time — loudly, with the fix in the message — rather than running at the wrong moment.

Patch Changes

  • 09029b0: publishConfig.access is now declared as public in every published manifest.

    access: "public" previously lived only in .changeset/config.json, so it applied to
    changeset publish and nothing else. Any other publish path had to remember --access public
    or npm would refuse the scoped package as private. The declaration now travels with each
    manifest. No code changed.

  • 09029b0: A loader now fetches the fallback chain, and an empty translation falls through to it.

    fallbackLocale was a lookup rule only. With a loader, init() and setLocale() fetched the
    active locale and nothing else, so createI18n({ locale: 'de', fallbackLocale: 'en' }) over a CDN
    rendered a key missing from de as the key: the en catalog it was supposed to fall back to had
    never been requested. The same held for a de-AT → de → en chain and for a namespace
    activated later.

    A namespace is now loaded for each locale of the fallback chain when it is first needed: at
    init() and on addActiveNamespace(s)(). setLocale() then finds the chain in the cache and
    fetches the target locale alone. A catalog already in the cache, including
    one passed as messages or hydrated from a server payload, is not requested again, and a fallback
    equal to the locale costs nothing. A fallback catalog is a safety net and never a requirement: when
    it fails to load, the failure reaches onLoadError and onError like any other and the operation
    still succeeds. The locale's own catalog keeps its old rule, so init() rejects when every
    namespace of the active locale failed. A chain changed with
    setFallbackLocale() after init() is loaded by reloadMessages() or with the next namespace
    that is activated. A fallback catalog that is missing for good is requested once per namespace,
    not on every locale switch.

    BREAKING (behaviour change on a 0.x minor): an empty string is a missing translation. A key
    whose stored value is "" reads as absent everywhere a key is looked up. t() renders the first
    non-empty value along the fallback chain where it used to render an empty string, and
    hasTranslation() returns false for it. A key that is empty in every locale of the chain takes
    the missing-key path exactly like a key that is in none: missingKey fires, onMissingKey runs,
    a per-call fallback is used, and otherwise the key is rendered. This is what a translation
    platform publishes for a value nobody has written yet. Only the stored value is judged: a message
    with text that renders empty for one call, such as an empty plural or select branch, is a
    translation and is returned as is.

  • 09029b0: The shipped .d.ts files now use extension-qualified relative specifiers (./options.js,
    ./cdn/index.js), so the published types resolve under moduleResolution: node16 and
    nodenext — the mode a Node ESM package or a "type": "module" app gets by default.

    Previously every relative specifier in the emitted declarations was extensionless, inherited
    verbatim from sources written for moduleResolution: "bundler". Bundler resolution
    substitutes extensions; node16 does not, and it has no directory-index lookup either. The
    damage had two shapes, and only one of them was loud:

    • The @comvi/plugin-fetch-loader entry that carries cdnLoader was built as
      export * from './cdn/index'. A star re-export from an unresolved module contributes no
      names, so the entry point had zero exported members, and importing cdnLoader failed
      with TS2305 ... has no exported member 'cdnLoader'. @comvi/core and
      @comvi/plugin-in-context-editor had the same shape on export type * from './types' and
      export * from './entry-production'.
    • Every other entry used named re-exports (export { X } from './options'). Those keep the
      name but bind it to an unresolved module, so X silently became any. With the
      skipLibCheck: true nearly every app sets, a node16 consumer got no diagnostic at
      all
      : createI18n(123) type-checked. The packages looked typed and checked nothing.

    On upgrade, a node16/nodenext project may see NEW type errors in its own code. Those
    errors were always there — the types that would have reported them were any. Code written
    against the real signatures needs no change.

    Bundler-resolution consumers (Vite, and Next.js projects on the bundler default) were
    never affected — their types were correct throughout. Consumers on node16/nodenext get
    real types for the first time, with one exception outside our control: @comvi/next's
    middleware and routing declarations import next/server and next/link, and next@15.5.9
    ships no exports field, so those subpaths still do not resolve under node16 (3 ×
    TS2307 raised inside @comvi/next's own declarations). Next.js projects on the bundler
    resolution its own tooling sets are unaffected.

    pnpm check:types-exports now gates attw's node16 resolution mode in addition to
    bundler. attw detects this defect perfectly well — 10 × InternalResolutionError on
    @comvi/plugin-fetch-loader alone, ./cdn/index among them. It was a single line in the
    repo's own wrapper that suppressed the entire node16 report, so the gate was green on a
    broken tree. The gate also grew a small probe for the one thing attw cannot reach: attw
    resolves entry points with a fixed condition set, so a types branch under a custom
    condition — @comvi/plugin-in-context-editor's "types": { "production": … } — is never
    visited, and that is exactly where export * from './entry-production' was hiding.

    @comvi/svelte and @comvi/nuxt were previously excluded from that gate as "covered by
    publint". They were not: publint validates the manifest, not resolution inside a declaration
    file, and printed "All good!" on both broken trees. Every published package is now gated.

    Follow-up, deliberately NOT in this release: the declarations are corrected by a post-build
    rewrite (tooling/qualify-dts-specifiers.mjs) wired into each package's build. Writing the
    .js extension in the SOURCES instead is legal under moduleResolution: "bundler" and is
    emitted verbatim, which would let the rewriter be deleted and would cover svelte-package
    and nuxt-module-build without per-package wiring — one mechanical sweep over 566
    extensionless relative imports across 161 files. Neither moduleResolution: node16 in the emitting
    tsconfig nor rewriteRelativeImportExtensions is a substitute.

  • 09029b0: Node 18 reached end of life; the packages now declare node >= 20. No code changed.

    @comvi/cli and @comvi/vite-plugin are build-time tooling and keep their existing
    node >= 22.

  • 09029b0: Every runtime message opens with the name of the package whose code wrote it. [i18n] and [comvi] become [@comvi/core] in core, [@comvi/react] and [@comvi/vue] in those bindings, and [@comvi/next] on next's .use() identity error; the in-context editor's [ComviInContextEditor], [InContextEditor], [ElementHighlighter] and [comvi] become [@comvi/plugin-in-context-editor], and the CLI's [comvi] becomes [@comvi/cli]. A core message surfaced through a binding keeps [@comvi/core]. The sentence after the prefix is unchanged, except that a few messages that had no prefix gain one: core's plugin-initialization timeout and failure and its missing-tag-handler report, react's non-element tag handler error, the fetch loader's project-info, API, CDN HTTP and timeout errors, and the editor's console diagnostics. Core's Locale is not set and `messages` is not an object move from the @comvi/core: form to [@comvi/core]. Three catalog-level sentences now say messages, the catalog word: No loader registered. Cannot reload messages., Failed to reload messages (core) and Failed to refresh messages from the API. (editor). Production error codes such as E_ICU_SYNTAX are unchanged.

  • 09029b0: A request whose whole scope clearMessages() drops is now aborted.
    Clearing stops the instance from waiting on the matching loads, but their HTTP
    requests kept running to completion — holding a connection open for an answer
    nobody would read. The editor-save and webhook-refresh flow (clear, then
    reload) paid for every namespace twice.

    A request is abandoned only when the clear covers all of it:

    • clearMessages("en", "common") aborts the request loading exactly that
      namespace, and leaves requests for other locales and other namespaces alone.
    • clearMessages() and clearMessages("en") abort everything in
      flight for that scope.
    • In API mode one request carries every active namespace. Clearing one of them
      leaves that request running, because the instance is still waiting for the rest.

    Nothing new is reported: an abandoned request raises neither onLoadError nor
    the instance's loadError, because the instance had already stopped waiting for every
    namespace it carried.

    What this does not cover: a locale switch superseded by another switch. The
    instance keeps such a load, lets it finish and caches its result, and announces
    nothing the loader could follow — so that request still runs to completion.

  • 09029b0: A CDN catalog whose JSON root is not an object is now a reported load failure.

    The CDN path accepted any JSON root and handed it to addMessages, so a string became
    per-character keys, an array numeric ones and null an empty catalog — a silently wrong catalog
    instead of an error. It now runs the same shape check as the API path, which both paths share, and
    the failure reaches onLoadError like any other.

  • 09029b0: Errors thrown by this package are prefixed with the package name rather than
    [FetchLoader].

    [FetchLoader] named an export, not the package, so a
    @comvi/plugin-fetch-loader user reading [FetchLoader] Request aborted: …
    saw a symbol they never imported. Every message now begins
    [@comvi/plugin-fetch-loader], whichever entry point raised it:

    -[FetchLoader] Invalid locale: "../etc"
    +[@comvi/plugin-fetch-loader] Invalid locale: "../etc"

    Only the prefix changes; the rest of every message, the error name
    (AbortError is still AbortError) and every thrown type are the same. Code
    matching on message text needs updating; code matching on error.name does not.

  • 09029b0: .use(fetchLoader(…)) and .use(cdnLoader(…)) composed after init() now warn with the plugin-queue message, which is the one whose recovery advice works. Before, the loader capability attached first and claimed the once-per-instance warning with advice that could not work on that instance (reloadMessages() throws E_NO_LOADER_REGISTERED, addActiveNamespace() fetches nothing). The loadOnInit JSDoc now says what the flag does on the public entry points: it does not suppress or add the init() request; use ns: [] to start without one.

  • 09029b0: The legacy export URL now validates its locale and namespaces like every
    other URL the loader builds.
    It was the one builder that skipped the check,
    so a locale or namespace carrying path segments would have been interpolated
    into the request path unexamined. Nothing reaches it with bad input today — the
    only route to it validates first — and now nothing can.

  • 09029b0: The API base URL no longer reads import.meta.env.VITE_API_BASE_URL, which the published build could never see; pass baseUrl, or set NEXT_PUBLIC_COMVI_API_URL, which is read from process.env on the server (the name follows Next's convention, and only Next can inline it into a browser bundle).

  • 09029b0: A malformed API payload is now refused instead of silently becoming junk
    translations.
    Two levels of the export response went unchecked.

    If a namespace's locale map arrived as a string, Object.entries("ab") turned
    it into entries 0 and 1, so the loader cached 0:default and 1:default
    and the real keys were simply missing. The same held for a catalog that came
    back as a string, a number or null. Both now throw the existing invalid
    response error, naming the exact path:

    [@comvi/plugin-fetch-loader] Invalid API response: "namespaces.default.en" must be an object
    

    The project lookup is checked too. Its id is interpolated into the export URL
    path, so a 200 that is not a project — no id, a null one, an object, or a
    string carrying path segments such as ../.. — is refused rather than turned
    into a malformed request:

    [@comvi/plugin-fetch-loader] Invalid API response from https://api.comvi.io/v1/project: "id" must be a number or a path-safe string
    

    That refusal is not retried: asking the same endpoint again cannot change what
    it answered.

  • 09029b0: The request timeout now covers reading the response body. The timer used to
    be cleared as soon as the response headers arrived, so a server that answered
    and then stalled mid-body held the load open indefinitely: setLocale
    never settled, no fallback locale was tried, and onLoadError never fired.

    All four request shapes are bounded now — the CDN namespace fetch, the API
    translations request, the project-info lookup, and the export request the 404
    fallback reaches for — each under its own full timeout, failing with the same
    Request timeout after Nms error a stalled connection produces.

    If you supply your own transport through fetchFn, a non-OK response is now
    also read for its Retry-After header. A response object without headers is
    handled, so a hand-written transport that answers with a bare
    { ok, status, statusText } keeps working.

  • 09029b0: The cdnLoader and cdnLoaderPlugin documentation now points to fetchLoader in @comvi/plugin-fetch-loader/api and imports attachLoader in its example.

  • 09029b0: A load that follows reloadMessages() or clearMessages() now starts a new request instead of joining the abandoned one, so it no longer returns the stale catalog or the old request's AbortError.

  • 09029b0: The fetchLoaderPlugin documentation example now composes the loader and plugin capabilities before calling registerPlugin.

  • 09029b0: Docs: the CDN endpoint is named what the dashboard names it. "Distribution id" was terminology the
    product does not have — the dashboard shows a CDN Endpoint card holding the endpoint as a full
    URL with a Copy URL button, and the path segment is an opaque per-project token assembled
    server-side. So the old placeholder https://cdn.comvi.io/your-distribution-id was wrong in shape
    as well as in name: it asked the reader to assemble a URL out of a part they were never handed,
    when what is in their clipboard is the whole thing. Every README now shows the complete endpoint
    and says to paste what the card gives them; the @example doc comments on fetchLoaderPlugin and
    cdnLoaderPlugin match. The navigation was already correct and is unchanged. 23 occurrences across
    12 files, comments only in the two .ts files.

    The root README's quick start is also runnable for the first time, which is a repository-surface
    change rather than a package one: step 1 gained the npm create vite scaffold it never had (its
    fence imports react-dom/client, and @comvi/react peer-depends on react only, so following the
    README alone ended in ERR_MODULE_NOT_FOUND), gained the src/App.tsx that actually calls t()
    instead of describing it in prose, and closes with the dev command and the literal strings to
    expect. Step 2 now rewrites src/main.tsx as one copy-pasteable fence, matching the docs site
    instead of inventing a second project shape around a new src/i18n.ts.

  • 09029b0: Every package README is now the short form: what the package is, install, the inline catalog,
    connecting to Comvi, and the handful of things specific to that framework. The API references, the
    SSR walkthroughs, the typed-keys sections and the composing-by-hand references moved to
    comvi.io/docs/i18n, which is now the canonical place for all of it — each removed section has a
    page there, linked from the README that used to carry it. No code changed.

    The READMEs are published files, so this is what a reader sees on npm. They were 204 to 595 lines
    each and most of that was reference material that drifts: a package README cannot be re-verified
    against the build the way a docs page can, and eight of ten competitor READMEs carry no API
    reference at all. What stays is deliberately the part a reader runs in the first minute, plus the
    facts that only exist in this repo — one measured size figure per package, named by the fixture in
    scripts/size-budgets.json so the prose/stale-size-figure gate still grades it, and the house
    paragraphs (the ICU failure mode, the instance definition, the @comvi/core formatters caveat, the
    Content Deploy sentence), kept byte-identical.

@comvi/plugin-in-context-editor

Patch Changes

  • 1fb2046: The in-context editor counts its stylesheet as adopted wherever it sits among the shadow root's adopted sheets, and takes a half-finished adoption back before it falls back to a <style> element, so its styles are never applied twice.
  • 1fb2046: In the in-context editor, translation text no longer jumps to the right when you start editing a field, and the source language is now listed first with a neutral Source marker instead of an orange tint.
  • 1fb2046: The in-context editor now applies its styles through a constructable stylesheet instead of an inline <style> element, so it renders on sites whose Content Security Policy has a strict style-src; the standalone build also exposes its own version and reports the devtools() editor loader it found in getStatus().hook.
  • 1fb2046: The "Languages" picker in the in-context editor now shows all of its languages instead of letting the first translation field draw over them.

@comvi/plugin-locale-detector

0.5.0

Minor Changes

  • 09029b0: BREAKING (breaking change on a 0.x minor): the raw plugin factory is renamed LocaleDetector → localeDetectorPlugin. It differed from the localeDetector installer by one capital, which is not a difference a reader notices and not one a type error explains well. The Plugin suffix names the thing you hand to registerPlugin.

    -import { LocaleDetector } from "@comvi/plugin-locale-detector";
    +import { localeDetectorPlugin } from "@comvi/plugin-locale-detector";
    
    -i18n.registerPlugin(LocaleDetector({ order: ["cookie"] }));
    +i18n.registerPlugin(localeDetectorPlugin({ order: ["cookie"] }));

    The localeDetector installer, LocaleDetectorOptions, LocaleDetectorInstaller and resolveLocale keep their names, and behaviour is unchanged. On Next this is also the name inside nextPlugin(localeDetectorPlugin(…), { runtime: "client" }), still the only correct way to run the detector on an App Router instance. The old names are removed, with no deprecated aliases.

  • 09029b0: Added: localeDetector(options) — the bare-name .use(…) installer. @comvi/core is
    the base instance now, so the plugins capability is an import you add; the installer composes it and
    registers the plugin you already know.

    import { createI18n } from "@comvi/core";
    import { localeDetector } from "@comvi/plugin-locale-detector";
    
    const i18n = createI18n({ locale: "en" }).use(
      localeDetector({ supportedLocales: ["en", "uk", "de"], caches: ["cookie"] }),
    );

    That replaces createI18n({ … }).use(attachPlugins) followed by
    i18n.registerPlugin(localeDetectorPlugin({ … })). No loader capability is composed, deliberately: the
    detector hands core a locale, it never loads a catalog.

    localeDetectorPlugin behaves exactly as LocaleDetector did (the rename is in its
    own changeset), and the installer re-implements no lifecycle: required,
    timeout, onError, cleanup registration and LIFO destroy keep running inside the plugins capability,
    because the installer's last act is registerPlugin. Composing onto an instance that already has the
    capability installs nothing and keeps every plugin already registered. The exported installer
    type is LocaleDetectorInstaller. Putting one in the other's slot is a type error and loud at
    runtime.

  • 09029b0: .use(installer) is the ONE composition verb. 0.5 shipped two verbs for two contracts — .with(installer), a synchronous widening pipe on the base instance, and .use(plugin), a deferred queue on the plugins capability. Two verbs one letter apart, with opposite timing, is a coin toss at every call site, and with needed explaining every time. use does not: Vue's app.use(plugin) calls install(app) synchronously and hands the app back for chaining, and Express's app.use and i18next's .use(module) are synchronous registration too. So use is now the only verb, and it means what every reader already thinks it means.

    import { createI18n, icuCompiler, definePlugin } from "@comvi/vue";
    import { cdnLoader } from "@comvi/plugin-fetch-loader";
    import { localeDetector } from "@comvi/plugin-locale-detector";
    
    const i18n = createI18n({ locale: "en", fallbackLocale: "en", compiler: icuCompiler })
      .use(cdnLoader({ cdnUrl }))
      .use(localeDetector({ order: ["querystring", "navigator"] }))
      .use(
        definePlugin((i18n) => {
          /* my own plugin */
        }),
      );
    
    createApp(App).use(i18n).mount("#app"); // vue's own `use`, unchanged

    The renames

    0.5 (before) 0.5 (now) What it is
    i18n.with(installer) i18n.use(installer) the composition pipe, on the BASE instance
    VueI18n.with(installer) VueI18n.use(installer) the same pipe on vue's wrapper, running on i18n.core
    i18n.use(plugin, options) i18n.registerPlugin(plugin, options) the deferred plugin queue, on the plugins capability
    — definePlugin(fn, options?) NEW: registers your own inline plugin through .use
    E_WITH_HOST_IDENTITY E_USE_INSTANCE_IDENTITY vue: the installer returned a different object
    WithHostIdentityError UseInstanceIdentityError the exported type for that error
    E_INSTALLER_NESTED_USE E_INSTALLER_AS_PLUGIN an installer was registered as a plugin
    — E_USE_NOT_INSTALLER NEW: .use(f) got something that is not an installer

    registerPlugin lines up with the neighbours it sits beside on that capability — registerLoader, registerLocaleDetector, registerPostProcessor — and it is no longer a composition verb, so nothing reads as if it belonged in a .use chain. definePlugin(fn) is attachPlugins(instance).registerPlugin(fn, options) wrapped as an installer: it attaches the plugins capability if the chain has not already and queues exactly one plugin, so an app never touches the raw queue. It is exported from @comvi/core/plugins and re-exported by every binding root (@comvi/react, @comvi/solid, @comvi/svelte, @comvi/vue, @comvi/next/client, @comvi/next/server) beside plugins; the low-level attachPlugins stays on @comvi/core/plugins alone. plugins() attaches the plugins CAPABILITY; definePlugin(fn) registers ONE plugin on it.

    E_USE_NOT_INSTALLER is what makes one verb safe. The rule is IDENTITY: an installer widens the instance in place and returns the instance it was given, so .use(f) throws unless f handed back that same object. A shape probe ("did it return an object?") would not have been enough — fetchLoaderPlugin(…) and friends are async functions, so calling one returns a Promise, which is an object; the plugin would have run at composition time with its rejection unhandled. Identity catches sync and async raw plugins alike, and .use — core's and vue's alike — also swallows the rejection an already-invoked async plugin left behind, so the actionable error is the only thing you see. In development the message states the rule, NAMES what came back (typeof, and whether an object was thenable — which is what separates "you passed an async plugin" from "you passed nothing"), and names both ways out; in production it is the bare code. This is the same rule vue's wrapper has always enforced over i18n.core, reported there under the wrapper's own code and with the same development wording, so the branchable code set is now three: E_ICU_SYNTAX, E_USE_INSTANCE_IDENTITY, E_USE_NOT_INSTALLER.

    The rule is also stated in the TYPE: use<T extends this>(installer: (instance: this) => T): T, so all three misuses — a derived object, an async installer, an installer that returns nothing — are compile errors, while every widening installer still infers and widens. The runtime guard stays for JS and any-typed callers. Vue's use keeps its T extends WrapperI18nInstance<D> bound, which already refuses the same three shapes.

    One consequence for hand-written installers: a step that returned a DERIVED object (.use((i18n) => ({ i18n, dispose }))) is no longer a valid installer. Widen in place and return the instance, and keep whatever else you built beside the chain.

    Everything else keeps its name: loader(), plugins(), devtools(), attachLoader, attachPlugins, attachDevtools, fetchLoader(), cdnLoader(), localeDetector(), inContextEditor(), icuCompiler, the compiler: option, useI18n(), useI18nLoader(), useI18nPlugins(), createI18nFromCore, createNextI18nFromInstance, Nuxt's i18nModule. (@comvi/vue's createCore alias is gone — see vue-drop-create-core.md; attachPlugins / attachDevtools keep their names on @comvi/core/plugins / @comvi/core/devtools, off the binding roots — see roots-drop-attach-pair.md.) (createNextI18n(...).use becomes the same installer pipe in wave 2 — see the @comvi/next entry.) Vue's app.use(i18n) and Nuxt's nuxtApp.use were never ours and are untouched.

    Migration

    - import { createI18n, icuCompiler, loader, plugins } from "@comvi/react";
    - import { LocaleDetector } from "@comvi/plugin-locale-detector";
    + import { createI18n, icuCompiler, loader, definePlugin } from "@comvi/react";
    + import { localeDetector } from "@comvi/plugin-locale-detector";
    
      export const i18n = createI18n({ locale: "en", compiler: icuCompiler })
    -   .with(loader({ uk: () => import("./uk.json") }))
    -   .with(plugins())
    -   .use(LocaleDetector({ order: ["navigator"] }))
    -   .use((i18n) => {
    -     track(i18n.locale);
    -   });
    +   .use(loader({ uk: () => import("./uk.json") }))
    +   .use(localeDetector({ order: ["navigator"] }))
    +   .use(
    +     definePlugin((i18n) => {
    +       track(i18n.locale);
    +     }),
    +   );

    Three mechanical steps, in this order:

    1. .with( → .use( everywhere, including on vue's VueI18n.
    2. Every remaining i18n.use(rawPlugin) → i18n.registerPlugin(rawPlugin). A "raw plugin" is a function that returns nothing or a cleanup function: your own inline plugin, or a *Plugin factory's result (localeDetectorPlugin(), fetchLoaderPlugin(), inContextEditorPlugin()).
    3. Prefer composing instead of queuing: an inline plugin becomes .use(definePlugin(fn)), and a plugin package's *Plugin factory becomes its bare-name installer in the chain (localeDetector(), fetchLoader(), cdnLoader(), inContextEditor()).

    No with alias survives anywhere, and the old error identities are gone: a catch that branched on E_WITH_HOST_IDENTITY or E_INSTALLER_NESTED_USE, or a type import of WithHostIdentityError, must be updated. Step 2 has no compile error on an instance typed as any, and a raw plugin left in .use() now throws E_USE_NOT_INSTALLER at composition time — loudly, with the fix in the message — rather than running at the wrong moment.

Patch Changes

  • 09029b0: publishConfig.access is now declared as public in every published manifest.

    access: "public" previously lived only in .changeset/config.json, so it applied to
    changeset publish and nothing else. Any other publish path had to remember --access public
    or npm would refuse the scoped package as private. The declaration now travels with each
    manifest. No code changed.

  • 09029b0: Node 18 reached end of life; the packages now declare node >= 20. No code changed.

    @comvi/cli and @comvi/vite-plugin are build-time tooling and keep their existing
    node >= 22.

  • 09029b0: Internal cache-write config keys renamed from minified-style {ck, ls, ss, co, ma} to {cookieKey, localStorageKey, sessionStorageKey, cookieOptions, maxAge}. The object never crosses the package boundary (it is only passed to the internal writeCaches helper), so the public options (lookupCookie, lookupLocalStorage, …) and behavior are unchanged.

  • 09029b0: A detected locale is cached only after init() has succeeded with it. The detector wrote the
    cache as soon as the detected locale was applied, which is before the first catalog is requested.
    Without supportedLocales, a browser language the app has no catalog for was therefore cached and
    then failed to load, and every later visit read it back and failed the same way: ?lng=en lost to
    the cached value, and an in-app setLocale('en') after the failed init did not rewrite it.

    A locale detected during init() is now written when initialized fires. A setLocale() after
    init is written at once, as before. When init() fails and a load for the locale the detector
    read from its own cache has failed, that cached value is removed from every cache target that
    still holds it; a value the app wrote over it in the meantime is left alone, and so is the cache
    when the instance is destroyed mid-init without a load failure.

    Unchanged: with a browser that keeps reporting an unshipped language and no supportedLocales,
    each visit detects that language again and init() fails again. The detector does not pick
    another locale on its own; pass supportedLocales.

  • 09029b0: The locale detector warns in development when it has no supportedLocales. Without the list
    (and without convertDetectedLocale) any language the browser reports becomes the active locale
    and is cached, including one the app has no translations for; init() then fails for that
    visitor on every later visit. Development builds now say so once, when the plugin starts, and
    name the option.

    The package gains a development artifact for this, dist/index.dev.js, behind the development
    export condition, the same split @comvi/core and @comvi/vue have. The default artifact is
    byte-identical to the previous one: the check and its text exist only in the development file.
    Runtime behaviour is unchanged in both.

  • 09029b0: A locale switch made while init() is still loading stays the cached choice. The detector
    holds an init-time detection back until initialized; a setLocale() in that window was written
    at once and then overwritten by the held detection, leaving the cache on one locale and the UI on
    another. The switch now supersedes the held detection.

  • 09029b0: Fixed a manually re-consulted detector (e.g. via getLanguageDetector(), whether called before init() has ever run or long after it has settled) being able to leave a fallback-suppression marker that swallowed a later, unrelated explicit choice of that same locale. The marker is now armed only during init()'s own detector call, the one time core can auto-apply it.

@comvi/react

Patch Changes

@comvi/solid

Patch Changes

@comvi/svelte

Patch Changes

@comvi/vite-plugin

Patch Changes

  • 1fb2046: Generated types accept a number for a bare {name} and for a select variable: such a parameter is now string | number, not string. t("errors.count", { count: 3 }) against "Reported misses: {count}" no longer fails tsc; the runtime always rendered it. A plural or selectordinal count stays number. comvi typegen and comviTypes() still write byte-identical files, so a file with such a parameter has to be regenerated once after upgrading, or comvi typegen --check reports it as stale.

@comvi/vue

Patch Changes