Skip to main content

dmc/engine/
schema_ts.rs

1//! `dmc-schema` descriptor -> TS type emitter for generated `index.d.ts`.
2//! Unknown shapes fall back to `unknown` (build never fails on an
3//! unrecognised descriptor).
4
5use serde_json::Value;
6
7use crate::engine::utils::is_js_ident;
8
9const INDENT: &str = "  ";
10
11pub fn schema_to_ts(v: &Value, indent: usize) -> String {
12  let kind = match v.get("kind").and_then(|k| k.as_str()) {
13    Some(k) => k,
14    None => return "unknown".into(),
15  };
16
17  match kind {
18    "string" | "isodate" | "path" | "slug" | "unique" | "file" | "image" | "raw" | "markdown" | "mdx" | "excerpt" => {
19      "string".into()
20    },
21    "number" => "number".into(),
22    "boolean" => "boolean".into(),
23    "metadata" => "{ readingTime: number; wordCount: number }".into(),
24    "toc" => "TocItem[]".into(),
25    "array" => {
26      let item = v.get("item").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into());
27      format!("{item}[]")
28    },
29    "object" => render_object(v, indent),
30    "record" => {
31      let val = v.get("value").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into());
32      format!("{{ [k: string]: {val} }}")
33    },
34    "tuple" => {
35      let items: Vec<String> = v
36        .get("items")
37        .and_then(|a| a.as_array())
38        .map(|a| a.iter().map(|i| schema_to_ts(i, indent)).collect())
39        .unwrap_or_default();
40      format!("[{}]", items.join(", "))
41    },
42    "enum" => {
43      let parts: Vec<String> = v
44        .get("variants")
45        .and_then(|a| a.as_array())
46        .map(|a| a.iter().filter_map(literal_value).collect())
47        .unwrap_or_default();
48      if parts.is_empty() { "string".into() } else { parts.join(" | ") }
49    },
50    "literal" => v.get("expected").and_then(literal_value).unwrap_or_else(|| "unknown".into()),
51    "union" => {
52      let parts: Vec<String> = v
53        .get("variants")
54        .and_then(|a| a.as_array())
55        .map(|a| a.iter().map(|i| schema_to_ts(i, indent)).collect())
56        .unwrap_or_default();
57      if parts.is_empty() { "unknown".into() } else { parts.join(" | ") }
58    },
59    "discriminatedUnion" => {
60      let parts: Vec<String> = v
61        .get("variants")
62        .and_then(|a| a.as_array())
63        .map(|a| a.iter().map(|i| schema_to_ts(i, indent)).collect())
64        .unwrap_or_default();
65      if parts.is_empty() { "unknown".into() } else { parts.join(" | ") }
66    },
67    "intersection" => {
68      let l = v.get("left").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into());
69      let r = v.get("right").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into());
70      format!("{l} & {r}")
71    },
72    "optional" | "default" | "transform" | "refine" | "superRefine" | "super_refine" => {
73      v.get("inner").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into())
74    },
75    "nullable" => {
76      let inner = v.get("inner").map(|i| schema_to_ts(i, indent)).unwrap_or_else(|| "unknown".into());
77      format!("{inner} | null")
78    },
79    "coerce.string" => "string".into(),
80    "coerce.number" => "number".into(),
81    "coerce.boolean" => "boolean".into(),
82    "coerce.date" => "Date".into(),
83    _ => "unknown".into(),
84  }
85}
86
87pub fn schema_to_ts_object(v: &Value) -> String {
88  render_object(v, 0)
89}
90
91fn render_object(v: &Value, indent: usize) -> String {
92  let pad_outer = INDENT.repeat(indent);
93  let pad_inner = INDENT.repeat(indent + 1);
94
95  let fields = match v.get("fields").and_then(|f| f.as_object()) {
96    Some(f) => f,
97    None => return "{}".into(),
98  };
99
100  let mut out = String::from("{\n");
101  for (key, sub) in fields {
102    let optional = matches!(sub.get("kind").and_then(|k| k.as_str()), Some("optional") | Some("default"),);
103    let opt = if optional { "?" } else { "" };
104    let ty = schema_to_ts(sub, indent + 1);
105    let safe_key = if is_js_ident(key) { key.clone() } else { format!("'{}'", key.replace('\'', "\\'")) };
106    out.push_str(&format!("{pad_inner}{safe_key}{opt}: {ty}\n"));
107  }
108  let passthrough = v.get("passthrough").and_then(|b| b.as_bool()).unwrap_or(false);
109  if passthrough {
110    out.push_str(&format!("{pad_inner}[k: string]: unknown\n"));
111  }
112  out.push_str(&format!("{pad_outer}}}"));
113  out
114}
115
116fn literal_value(v: &Value) -> Option<String> {
117  match v {
118    Value::String(s) => Some(format!("'{}'", s.replace('\'', "\\'"))),
119    Value::Number(n) => Some(n.to_string()),
120    Value::Bool(b) => Some(b.to_string()),
121    Value::Null => Some("null".into()),
122    _ => None,
123  }
124}