use crate::object_definition::{ObjectDefinition, ObjectDefinitions};
use crate::FieldType;
use std::collections::{HashMap, HashSet};
const HEADER: &str = "// AUTO-GENERATED by archival - do not edit.";
const ROOT_TYPE: &str = "ArchivalObjects";
const FILE_TYPE: &str = "ArchivalFile";
const META_TYPE: &str = "ArchivalMeta";
const PREAMBLE: &str = r#"/** An uploaded file. `url` is "" until something has been uploaded. */
export interface ArchivalFile {
display_type: "image" | "video" | "audio" | "upload";
filename: string;
sha: string;
mime: string;
name?: string;
description?: string;
url: string;
}
/** A `meta` field: free-form TOML, converted to JSON. */
export type ArchivalMeta =
| string
| number
| boolean
| null
| ArchivalMeta[]
| { [key: string]: ArchivalMeta };"#;
const INJECTED: [&str; 2] = ["path", "order"];
fn ts_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| format!("\"{}\"", value))
}
fn is_identifier(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn property_name(name: &str) -> String {
if is_identifier(name) {
name.to_string()
} else {
ts_string(name)
}
}
fn pascal_case(name: &str) -> String {
let joined: String = name
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect();
if joined
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
joined
} else {
format!("Archival{}", joined)
}
}
struct Names {
used: HashSet<String>,
}
impl Names {
fn new() -> Self {
Names {
used: HashSet::from([
ROOT_TYPE.to_string(),
FILE_TYPE.to_string(),
META_TYPE.to_string(),
]),
}
}
fn take(&mut self, base: &str) -> String {
let mut name = format!("{}Object", base);
let mut n = 2;
while self.used.contains(&name) {
name = format!("{}Object{}", base, n);
n += 1;
}
self.used.insert(name.clone());
name
}
}
fn field_type(field: &FieldType) -> String {
match field {
FieldType::Alias(alias) => field_type(&alias.0),
FieldType::String | FieldType::Markdown => "string | null".to_string(),
FieldType::Secret => "string | null".to_string(),
FieldType::Number => "number | null".to_string(),
FieldType::Boolean => "boolean | null".to_string(),
FieldType::Date => "string | null".to_string(),
FieldType::Enum(values) => {
if values.is_empty() {
"string | null".to_string()
} else {
let variants: Vec<String> = values.iter().map(|v| ts_string(v)).collect();
format!("{} | null", variants.join(" | "))
}
}
FieldType::Image | FieldType::Video | FieldType::Audio | FieldType::Upload => {
format!("{} | null", FILE_TYPE)
}
FieldType::Meta => format!("{} | null", META_TYPE),
FieldType::Oneof(options) => {
if options.is_empty() {
return "null".to_string();
}
let branches: Vec<String> = options
.iter()
.map(|option| {
format!(
"{{ type: {}; value: {} }}",
ts_string(&option.name),
field_type(&option.r#type)
)
})
.collect();
format!("{} | null", branches.join(" | "))
}
}
}
fn emit_object(
base: &str,
definition: &ObjectDefinition,
injected: bool,
names: &mut Names,
out: &mut Vec<String>,
) -> String {
let type_name = names.take(base);
let slot = out.len();
out.push(String::new());
let mut members: Vec<String> = Vec::new();
if injected {
members.push(" /** `<object name>/<file name>` this object was read from. */".to_string());
members.push(" path: string;".to_string());
members.push(" /** The object's `order`, or null when it is unordered. */".to_string());
members.push(" order: number | null;".to_string());
}
for (field, field_definition) in &definition.fields {
if injected && INJECTED.contains(&field.as_str()) {
continue;
}
if let Some(description) = &field_definition.description {
members.push(doc_comment(description, " "));
}
members.push(format!(
" {}: {};",
property_name(field),
field_type(&field_definition.r#type)
));
}
for (child, child_definition) in &definition.children {
let child_type = emit_object(
&format!("{}{}", base, pascal_case(child)),
child_definition,
false,
names,
out,
);
if let Some(description) = &child_definition.description {
members.push(doc_comment(description, " "));
}
members.push(format!(" {}: {}[];", property_name(child), child_type));
}
let mut doc = if injected {
String::new()
} else {
"// Child objects are read from their parent's file, so they have no path/order.\n"
.to_string()
};
if let Some(description) = &definition.description {
doc.push_str(&doc_comment(description, ""));
doc.push('\n');
}
out[slot] = format!(
"{}export interface {} {{\n{}\n}}",
doc,
type_name,
members.join("\n")
);
type_name
}
fn doc_comment(description: &str, indent: &str) -> String {
let description = description.replace("*/", "*\\/");
let mut lines = description.lines();
let first = lines.next().unwrap_or_default();
match lines.next() {
None => format!("{indent}/** {first} */"),
Some(second) => {
let rest = std::iter::once(first)
.chain(std::iter::once(second))
.chain(lines)
.map(|line| format!("{indent} * {line}").trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
format!("{indent}/**\n{rest}\n{indent} */")
}
}
}
pub fn generate_typescript_defs(
objects: &ObjectDefinitions,
root_objects: &HashSet<String>,
) -> String {
let mut names = Names::new();
let mut declarations: Vec<String> = Vec::new();
let mut members: Vec<String> = Vec::new();
let mut types: HashMap<&String, String> = HashMap::new();
for (name, definition) in objects {
let type_name = emit_object(
&pascal_case(name),
definition,
true,
&mut names,
&mut declarations,
);
types.insert(name, type_name);
}
for (name, definition) in objects {
let type_name = &types[name];
let is_root = root_objects.contains(name);
if let Some(description) = &definition.description {
members.push(doc_comment(description, " "));
}
members.push(format!(
" {}: {}{};",
property_name(name),
type_name,
if is_root { "" } else { "[]" }
));
}
let root = format!(
"/** Every object in the site, keyed by name. */\nexport interface {} {{\n{}\n}}",
ROOT_TYPE,
members.join("\n")
);
let mut sections = vec![HEADER.to_string(), PREAMBLE.to_string()];
sections.extend(declarations);
sections.push(root);
format!("{}\n", sections.join("\n\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object_definition::ObjectDefinition;
use anyhow::Result;
use ordermap::OrderMap;
fn defs(toml: &str) -> Result<ObjectDefinitions> {
ObjectDefinition::from_source(toml, &OrderMap::new())
}
fn generate(toml: &str, roots: &[&str]) -> Result<String> {
let roots: HashSet<String> = roots.iter().map(|r| r.to_string()).collect();
Ok(generate_typescript_defs(&defs(toml)?, &roots))
}
#[test]
fn empty_site() -> Result<()> {
let out = generate("", &[])?;
assert!(out.contains("export interface ArchivalObjects {"));
assert!(out.contains("export interface ArchivalFile {"));
Ok(())
}
#[test]
fn scalar_fields() -> Result<()> {
let out = generate(
r#"
[posts]
title = "string"
body = "markdown"
views = "number"
draft = "boolean"
published = "date"
token = "secret"
"#,
&[],
)?;
assert!(out.contains("title: string | null;"), "{}", out);
assert!(out.contains("body: string | null;"));
assert!(out.contains("views: number | null;"));
assert!(out.contains("draft: boolean | null;"));
assert!(out.contains("published: string | null;"));
assert!(out.contains("token: string | null;"));
Ok(())
}
#[test]
fn injects_path_and_order_on_top_level_objects_only() -> Result<()> {
let out = generate(
r#"
[events]
name = "string"
[events.tickets]
url = "string"
"#,
&[],
)?;
assert!(out.contains("export interface EventsObject {"));
assert!(out.contains(" path: string;"));
assert!(out.contains(" order: number | null;"));
let child = out
.split("export interface EventsTicketsObject {")
.nth(1)
.expect("no child interface")
.split("}")
.next()
.unwrap();
assert!(!child.contains("path:"), "child had path: {}", child);
assert!(!child.contains("order:"), "child had order: {}", child);
assert!(out.contains("tickets: EventsTicketsObject[];"));
Ok(())
}
#[test]
fn lists_roots_and_absent_objects() -> Result<()> {
let out = generate(
r#"
[posts]
title = "string"
[settings]
contact = "string"
"#,
&["settings"],
)?;
assert!(out.contains("posts: PostsObject[];"), "{}", out);
assert!(out.contains("settings: SettingsObject;"), "{}", out);
Ok(())
}
#[test]
fn enums_become_literal_unions() -> Result<()> {
let out = generate(
r#"
[posts]
genre = ["emo", "metal"]
"#,
&[],
)?;
assert!(out.contains(r#"genre: "emo" | "metal" | null;"#), "{}", out);
Ok(())
}
#[test]
fn files_and_meta() -> Result<()> {
let out = generate(
r#"
[posts]
hero = "image"
clip = "video"
attachment = "upload"
extra = "meta"
"#,
&[],
)?;
assert!(out.contains("hero: ArchivalFile | null;"));
assert!(out.contains("clip: ArchivalFile | null;"));
assert!(out.contains("attachment: ArchivalFile | null;"));
assert!(out.contains("extra: ArchivalMeta | null;"));
Ok(())
}
#[test]
fn oneofs_become_discriminated_unions() -> Result<()> {
let out = generate(
r#"
[posts]
[[posts.media]]
name = "video"
type = "video"
[[posts.media]]
name = "link"
type = "string"
"#,
&[],
)?;
assert!(
out.contains(
r#"media: { type: "video"; value: ArchivalFile | null } | { type: "link"; value: string | null } | null;"#
),
"{}",
out
);
Ok(())
}
#[test]
fn resolves_editor_type_aliases() -> Result<()> {
let source = r#"
[posts]
hero = "hero_image"
"#;
let mut editor_types = OrderMap::new();
editor_types.insert(
"hero_image".to_string(),
crate::manifest::ManifestEditorType {
alias_of: "image".to_string(),
validate: vec![],
editor_url: String::new(),
},
);
let definitions = ObjectDefinition::from_source(source, &editor_types)?;
let out = generate_typescript_defs(&definitions, &HashSet::new());
assert!(out.contains("hero: ArchivalFile | null;"), "{}", out);
Ok(())
}
#[test]
fn sanitizes_names() -> Result<()> {
let out = generate(
r#"
["my-object"]
"weird key" = "string"
"#,
&[],
)?;
assert!(out.contains("export interface MyObjectObject {"), "{}", out);
assert!(out.contains(r#""weird key": string | null;"#), "{}", out);
assert!(out.contains(r#""my-object": MyObjectObject[];"#), "{}", out);
Ok(())
}
#[test]
fn allocates_around_name_collisions() -> Result<()> {
let out = generate(
r#"
[posts]
title = "string"
["posts-2"]
title = "string"
"#,
&[],
)?;
assert!(out.contains("export interface PostsObject {"), "{}", out);
assert!(out.contains("export interface Posts2Object {"), "{}", out);
Ok(())
}
#[test]
fn never_shadows_the_preamble_types() -> Result<()> {
let out = generate(
r#"
["archival_file"]
a = "string"
["archival_meta"]
b = "string"
"#,
&[],
)?;
assert_eq!(out.matches("export interface ArchivalFile {").count(), 1);
assert!(
out.contains("export interface ArchivalFileObject {"),
"{}",
out
);
assert!(
out.contains("export interface ArchivalMetaObject {"),
"{}",
out
);
Ok(())
}
#[test]
fn is_deterministic() -> Result<()> {
let toml = r#"
[posts]
title = "string"
[posts.tags]
name = "string"
[settings]
contact = "string"
"#;
assert_eq!(
generate(toml, &["settings"])?,
generate(toml, &["settings"])?
);
Ok(())
}
#[test]
fn descriptions_become_jsdoc() -> Result<()> {
let out = generate(
r#"
# A blog post.
[posts]
# The headline.
title = "string"
body = "markdown"
# When it was written.
# Rendered with the `date` filter.
published = "date"
"#,
&[],
)?;
assert!(
out.contains("/** A blog post. */\nexport interface"),
"{out}"
);
assert!(
out.contains(" /** The headline. */\n title: string | null;"),
"{out}"
);
assert!(
out.contains(
" /**\n * When it was written.\n * Rendered with the `date` filter.\n */\n published: string | null;"
),
"{out}"
);
assert!(out.contains("\n body: string | null;"), "{out}");
Ok(())
}
#[test]
fn object_descriptions_reach_every_place_they_are_referenced() -> Result<()> {
let out = generate(
r#"
# A blog post.
[posts]
title = "string"
# Related links.
[posts.links]
url = "string"
"#,
&[],
)?;
assert!(
out.contains("/** Related links. */\nexport interface PostsLinksObject {"),
"{out}"
);
assert!(
out.contains(" /** Related links. */\n links: PostsLinksObject[];"),
"{out}"
);
assert!(
out.contains(" /** A blog post. */\n posts: PostsObject[];"),
"{out}"
);
Ok(())
}
#[test]
fn a_description_cannot_end_its_own_comment() -> Result<()> {
let out = generate(
r#"
[posts]
# Not a terminator: */ still inside.
title = "string"
"#,
&[],
)?;
assert!(out.contains(r"*\/ still inside."), "{out}");
let line = out
.lines()
.find(|l| l.contains("still inside"))
.expect("description not emitted");
assert_eq!(line.matches("*/").count(), 1, "{line}");
Ok(())
}
}