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());
}
}