stelegen 0.0.8

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

/// Emits reactive React / React Native bindings over the generated copy. The
/// output is generic over the catalog (it just wraps the factory in a Context +
/// `useState`), so it ignores the IR entirely — only the import path to the core
/// module and the brand binding vary. Uses `createElement` rather than JSX so the
/// file needs no JSX build step and works the same in React DOM and React Native.
pub struct ReactEmitter {
    pub core: String,
    pub binding: Binding,
}

const TEMPLATE: &str = r#"// AUTO-GENERATED by stele — do not edit.
// Reactive React / React Native bindings over the generated copy.
// Changing the locale via setLocale re-renders every __HOOK__() consumer.
import {
  createContext,
  createElement,
  useContext,
  useMemo,
  useState,
  type ReactNode,
} from "react";
import { __FACTORY__, type __TY__, type Locale } from "__CORE__";

type __TY__ContextValue = {
  __FIELD__: __TY__;
  locale: Locale;
  setLocale: (locale: Locale) => void;
};

const __TY__Context = createContext<__TY__ContextValue | null>(null);

export function __PROVIDER__(props: { locale: Locale; children: ReactNode }) {
  const [locale, setLocale] = useState<Locale>(props.locale);
  const __FIELD__ = useMemo(() => __FACTORY__(locale), [locale]);
  const value = useMemo(() => ({ __FIELD__, locale, setLocale }), [__FIELD__, locale]);
  return createElement(__TY__Context.Provider, { value }, props.children);
}

export function __HOOK__(): __TY__ {
  const ctx = useContext(__TY__Context);
  if (ctx === null) {
    throw new Error("__HOOK__ must be used within a <__PROVIDER__>");
  }
  return ctx.__FIELD__;
}

export function useLocale(): [Locale, (locale: Locale) => void] {
  const ctx = useContext(__TY__Context);
  if (ctx === null) {
    throw new Error("useLocale must be used within a <__PROVIDER__>");
  }
  return [ctx.locale, ctx.setLocale];
}
"#;

impl Emitter for ReactEmitter {
    fn emit(&self, _ir: &Ir) -> String {
        // Tokens are distinct strings, so replacement order is safe even though
        // `__TY__` is a prefix of `__TY__Context` (that's intended → `<Ty>Context`).
        TEMPLATE
            .replace("__CORE__", &self.core)
            .replace("__FACTORY__", &self.binding.factory())
            .replace("__PROVIDER__", &self.binding.provider())
            .replace("__HOOK__", &self.binding.hook())
            .replace("__FIELD__", &self.binding.field)
            .replace("__TY__", &self.binding.ty)
    }
}

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

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

    #[test]
    fn substitutes_core_and_exports_default_stele_bindings() {
        let out = ReactEmitter {
            core: "./my-copy".into(),
            binding: Binding::new("stele"),
        }
        .emit(&empty_ir());
        assert!(out.contains("from \"./my-copy\""));
        assert!(out.contains("import { createStele, type Stele, type Locale }"));
        assert!(out.contains("export function SteleProvider"));
        assert!(out.contains("export function useStele(): Stele"));
        assert!(out.contains("export function useLocale"));
        assert!(out.contains("useStele must be used within a <SteleProvider>"));
        // no stray template tokens survived substitution
        assert!(!out.contains("__"));
    }

    #[test]
    fn binding_renames_the_whole_surface() {
        let out = ReactEmitter {
            core: "./copy.gen".into(),
            binding: Binding::new("copy"),
        }
        .emit(&empty_ir());
        assert!(out.contains("export function CopyProvider"));
        assert!(out.contains("export function useCopy(): Copy"));
        assert!(out.contains("import { createCopy, type Copy, type Locale }"));
    }
}