Repository navigation
v0.6.0
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()).createNextI18nno 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.netinscript-srcif translators use the extension on it.style-srcneeds 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 typedstring | number, so if your generated file has a plain orselectparameter, it differs from fresh output andcomvi typegen --checkfails in CI until you runcomvi typegenagain.comviTypes()in a Vite app rewrites it on the nextvite devorvite build. - Upgrade the site before the extension. Extension 0.6.0 loads the editor only onto a site on
@comvi/core0.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 whatdevtools()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>forhttps://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 afterdestroy(). - Pin the editor with
devtools({ editorVersion: "0.6.0" }). It takes an exact version: SemVermajor.minor.patchwith no leading zeros, optionally with a prerelease such as0.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'sload()answers the failure codeinvalid-editor-version, the extension's popup says the pin must be an exact version like1.2.3, and development builds warn. A rejected pin never falls back tolatest. The firstdevtools()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). Onlatestthe 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 previouslatestfor 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'sload()resolves to, declareseditorVersion,codeandhostas always present, holdingundefinedwhere they do not apply.- Size.
devtools()costs about 1.2 kB min+gzip over the base i18n instance:@comvi/corealone measures 5,607 bytes, and 6,870 with.use(devtools()), measured on the 0.6.0 build withpnpm size(thecore-baseandcore-base-devtoolsrows). About 0.7 kB of that is the editor loader, new in 0.6.0; the rest is the discoverydevtools()already did in 0.5.0. An app on the ESM packages that does not calldevtools()carries none of it; the<script>global build composes it on its own, see Behaviour changes. - Content Security Policy. Add
https://cdn.jsdelivr.nettoscript-src. A site that enforces Trusted Types needs two more things: adefaultpolicy whosecreateScriptURLaccepts that script URL, because the loader assignsscript.srcas a plain string, andvuein itstrusted-typesdirective, the policy the editor's Vue runtime creates for the HTML it renders.style-srcneeds 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:createNextI18nno longer composes discovery. Until 0.6.0 it published every Next app onwindow.__COMVI__and gave it aninstanceId. Now the app is not visible to the extension, andi18n.instanceIdisundefined, 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());
devtoolsis also exported from@comvi/next/clientand@comvi/next/server. On the server it publishes nothing.instanceIdandexposeGlobalgo to the installer:devtools({ instanceId: "app" }). -
#inpluralandselectordinalis formatted for the message's locale, as ICU MessageFormat defines it.{count, plural, other {# items}}renders1,234 itemsinenand1.234 itemsinde; 0.5.0 inserted the raw digits.#follows theIntl.NumberFormatdefaults, so a fractional count is rounded to three fraction digits (1.23456renders1.235). Exact selectors (=0,=1) still compare the number itself, a count of negative zero renders0without a sign, and{count}is unchanged. -
icuCompilerno 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_SYNTAXat ingestion in development, and in production the braced text renders literally and is reported throughonErrorwhen 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/coregains the editor loader with no code change.ComviCore.I18nandComviCore.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 onlatest: the global takes noeditorVersion. 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-compositewent from 9,608 to 10,575 bytes on the 0.6.0 build (pnpm size).exposeGlobal: falsestops the loader, and discovery with it, from being installed on the page; the code stays incomvi-core.global.prod.js. To ship without those bytes, use the ESM packages and compose what you need. The ESM entries are not affected:createI18nfrom@comvi/coreand 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/corenever writes to it, andgetMessages()returns that same object until anaddMessages()merges into that locale and namespace, which builds a fresh one. Edits you make to the object after passing it are read byt()on the next call without anamespaceLoadedorconfigChangedevent, so calladdMessages()when bindings should re-render, or pass a copy when you want a snapshot. Only a non-empty string is a translation:constructor,toStringand__proto__inherited fromObject.prototypenever 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 toObject.prototypeafter 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 undermissingParam: "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/core0.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.prototypeor 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 fromJSON.parseor 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/core0.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/core0.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 blockscdn.jsdelivr.net, a failed or slow download. - The extension requests no new permission.
Fixes
- Generated types accept a number for a plain parameter.
comvi typegenandcomviTypes()from@comvi/vite-plugintype a bare{name}and aselectvariable asstring | number, sot("errors.count", { count: 3 })against"Reported misses: {count}"passestsc; 0.5.0 typed itstring, although the runtime always rendered the number. Apluralorselectordinalcount staysnumber. If the file has such a parameter, regenerate it once after upgrading, orcomvi typegen --checkreports 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
Sourcemarker 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.versionis the package version, andgetStatus().hookreports thedevtools()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 callst()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 oflocaleDetection.resolveAcceptLanguageusesvm.Script, so the module no longer containsnew Function. The source is parsed and never executed.@comvi/next/proxyis the entry for Next.js 16'sproxy.tsfile convention, which replacesmiddleware.ts. It exports the samecreateMiddlewareas@comvi/next/middleware, which stays for Next.js 15. Next 16 runsproxy.tson the Node.js runtime.@comvi/nextdocs: the<I18nProvider>JSDoc example names the hand-written"use client"wrapperClientProvider.<ComviProvider>means the export from@comvi/next/server, which makes that wrapper unnecessary underwithComvi.
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 exactlyObject.prototype— the latter only whileObject.prototypeitself 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 pollutedObject.prototype) is still flattened into a fresh null-prototype copy.Comvi never writes your catalog object, and
getMessages()returns that same object until anaddMessages()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 byt()without anamespaceLoadedorconfigChangedevent: useaddMessages()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.prototypeafter a catalog was ingested are not guarded against. -
1fb2046:
#insidepluralandselectordinalis now formatted for the message's locale, as ICU MessageFormat defines it:{count, plural, other {# items}}renders1,234 itemsinenand1.234 itemsinde. Previously#inserted the raw digits.#follows theIntl.NumberFormatdefaults, so a fractional count is rounded to three fraction digits (1.23456renders1.235). Exact-match selectors (=0,=1) still compare the number itself. A count of negative zero renders0, 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; neweditorVersionoption pins the editor. TheEditorLoadResulttype declareseditorVersion,codeandhostas always present, holdingundefinedwhere they do not apply. -
1fb2046:
devtools({ editorVersion })fails closed. An explicit value that is not an exact version (SemVermajor.minor.patchwith no leading zeros, optionally with a prerelease such as0.6.0-rc.1; build metadata is not accepted) no longer falls back tolatest: the editor loader is still installed so the Comvi browser extension can explain the problem, but it requests nothing, and itsload()answers the new failure codeinvalid-editor-version. The extension's popup tells the translator that the pin must be an exact version like1.2.3, and development builds warn as before. LeavingeditorVersionout, or passing"latest", still loadslatest. -
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 undermissingParam: "drop". This is what a translator hits by typing{link}where the message needs<link>…</link>, both int()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 callst()and catches what that render throws. Until now the count the failed inner plural had set stayed behind, and the outer#printed it. -
1fb2046:
icuCompilerno 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_SYNTAXat ingestion in development, and in production the braced text renders literally and is reported once throughonError.
@comvi/cli
Patch Changes
- 1fb2046: Generated types accept a number for a bare
{name}and for aselectvariable: such a parameter is nowstring | number, notstring.t("errors.count", { count: 3 })against"Reported misses: {count}"no longer failstsc; the runtime always rendered it. Apluralorselectordinalcount staysnumber.comvi typegenandcomviTypes()still write byte-identical files, so a file with such a parameter has to be regenerated once after upgrading, orcomvi typegen --checkreports it as stale.
@comvi/next
Minor Changes
-
1fb2046: Behaviour change:
createNextI18nno longer composes browser-extension discovery. Until now it attached discovery to every Next app on its own, so the i18n instance was published onwindow.__COMVI__and got an auto-generatedinstanceId. Now a Next app is not visible to the Comvi browser extension, andi18n.instanceIdisundefined, 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());
devtoolsis also exported from@comvi/next/clientand@comvi/next/server. On the server, where there is nowindow, it publishes nothing and assigns no id.createNextI18nnever tookinstanceIdorexposeGlobal(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 setupClientProvider, and says that<ComviProvider>from@comvi/next/servermakes it unnecessary underwithComvi. - 1fb2046: Add
@comvi/next/proxyfor Next.js 16'sproxy.tsfile convention. Next 16 renamedmiddleware.tstoproxy.tswith the same request handler, so@comvi/next/proxyexports the samecreateMiddlewareas@comvi/next/middleware: writeexport default createMiddleware(routing)insrc/proxy.tson Next 16.@comvi/next/middlewarestays for Next 15'smiddleware.ts, and both subpaths hand out one function. Next 16 runsproxy.tson the Node.js runtime and rejects aruntimeoption there;createMiddlewareruns 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.resolveAcceptLanguageat build time withvm.Scriptinstead ofnew Function, so the module no longer usesnew 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 onlybaseUrl.apiBaseUrlis removed fromFetchLoaderOptions, so passing it is a type error. Precedence:baseUrl> build-time env overrides (VITE_API_BASE_URL/NEXT_PUBLIC_COMVI_API_URL) > the newcomviPresetexport, which carries the vendor defaults (https://api.comvi.io/https://cdn.comvi.io). Internally, the 948-linesrc/index.tsis split intooptions/http/cache/loadermodules with an unchanged public surface. -
09029b0: BREAKING (breaking change on a 0.x minor): the raw plugin factories are renamed to
fetchLoaderPluginandcdnLoaderPlugin. They used to differ from their installers by letter case alone —fetchLoadercomposed the capabilities and registered the plugin,FetchLoaderbuilt a plugin and composed nothing — so one mistyped capital silently changed what a chain did, and the failure surfaced asE_USE_NOT_INSTALLERor a missing-capability error rather than as a typo. ThePluginsuffix 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 }));
fetchLoaderandcdnLoader— the installers you pass to.use(…)— keep their names, and so does every option type (FetchLoaderOptions,CdnLoaderOptions), every installer type andI18nPluginFactory. Behaviour is unchanged on both names. The old names are removed, with no deprecated aliases. -
09029b0:
cdnLoaderis the package root;fetchLoaderand 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)andcdnLoaderPlugin(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'sapiKey, 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,
cdnLoaderdoes: one request perlocale:namespacewith concurrent
callers deduplicated, a timeout that covers the response body, thefallbackimport map,
cdnLayout, SSRcacheoptions, and the abort when the i18n instance clears a scope that is still
loading.CdnLoaderOptionsis the CDN subset ofFetchLoaderOptions:baseUrlandretryonly
describe the API request path, so naming one here is a compile error rather than a setting
that quietly does nothing.Import
fetchLoader()andfetchLoaderPlugin()(0.4'sFetchLoader()) from
@comvi/plugin-fetch-loader/apiwherever the i18n instance carries anapiKey. Both entries run
one shared CDN request path, so the two cannot drift. -
09029b0: Loads land through
addMessages(), and the late-compose diagnostic now recommendsreloadMessages(), matching the renamed@comvi/corecatalog methods. -
09029b0:
CDN_BASE_URLis not exported. Nothing in the package read it:cdnUrlis a required option with no default and no environment override, so the constant and itsVITE_CDN_BASE_URL/NEXT_PUBLIC_COMVI_CDN_URLreads answered a question the loader never asks.API_BASE_URLstays, since the API path really does fall back to it, and the hosted CDN default is readable ascomviPreset.cdnBaseUrl. -
09029b0: The project-info request is retried, within bounds. A 429, a 5xx or a
transport failure on/v1/projectnow 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 asetLocalecosts on a flapping CDN.The policy is deliberately narrow:
- a timeout is never retried, so a stalled server still fails as fast as
timeoutsays 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 owntimeout.
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: falseto the installer to switch it off:i18n.use(fetchLoader({ cdnUrl, retry: false }));
- a timeout is never retried, so a stalled server still fails as fast as
-
09029b0: Added:
fetchLoader(options)— the bare-name.use(…)installer.@comvi/coreis 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({ … })).fetchLoaderPluginbehaves exactly asFetchLoaderdid (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 isregisterPlugin. It composes@comvi/core/loaderand
@comvi/core/pluginsidempotently, so an instance that already has either keeps everything registered
on it. One timing difference is deliberate:fetchLoaderbuilds the plugin while it composes, so
a missingcdnUrlthrows at COMPOSITION rather than atinit(). 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 atinit()withE_INSTALLER_AS_PLUGIN, on the
installer's first ensure-step, namingfetchLoaderand the.useform, while
.use(fetchLoaderPlugin(…))runs the plugin against an instance that has neither capability — and on
one that has both it throwsE_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, andwithneeded explaining every time.usedoes not: Vue'sapp.use(plugin)callsinstall(app)synchronously and hands the app back for chaining, and Express'sapp.useand i18next's.use(module)are synchronous registration too. Souseis 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.corei18n.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 .useE_WITH_HOST_IDENTITYE_USE_INSTANCE_IDENTITYvue: the installer returned a different object WithHostIdentityErrorUseInstanceIdentityErrorthe exported type for that error E_INSTALLER_NESTED_USEE_INSTALLER_AS_PLUGINan installer was registered as a plugin — E_USE_NOT_INSTALLERNEW: .use(f)got something that is not an installerregisterPluginlines 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.usechain.definePlugin(fn)isattachPlugins(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/pluginsand re-exported by every binding root (@comvi/react,@comvi/solid,@comvi/svelte,@comvi/vue,@comvi/next/client,@comvi/next/server) besideplugins; the low-levelattachPluginsstays on@comvi/core/pluginsalone.plugins()attaches the plugins CAPABILITY;definePlugin(fn)registers ONE plugin on it.E_USE_NOT_INSTALLERis 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 unlessfhanded 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 overi18n.core, reported there under the wrapper's own code and with the same development wording, so the branchablecodeset 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 andany-typed callers. Vue'susekeeps itsT 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, thecompiler:option,useI18n(),useI18nLoader(),useI18nPlugins(),createI18nFromCore,createNextI18nFromInstance, Nuxt'si18nModule. (@comvi/vue'screateCorealias is gone — seevue-drop-create-core.md;attachPlugins/attachDevtoolskeep their names on@comvi/core/plugins/@comvi/core/devtools, off the binding roots — seeroots-drop-attach-pair.md.) (createNextI18n(...).usebecomes the same installer pipe in wave 2 — see the@comvi/nextentry.) Vue'sapp.use(i18n)and Nuxt'snuxtApp.usewere 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:
.with(→.use(everywhere, including on vue'sVueI18n.- 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*Pluginfactory's result (localeDetectorPlugin(),fetchLoaderPlugin(),inContextEditorPlugin()). - Prefer composing instead of queuing: an inline plugin becomes
.use(definePlugin(fn)), and a plugin package's*Pluginfactory becomes its bare-name installer in the chain (localeDetector(),fetchLoader(),cdnLoader(),inContextEditor()).
No
withalias survives anywhere, and the old error identities are gone: acatchthat branched onE_WITH_HOST_IDENTITYorE_INSTALLER_NESTED_USE, or a type import ofWithHostIdentityError, must be updated. Step 2 has no compile error on an instance typed asany, and a raw plugin left in.use()now throwsE_USE_NOT_INSTALLERat composition time — loudly, with the fix in the message — rather than running at the wrong moment.
Patch Changes
-
09029b0:
publishConfig.accessis now declared aspublicin every published manifest.access: "public"previously lived only in.changeset/config.json, so it applied to
changeset publishand 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.
fallbackLocalewas a lookup rule only. With a loader,init()andsetLocale()fetched the
active locale and nothing else, socreateI18n({ locale: 'de', fallbackLocale: 'en' })over a CDN
rendered a key missing fromdeas the key: theencatalog it was supposed to fall back to had
never been requested. The same held for ade-AT→de→enchain 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 onaddActiveNamespace(s)().setLocale()then finds the chain in the cache and
fetches the target locale alone. A catalog already in the cache, including
one passed asmessagesor 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 reachesonLoadErrorandonErrorlike any other and the operation
still succeeds. The locale's own catalog keeps its old rule, soinit()rejects when every
namespace of the active locale failed. A chain changed with
setFallbackLocale()afterinit()is loaded byreloadMessages()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()returnsfalsefor 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:missingKeyfires,onMissingKeyruns,
a per-callfallbackis 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.tsfiles now use extension-qualified relative specifiers (./options.js,
./cdn/index.js), so the published types resolve undermoduleResolution: node16and
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 formoduleResolution: "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-loaderentry that carriescdnLoaderwas 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 importingcdnLoaderfailed
withTS2305 ... has no exported member 'cdnLoader'.@comvi/coreand
@comvi/plugin-in-context-editorhad the same shape onexport 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, soXsilently becameany. With the
skipLibCheck: truenearly 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/nodenextproject may see NEW type errors in its own code. Those
errors were always there — the types that would have reported them wereany. Code written
against the real signatures needs no change.Bundler-resolution consumers (Vite, and Next.js projects on the
bundlerdefault) were
never affected — their types were correct throughout. Consumers onnode16/nodenextget
real types for the first time, with one exception outside our control:@comvi/next's
middleware and routing declarations importnext/serverandnext/link, andnext@15.5.9
ships noexportsfield, so those subpaths still do not resolve undernode16(3 ×
TS2307 raised inside@comvi/next's own declarations). Next.js projects on thebundler
resolution its own tooling sets are unaffected.pnpm check:types-exportsnow gates attw'snode16resolution mode in addition to
bundler. attw detects this defect perfectly well — 10 ×InternalResolutionErroron
@comvi/plugin-fetch-loaderalone,./cdn/indexamong 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 atypesbranch under a custom
condition —@comvi/plugin-in-context-editor's"types": { "production": … }— is never
visited, and that is exactly whereexport * from './entry-production'was hiding.@comvi/svelteand@comvi/nuxtwere 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
.jsextension in the SOURCES instead is legal undermoduleResolution: "bundler"and is
emitted verbatim, which would let the rewriter be deleted and would coversvelte-package
andnuxt-module-buildwithout per-package wiring — one mechanical sweep over 566
extensionless relative imports across 161 files. NeithermoduleResolution: node16in the emitting
tsconfig norrewriteRelativeImportExtensionsis a substitute. - The
-
09029b0: Node 18 reached end of life; the packages now declare
node >= 20. No code changed.@comvi/cliand@comvi/vite-pluginare 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'sLocale is not setand`messages` is not an objectmove 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) andFailed to refresh messages from the API.(editor). Production error codes such asE_ICU_SYNTAXare 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()andclearMessages("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
onLoadErrornor
the instance'sloadError, 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 andnullan 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 reachesonLoadErrorlike 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-loaderuser 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
(AbortErroris stillAbortError) and every thrown type are the same. Code
matching on message text needs updating; code matching onerror.namedoes not. -
09029b0:
.use(fetchLoader(…))and.use(cdnLoader(…))composed afterinit()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()throwsE_NO_LOADER_REGISTERED,addActiveNamespace()fetches nothing). TheloadOnInitJSDoc now says what the flag does on the public entry points: it does not suppress or add theinit()request; usens: []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; passbaseUrl, or setNEXT_PUBLIC_COMVI_API_URL, which is read fromprocess.envon 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 entries0and1, so the loader cached0:defaultand1:default
and the real keys were simply missing. The same held for a catalog that came
back as a string, a number ornull. 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 objectThe project lookup is checked too. Its
idis interpolated into the export URL
path, so a 200 that is not a project — noid, anullone, 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 stringThat 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, andonLoadErrornever 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 Nmserror a stalled connection produces.If you supply your own transport through
fetchFn, a non-OK response is now
also read for itsRetry-Afterheader. A response object withoutheadersis
handled, so a hand-written transport that answers with a bare
{ ok, status, statusText }keeps working. -
09029b0: The
cdnLoaderandcdnLoaderPlugindocumentation now points tofetchLoaderin@comvi/plugin-fetch-loader/apiand importsattachLoaderin its example. -
09029b0: A load that follows
reloadMessages()orclearMessages()now starts a new request instead of joining the abandoned one, so it no longer returns the stale catalog or the old request'sAbortError. -
09029b0: The
fetchLoaderPlugindocumentation example now composes the loader and plugin capabilities before callingregisterPlugin. -
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 placeholderhttps://cdn.comvi.io/your-distribution-idwas 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@exampledoc comments onfetchLoaderPluginand
cdnLoaderPluginmatch. The navigation was already correct and is unchanged. 23 occurrences across
12 files, comments only in the two.tsfiles.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 thenpm create vitescaffold it never had (its
fence importsreact-dom/client, and@comvi/reactpeer-depends onreactonly, so following the
README alone ended inERR_MODULE_NOT_FOUND), gained thesrc/App.tsxthat actually callst()
instead of describing it in prose, and closes with the dev command and the literal strings to
expect. Step 2 now rewritessrc/main.tsxas one copy-pasteable fence, matching the docs site
instead of inventing a second project shape around a newsrc/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.jsonso theprose/stale-size-figuregate still grades it, and the house
paragraphs (the ICU failure mode, the instance definition, the@comvi/coreformatters 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
Sourcemarker 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 strictstyle-src; the standalone build also exposes its ownversionand reports thedevtools()editor loader it found ingetStatus().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 thelocaleDetectorinstaller by one capital, which is not a difference a reader notices and not one a type error explains well. ThePluginsuffix names the thing you hand toregisterPlugin.-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
localeDetectorinstaller,LocaleDetectorOptions,LocaleDetectorInstallerandresolveLocalekeep their names, and behaviour is unchanged. On Next this is also the name insidenextPlugin(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/coreis
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.localeDetectorPluginbehaves exactly asLocaleDetectordid (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 isregisterPlugin. Composing onto an instance that already has the
capability installs nothing and keeps every plugin already registered. The exported installer
type isLocaleDetectorInstaller. 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, andwithneeded explaining every time.usedoes not: Vue'sapp.use(plugin)callsinstall(app)synchronously and hands the app back for chaining, and Express'sapp.useand i18next's.use(module)are synchronous registration too. Souseis 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.corei18n.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 .useE_WITH_HOST_IDENTITYE_USE_INSTANCE_IDENTITYvue: the installer returned a different object WithHostIdentityErrorUseInstanceIdentityErrorthe exported type for that error E_INSTALLER_NESTED_USEE_INSTALLER_AS_PLUGINan installer was registered as a plugin — E_USE_NOT_INSTALLERNEW: .use(f)got something that is not an installerregisterPluginlines 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.usechain.definePlugin(fn)isattachPlugins(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/pluginsand re-exported by every binding root (@comvi/react,@comvi/solid,@comvi/svelte,@comvi/vue,@comvi/next/client,@comvi/next/server) besideplugins; the low-levelattachPluginsstays on@comvi/core/pluginsalone.plugins()attaches the plugins CAPABILITY;definePlugin(fn)registers ONE plugin on it.E_USE_NOT_INSTALLERis 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 unlessfhanded 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 overi18n.core, reported there under the wrapper's own code and with the same development wording, so the branchablecodeset 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 andany-typed callers. Vue'susekeeps itsT 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, thecompiler:option,useI18n(),useI18nLoader(),useI18nPlugins(),createI18nFromCore,createNextI18nFromInstance, Nuxt'si18nModule. (@comvi/vue'screateCorealias is gone — seevue-drop-create-core.md;attachPlugins/attachDevtoolskeep their names on@comvi/core/plugins/@comvi/core/devtools, off the binding roots — seeroots-drop-attach-pair.md.) (createNextI18n(...).usebecomes the same installer pipe in wave 2 — see the@comvi/nextentry.) Vue'sapp.use(i18n)and Nuxt'snuxtApp.usewere 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:
.with(→.use(everywhere, including on vue'sVueI18n.- 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*Pluginfactory's result (localeDetectorPlugin(),fetchLoaderPlugin(),inContextEditorPlugin()). - Prefer composing instead of queuing: an inline plugin becomes
.use(definePlugin(fn)), and a plugin package's*Pluginfactory becomes its bare-name installer in the chain (localeDetector(),fetchLoader(),cdnLoader(),inContextEditor()).
No
withalias survives anywhere, and the old error identities are gone: acatchthat branched onE_WITH_HOST_IDENTITYorE_INSTALLER_NESTED_USE, or a type import ofWithHostIdentityError, must be updated. Step 2 has no compile error on an instance typed asany, and a raw plugin left in.use()now throwsE_USE_NOT_INSTALLERat composition time — loudly, with the fix in the message — rather than running at the wrong moment.
Patch Changes
-
09029b0:
publishConfig.accessis now declared aspublicin every published manifest.access: "public"previously lived only in.changeset/config.json, so it applied to
changeset publishand 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/cliand@comvi/vite-pluginare 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 internalwriteCacheshelper), 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.
WithoutsupportedLocales, 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=enlost to
the cached value, and an in-appsetLocale('en')after the failed init did not rewrite it.A locale detected during
init()is now written wheninitializedfires. AsetLocale()after
init is written at once, as before. Wheninit()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 andinit()fails again. The detector does not pick
another locale on its own; passsupportedLocales. -
09029b0: The locale detector warns in development when it has no
supportedLocales. Without the list
(and withoutconvertDetectedLocale) 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 thedevelopment
export condition, the same split@comvi/coreand@comvi/vuehave. 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 untilinitialized; asetLocale()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 beforeinit()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 duringinit()'s own detector call, the one time core can auto-apply it.
@comvi/react
Patch Changes
- 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/solid
Patch Changes
- 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/svelte
Patch Changes
- 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/vite-plugin
Patch Changes
- 1fb2046: Generated types accept a number for a bare
{name}and for aselectvariable: such a parameter is nowstring | number, notstring.t("errors.count", { count: 3 })against"Reported misses: {count}"no longer failstsc; the runtime always rendered it. Apluralorselectordinalcount staysnumber.comvi typegenandcomviTypes()still write byte-identical files, so a file with such a parameter has to be regenerated once after upgrading, orcomvi typegen --checkreports it as stale.