stelegen 0.0.10

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

/// Emits reactive React / React Native hooks bound to the `store` target. The
/// store is the single source of truth for the active locale; the hooks just
/// subscribe to it via `useSyncExternalStore`, so there's no Provider to mount
/// and `setLocale` works from anywhere (component or not). Ignores the IR
/// entirely — only the import path to the store and the brand binding vary. Uses
/// no JSX, so it works the same on React DOM and React Native.
pub struct ReactEmitter {
    pub store: String,
    pub binding: Binding,
}

const TEMPLATE: &str = r#"// AUTO-GENERATED by stele — do not edit.
// React / React Native hooks bound to the locale store. Calling setLocale
// anywhere (see the store module) re-renders every __HOOK__() consumer. There's
// no Provider to mount — the store is the single source of truth.
import { useSyncExternalStore } from "react";
import {
  __GETTER__,
  getLocale,
  setLocale,
  subscribeLocale,
  isFollowingDevice,
  type __TY__,
  type Locale,
} from "__STORE__";

export function __HOOK__(): __TY__ {
  return useSyncExternalStore(subscribeLocale, __GETTER__, __GETTER__);
}

export function useLocale(): [Locale, (locale: Locale) => void] {
  const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale);
  return [locale, setLocale];
}

/** Whether the active locale is following the device (for a "System" toggle). */
export function useFollowingDevice(): boolean {
  return useSyncExternalStore(
    subscribeLocale,
    isFollowingDevice,
    isFollowingDevice,
  );
}
"#;

// --- Package mode: split runtime (.js) and declarations (.d.ts) -------------
// Same hooks as the single-file react target; intra-package import is fixed
// (`./store.js`) since the package layout is known.

const REACT_JS: &str = r#"// AUTO-GENERATED by stele — do not edit.
import { useSyncExternalStore } from "react";
import {
  __GETTER__,
  getLocale,
  setLocale,
  subscribeLocale,
  isFollowingDevice,
} from "./store.js";

export function __HOOK__() {
  return useSyncExternalStore(subscribeLocale, __GETTER__, __GETTER__);
}

export function useLocale() {
  const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale);
  return [locale, setLocale];
}

export function useFollowingDevice() {
  return useSyncExternalStore(
    subscribeLocale,
    isFollowingDevice,
    isFollowingDevice,
  );
}
"#;

const REACT_DTS: &str = r#"// AUTO-GENERATED by stele — do not edit.
import type { __TY__, Locale } from "./store.js";

export declare function __HOOK__(): __TY__;
export declare function useLocale(): [Locale, (locale: Locale) => void];
export declare function useFollowingDevice(): boolean;
"#;

/// The package `react.js` (runtime only).
pub fn react_js(binding: &Binding) -> String {
    REACT_JS
        .replace("__GETTER__", &binding.getter())
        .replace("__HOOK__", &binding.hook())
}

/// The package `react.d.ts` (declarations only).
pub fn react_dts(binding: &Binding) -> String {
    REACT_DTS
        .replace("__HOOK__", &binding.hook())
        .replace("__TY__", &binding.ty)
}

impl Emitter for ReactEmitter {
    fn emit(&self, _ir: &Ir) -> String {
        // Tokens are distinct; `__TY__` is replaced last so it can't clobber the
        // longer `__GETTER__` / `__HOOK__` tokens that contain the type name.
        TEMPLATE
            .replace("__STORE__", &self.store)
            .replace("__GETTER__", &self.binding.getter())
            .replace("__HOOK__", &self.binding.hook())
            .replace("__TY__", &self.binding.ty)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::emit::Binding;
    use std::collections::BTreeMap;

    fn empty_ir() -> Ir {
        Ir {
            canonical: "en".into(),
            locales: vec!["en".into()],
            messages: vec![],
            plural_rules: BTreeMap::new(),
        }
    }

    #[test]
    fn binds_hooks_to_the_store_with_default_binding() {
        let out = ReactEmitter {
            store: "./stele.store".into(),
            binding: Binding::new("stele"),
        }
        .emit(&empty_ir());
        assert!(out.contains("from \"./stele.store\""));
        assert!(out.contains("useSyncExternalStore"));
        assert!(out.contains("export function useStele(): Stele"));
        assert!(out.contains("useSyncExternalStore(subscribeLocale, getStele, getStele)"));
        assert!(out.contains("export function useLocale"));
        // the Provider/Context model is gone
        assert!(!out.contains("SteleProvider"));
        assert!(!out.contains("createContext"));
        assert!(!out.contains("__"));
    }

    #[test]
    fn binding_renames_the_hook_and_getter() {
        let out = ReactEmitter {
            store: "./stele.store".into(),
            binding: Binding::new("copy"),
        }
        .emit(&empty_ir());
        assert!(out.contains("export function useCopy(): Copy"));
        assert!(out.contains("subscribeLocale, getCopy, getCopy"));
    }
}