use crate::CompileError;
fn named_keys(value: &serde_json::Value) -> Vec<String> {
let Some(obj) = value.as_object() else { return Vec::new() };
obj.keys().filter(|k| is_safe_export_name(k)).cloned().collect()
}
fn is_safe_export_name(name: &str) -> bool {
const RESERVED: &[&str] = &[
"default", "class", "const", "let", "var", "function", "return", "import", "export",
"new", "delete", "void", "typeof", "in", "of", "do", "if", "else", "switch", "case",
"for", "while", "break", "continue", "this", "super", "null", "true", "false", "enum",
"await", "yield",
];
if RESERVED.contains(&name) {
return false;
}
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn parse(source: &str, url: &str) -> Result<serde_json::Value, CompileError> {
serde_json::from_str(source).map_err(|e| CompileError::Parse {
path: std::path::PathBuf::from(url),
message: format!("invalid JSON: {e}"),
})
}
pub fn to_esm(source: &str, url: &str) -> Result<String, CompileError> {
let value = parse(source, url)?;
let raw = source.trim();
let mut out = format!("const __oj_json = {raw};\nexport default __oj_json;\n");
for key in named_keys(&value) {
out.push_str(&format!("export const {key} = __oj_json[{key:?}];\n"));
}
Ok(out)
}
pub fn to_factory_body(source: &str, url: &str) -> Result<String, CompileError> {
let value = parse(source, url)?;
let raw = source.trim();
let mut getters = vec!["\"default\": () => __oj_json".to_string()];
for key in named_keys(&value) {
getters.push(format!("{key:?}: () => __oj_json[{key:?}]"));
}
Ok(format!(
"var __oj_json = {raw};\n__oj_esm(__oj_exports, {{ {} }});\n",
getters.join(", ")
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esm_default_and_named_exports() {
let out = to_esm(r#"{"name":"oj","version":2,"is-kebab":1}"#, "/x.json").unwrap();
assert!(out.contains("export default __oj_json"));
assert!(out.contains(r#"export const name = __oj_json["name"]"#), "{out}");
assert!(out.contains("export const version ="), "{out}");
assert!(!out.contains("is-kebab ="), "kebab key must be skipped: {out}");
}
#[test]
fn array_and_scalar_have_only_default() {
let arr = to_esm("[1, 2, 3]", "/a.json").unwrap();
assert!(arr.contains("const __oj_json = [1, 2, 3]"));
assert_eq!(arr.matches("export ").count(), 1, "only default: {arr}");
}
#[test]
fn reserved_key_default_is_skipped() {
let out = to_esm(r#"{"default":1,"ok":2}"#, "/r.json").unwrap();
assert!(!out.contains("export const default"), "{out}");
assert!(out.contains("export const ok ="), "{out}");
}
#[test]
fn factory_body_installs_getters() {
let out = to_factory_body(r#"{"a":1}"#, "/f.json").unwrap();
assert!(out.contains("var __oj_json = {\"a\":1}"), "{out}");
assert!(out.contains(r#""default": () => __oj_json"#), "{out}");
assert!(out.contains(r#""a": () => __oj_json["a"]"#), "{out}");
}
#[test]
fn invalid_json_errors() {
assert!(to_esm("{ not json", "/bad.json").is_err());
}
#[test]
fn filters_reserved_and_non_identifier_named_exports() {
let out = to_esm(
r#"{"class":1,"import":2,"await":3,"$ok":4,"_ok":5,"1bad":6,"a-b":7,"ok":8}"#,
"/k.json",
)
.unwrap();
assert_eq!(out.matches("export ").count(), 4, "only default + 3 valid keys: {out}");
for ok in ["$ok", "_ok", "ok"] {
assert!(out.contains(&format!("export const {ok} = __oj_json[")), "{ok} should export: {out}");
}
}
#[test]
fn nested_objects_and_scalars_export_only_default() {
let nested = to_esm(r#"{"outer":{"inner":1}}"#, "/n.json").unwrap();
assert!(nested.contains("export const outer ="), "{nested}");
assert!(!nested.contains("export const inner ="), "nested keys must not leak: {nested}");
assert_eq!(nested.matches("export ").count(), 2, "default + outer only: {nested}");
for (src, label) in [("\"hello\"", "string"), ("42", "number"), ("true", "bool"), ("null", "null")] {
let out = to_esm(src, "/s.json").unwrap();
assert_eq!(out.matches("export ").count(), 1, "{label} has only default: {out}");
assert!(out.contains("export default __oj_json"), "{label}: {out}");
}
}
#[test]
fn factory_body_filters_invalid_and_reserved_keys() {
let out = to_factory_body(r#"{"ok":1,"bad-key":2,"default":3}"#, "/f.json").unwrap();
assert!(out.contains(r#""ok": () => __oj_json["ok"]"#), "valid key getter: {out}");
assert!(!out.contains(r#""bad-key": () =>"#), "invalid key gets no getter: {out}");
assert_eq!(out.matches(r#""default": () =>"#).count(), 1, "one default getter: {out}");
}
#[test]
fn raw_json_formatting_is_preserved() {
let src = "{\n \"a\": 1,\n \"b\": 2\n}";
let out = to_esm(src, "/fmt.json").unwrap();
assert!(out.contains(src), "raw formatting must be preserved: {out}");
}
}