use super::{Binding, Emitter};
use crate::ir::Ir;
pub struct StoreEmitter {
pub core: String,
pub binding: Binding,
}
const TEMPLATE: &str = r#"// AUTO-GENERATED by stele — do not edit.
// Framework-agnostic locale store — the single source of truth for the active
// locale. Tracks a preference ("system" = follow the device, or a pinned
// Locale), is observable (subscribe) and persistable. React binds to it via
// useSyncExternalStore; non-React code reads/writes it directly.
import { __FACTORY__, type __TY__, type Locale } from "__CORE__";
export type { __TY__, Locale };
/** What the user wants: follow the device ("system") or a pinned locale. */
export type LocalePref = "system" | Locale;
/** Pluggable persistence — supply your own AsyncStorage / localStorage adapter. */
export interface LocaleStorage {
load(): Promise<LocalePref | null> | LocalePref | null;
save(pref: LocalePref): Promise<void> | void;
}
const LOCALES = [__LOCALE_LIST__] as const;
const CANONICAL: Locale = __CANONICAL__;
let _locale: Locale = CANONICAL;
let _current: __TY__ = __FACTORY__(_locale);
let _pref: LocalePref = "system";
let _deviceLocales: () => readonly string[] = () => [];
let _storage: LocaleStorage | null = null;
const listeners = new Set<() => void>();
function notify(): void {
for (const listener of listeners) listener();
}
function resolve(locale: Locale): void {
if (locale === _locale) return;
_locale = locale;
_current = __FACTORY__(locale);
}
/** The active (resolved) locale. */
export function getLocale(): Locale {
return _locale;
}
/** The accessor bound to the active locale (stable until the locale changes). */
export function __GETTER__(): __TY__ {
return _current;
}
/** Whether the active locale is currently following the device ("system"). */
export function isFollowingDevice(): boolean {
return _pref === "system";
}
/** Subscribe to locale / preference changes; returns an unsubscribe function. */
export function subscribeLocale(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Pin an explicit locale (stops following the device); persists the choice. */
export function setLocale(locale: Locale): void {
_pref = locale;
resolve(locale);
notify();
void _storage?.save(locale);
}
/** Follow the device locale from now on; re-resolves now and persists "system". */
export function followDevice(): void {
_pref = "system";
resolve(resolveLocale(_deviceLocales()));
notify();
void _storage?.save("system");
}
/**
* Re-read the device locale, but only while in system mode. Wire this to an
* AppState "active" listener to track the OS language changing at runtime.
*/
export function syncDevice(): void {
if (_pref !== "system") return;
const next = resolveLocale(_deviceLocales());
if (next === _locale) return;
resolve(next);
notify();
}
/** Match arbitrary BCP-47 tags (e.g. device locales) to a supported Locale. */
export function resolveLocale(tags: readonly string[]): Locale {
for (const tag of tags) {
const lower = tag.toLowerCase();
const exact = LOCALES.find((l) => l.toLowerCase() === lower);
if (exact) return exact;
const primary = lower.split("-")[0];
const base = LOCALES.find((l) => l.toLowerCase().split("-")[0] === primary);
if (base) return base;
}
return CANONICAL;
}
/**
* Initialize the store once at startup: restore the saved preference (or default
* to following the device), wire the device-locale source, and apply the result.
* Pass `deviceLocales` (e.g. `() => Localization.locales`) to enable system mode.
* Returns the resolved active locale. Call once before your first render.
*/
export async function initLocale(opts?: {
storage?: LocaleStorage;
deviceLocales?: () => readonly string[];
initial?: LocalePref;
}): Promise<Locale> {
_storage = opts?.storage ?? null;
_deviceLocales = opts?.deviceLocales ?? (() => []);
let pref: LocalePref | null = opts?.initial ?? null;
if (pref === null && _storage !== null) pref = await _storage.load();
_pref = pref ?? "system";
if (_pref !== "system" && !(LOCALES as readonly Locale[]).includes(_pref)) {
_pref = "system";
}
resolve(_pref === "system" ? resolveLocale(_deviceLocales()) : _pref);
notify();
return _locale;
}
"#;
const STORE_JS: &str = r#"// AUTO-GENERATED by stele — do not edit.
import { __FACTORY__ } from "./index.js";
const LOCALES = [__LOCALE_LIST__];
const CANONICAL = __CANONICAL__;
let _locale = CANONICAL;
let _current = __FACTORY__(_locale);
let _pref = "system";
let _deviceLocales = () => [];
let _storage = null;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
function resolve(locale) {
if (locale === _locale) return;
_locale = locale;
_current = __FACTORY__(locale);
}
export function getLocale() {
return _locale;
}
export function __GETTER__() {
return _current;
}
export function isFollowingDevice() {
return _pref === "system";
}
export function subscribeLocale(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setLocale(locale) {
_pref = locale;
resolve(locale);
notify();
void _storage?.save(locale);
}
export function followDevice() {
_pref = "system";
resolve(resolveLocale(_deviceLocales()));
notify();
void _storage?.save("system");
}
export function syncDevice() {
if (_pref !== "system") return;
const next = resolveLocale(_deviceLocales());
if (next === _locale) return;
resolve(next);
notify();
}
export function resolveLocale(tags) {
for (const tag of tags) {
const lower = tag.toLowerCase();
const exact = LOCALES.find((l) => l.toLowerCase() === lower);
if (exact) return exact;
const primary = lower.split("-")[0];
const base = LOCALES.find((l) => l.toLowerCase().split("-")[0] === primary);
if (base) return base;
}
return CANONICAL;
}
export async function initLocale(opts) {
_storage = opts?.storage ?? null;
_deviceLocales = opts?.deviceLocales ?? (() => []);
let pref = opts?.initial ?? null;
if (pref === null && _storage !== null) pref = await _storage.load();
_pref = pref ?? "system";
if (_pref !== "system" && !LOCALES.includes(_pref)) _pref = "system";
resolve(_pref === "system" ? resolveLocale(_deviceLocales()) : _pref);
notify();
return _locale;
}
"#;
const STORE_DTS: &str = r#"// AUTO-GENERATED by stele — do not edit.
import type { __TY__, Locale } from "./index.js";
export type { __TY__, Locale };
/** What the user wants: follow the device ("system") or a pinned locale. */
export type LocalePref = "system" | Locale;
/** Pluggable persistence — supply your own AsyncStorage / localStorage adapter. */
export interface LocaleStorage {
load(): Promise<LocalePref | null> | LocalePref | null;
save(pref: LocalePref): Promise<void> | void;
}
export declare function getLocale(): Locale;
export declare function __GETTER__(): __TY__;
export declare function isFollowingDevice(): boolean;
export declare function subscribeLocale(listener: () => void): () => void;
export declare function setLocale(locale: Locale): void;
export declare function followDevice(): void;
export declare function syncDevice(): void;
export declare function resolveLocale(tags: readonly string[]): Locale;
export declare function initLocale(opts?: {
storage?: LocaleStorage;
deviceLocales?: () => readonly string[];
initial?: LocalePref;
}): Promise<Locale>;
"#;
pub fn store_js(ir: &Ir, binding: &Binding) -> String {
let locale_list = ir
.locales
.iter()
.map(|l| format!("\"{l}\""))
.collect::<Vec<_>>()
.join(", ");
STORE_JS
.replace("__FACTORY__", &binding.factory())
.replace("__GETTER__", &binding.getter())
.replace("__LOCALE_LIST__", &locale_list)
.replace("__CANONICAL__", &format!("\"{}\"", ir.canonical))
}
pub fn store_dts(binding: &Binding) -> String {
STORE_DTS
.replace("__GETTER__", &binding.getter())
.replace("__TY__", &binding.ty)
}
impl Emitter for StoreEmitter {
fn emit(&self, ir: &Ir) -> String {
let locale_list = ir
.locales
.iter()
.map(|l| format!("\"{l}\""))
.collect::<Vec<_>>()
.join(", ");
TEMPLATE
.replace("__CORE__", &self.core)
.replace("__FACTORY__", &self.binding.factory())
.replace("__GETTER__", &self.binding.getter())
.replace("__LOCALE_LIST__", &locale_list)
.replace("__CANONICAL__", &format!("\"{}\"", ir.canonical))
.replace("__TY__", &self.binding.ty)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn ir() -> Ir {
Ir {
canonical: "en".into(),
locales: vec!["en".into(), "es".into()],
messages: vec![],
plural_rules: BTreeMap::new(),
}
}
#[test]
fn emits_store_surface_with_default_binding() {
let out = StoreEmitter {
core: "./stele.gen".into(),
binding: Binding::new("stele"),
}
.emit(&ir());
assert!(
out.contains("import { createStele, type Stele, type Locale } from \"./stele.gen\"")
);
assert!(out.contains("export function getStele(): Stele"));
assert!(out.contains("export function setLocale(locale: Locale): void"));
assert!(out.contains("export function getLocale(): Locale"));
assert!(out.contains("export function followDevice(): void"));
assert!(out.contains("export function syncDevice(): void"));
assert!(out.contains("export function isFollowingDevice(): boolean"));
assert!(out.contains("export type LocalePref = \"system\" | Locale"));
assert!(out.contains("export function subscribeLocale"));
assert!(out.contains("export function resolveLocale"));
assert!(out.contains("export async function initLocale"));
assert!(out.contains("const LOCALES = [\"en\", \"es\"] as const"));
assert!(out.contains("const CANONICAL: Locale = \"en\""));
assert!(!out.contains("__"));
}
#[test]
fn binding_renames_the_getter() {
let out = StoreEmitter {
core: "./copy.gen".into(),
binding: Binding::new("copy"),
}
.emit(&ir());
assert!(out.contains("export function getCopy(): Copy"));
assert!(out.contains("import { createCopy, type Copy, type Locale }"));
}
}