stelegen 0.0.10

JSON-first, type-safe i18n codegen with pluggable per-language emitters
use super::{Binding, Emitter};
use crate::ir::Ir;

/// Emits a framework-agnostic locale store — the single source of truth for the
/// active locale. It wraps the pure `create<Ty>` factory in a tiny observable +
/// persistable module singleton, so both React (via the `react` target's
/// `useSyncExternalStore` hooks) and plain non-React code read and write the
/// same locale. The supported-locale list and canonical locale are baked in for
/// the `resolveLocale` matcher; everything else is static.
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;
}
"#;

// --- Package mode: split runtime (.js) and declarations (.d.ts) -------------
// These reuse the same logic as the single-file store above; the runtime is the
// `.js` (no types), the declarations the `.d.ts`. Intra-package imports are
// fixed (`./index.js`) since the package layout is known.

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>;
"#;

/// The package `store.js` (runtime only).
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))
}

/// The package `store.d.ts` (declarations only).
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(", ");
        // Tokens are distinct; `__TY__` is replaced last so it can't clobber the
        // longer `__FACTORY__` / `__GETTER__` tokens that contain the type name.
        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 }"));
    }
}