@magnet-js/locale
v0.2.0
Published
Locale state management and negotiation for Magnet.
Readme
@magnet/locale
Locale state management and negotiation for Magnet.
@magnet/locale resolves which of your supported locales is active — from an
explicit pick, a stored choice, or the browser's language preferences — and
keeps lang/dir attributes and a reactive signal in sync with the result. No
routing, no message catalogs: just locale state.
Install
JSR
deno add jsr:@magnet/localenpm via JSR
npx jsr add @magnet/localenpm
npm install @magnet-js/localeUsage
import { locale, localeManager } from "@magnet/locale";
import { tc39 } from "@magnet/signal";
import { magnet } from "@magnet/ui";
const m = magnet({ window, ...tc39 });
const locales = [
locale("en", "English", [/^en-/]),
locale("pt", "Português", [/^pt-/]),
];
const current = localeManager(
m,
locales,
document.documentElement,
undefined, // dirElement — defaults to the lang element
undefined, // initial — pass the server-rendered tag during hydration
{ localStorage: {} }, // enables persistence
);
current.get(); // the resolved Locale object (a computed: read it anywhere)
current.selected.get(); // the user's pick as a Locale, or null
current.id = "pt"; // explicit pick: lang="pt", translate="no", persisted
current.id = null; // reset to the browser preference, storage clearedHow the active locale is resolved
At creation, candidates are tried in priority order:
- The
initialparameter — the SSR/URL seam. Pass the tag the server rendered (read it from the route) so the client starts exactly where the HTML is, with no flash of the wrong language. - The stored pick in
localStorage(only when persistence is enabled). navigator.languages, in browser preference order.- The first registered locale, as the default.
Matching is exact for strings and a regexp test for RegExp accepts
(lastIndex is reset before each test, so shared /g regexps are safe). Every
locale implicitly accepts its own id tag; extra accepts go first. When several
locales accept the same tag, the earliest registered one wins.
Per-locale details payloads
Each Locale can carry an arbitrary payload of your own, typed by the D type
parameter and passed via the details option — display metadata, picker icons,
or a translations dictionary to resolve the locale's strings from. The manager
ignores it; read it back from locale.details:
const locales = [
locale("en", "English", undefined, {
details: { flag: "🇬🇧", dir: "ltr" },
}),
locale("ar", "العربية", undefined, {
rtl: true,
details: { flag: "🇸🇦", dir: "rtl" },
}),
];
current.get().details?.flag; // "🇸🇦" when Arabic is activeExplicit picks and translate="no"
Assigning manager.id = tag makes an explicit pick: the tag is matched via
accepts, and if it matches no locale, resolution falls through to the browser
preference — picks are never fatal. Assigning a falsy value clears the pick and
the stored value, returning to the browser preference.
By default the lang element is marked translate="no" from creation and stays
marked, so machine translators are discouraged from offering to translate the
page. Pass notranslate: false to gate the marker on deliberate picks instead:
it is then set only when a pick matches exactly and removed when a later pick
falls through or is reset — the page language is no longer a deliberate choice
there, so translation offers become useful again.
Persistence
Persistence is opt-in: pass options.localStorage ({} for the conventional
"magnet-locale" key, or { key: "my-key" } to override it). Picks are written
on selection and the key is removed on reset. Storage access is crash-safe: a
throwing or absent localStorage silently disables persistence without
affecting resolution.
Loading translations
loader resolves the active locale's translations reactively. Give it per-tag
loader functions — plain functions for bundled translations, dynamic import()
for code-split ones — and it keeps a translations signal in sync with the
manager:
import { loader } from "@magnet/locale";
const [t, stop] = loader(tc39, current, {
en: () => import("./en.ts"),
pt: () => import("./pt.ts"),
});
t.get(); // translations for the active locale (null while first loading)
t.loading.get(); // true while an async load is in flight
stop(); // dispose the effect with the appAsync results are cached per tag and only applied while the tag remains the
active locale: switching languages mid-load can never let the stale resolution
overwrite the translations or the loading flag. Locales without a loader — and
rejected loads — resolve to the fallback argument (default null).
What's deliberately not here
Accept-Language parsing, URL prefixes, and hreflang link generation live at
the framework edge (the Astro layer), not in this package — pass the URL's
locale through initial instead. There are no cookies either: the URL carries
the locale, localStorage only remembers the pick.
License
MIT © 2026 Fernando G. Vilar.
