Skip to main content

dmc/engine/
schema_ts.rs

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