Skip to main content

infinity_build_js/
config.rs

1use infinity_build_core::{BuildError, BuildResult};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct JsBuildConfig {
7    #[serde(flatten)]
8    pub package: PackageSpec,
9
10    #[serde(default)]
11    pub instruments: Vec<Instrument>,
12}
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct PackageSpec {
16    pub package_name: String,
17
18    #[serde(default = "default_package_dir")]
19    pub package_dir: PathBuf,
20}
21
22fn default_package_dir() -> PathBuf {
23    PathBuf::from("PackageSources")
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct Instrument {
28    pub name: String,
29    pub index: PathBuf,
30
31    #[serde(default)]
32    pub simulator_package: Option<SimulatorPackage>,
33
34    #[serde(default)]
35    pub modules: Vec<ModuleAlias>,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct ModuleAlias {
40    pub resolve: String,
41    pub index: PathBuf,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
45#[serde(tag = "type", rename_all = "camelCase")]
46pub enum SimulatorPackage {
47    React {
48        #[serde(default)]
49        file_name: Option<String>,
50
51        #[serde(default)]
52        template_id: Option<String>,
53
54        #[serde(default = "default_true")]
55        is_interactive: bool,
56
57        #[serde(default)]
58        imports: Vec<String>,
59
60        #[serde(default)]
61        html_template: Option<PathBuf>,
62
63        #[serde(default)]
64        js_template: Option<PathBuf>,
65    },
66
67    /// React instrument backed by a ReScript project. Before bundling,
68    /// the bundler runs the configured ReScript build command (default:
69    /// `bun run build`) so the generated `.res.mjs` entrypoint exists.
70    RescriptReact {
71        #[serde(default)]
72        file_name: Option<String>,
73
74        #[serde(default)]
75        template_id: Option<String>,
76
77        #[serde(default = "default_true")]
78        is_interactive: bool,
79
80        #[serde(default)]
81        imports: Vec<String>,
82
83        #[serde(default)]
84        html_template: Option<PathBuf>,
85
86        #[serde(default)]
87        js_template: Option<PathBuf>,
88
89        /// Command run before bundling. Executed in `build_dir` when
90        /// provided, otherwise the nearest ancestor containing
91        /// `rescript.json`, `bsconfig.json`, or `package.json`.
92        #[serde(default)]
93        build_command: Option<String>,
94
95        /// Directory to run `build_command` from. Relative paths are
96        /// resolved from the project root.
97        #[serde(default)]
98        build_dir: Option<PathBuf>,
99    },
100
101    BaseInstrument {
102        #[serde(default)]
103        file_name: Option<String>,
104
105        /// Required. Must match `BaseInstrument.templateID()`.
106        template_id: String,
107        /// Required. Must match the ID passed to `FSComponent.render()`.
108        mount_element_id: String,
109
110        #[serde(default)]
111        imports: Vec<String>,
112
113        #[serde(default)]
114        html_template: Option<PathBuf>,
115    },
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SimulatorPackageKind {
120    React,
121    RescriptReact,
122    BaseInstrument,
123}
124
125fn default_true() -> bool {
126    true
127}
128
129impl SimulatorPackage {
130    pub fn kind(&self) -> SimulatorPackageKind {
131        match self {
132            SimulatorPackage::React { .. } => SimulatorPackageKind::React,
133            SimulatorPackage::RescriptReact { .. } => SimulatorPackageKind::RescriptReact,
134            SimulatorPackage::BaseInstrument { .. } => SimulatorPackageKind::BaseInstrument,
135        }
136    }
137
138    pub fn file_name(&self) -> &str {
139        match self {
140            SimulatorPackage::React { file_name, .. }
141            | SimulatorPackage::RescriptReact { file_name, .. }
142            | SimulatorPackage::BaseInstrument { file_name, .. } => {
143                file_name.as_deref().unwrap_or("instrument")
144            }
145        }
146    }
147
148    pub fn imports(&self) -> &[String] {
149        match self {
150            SimulatorPackage::React { imports, .. }
151            | SimulatorPackage::RescriptReact { imports, .. }
152            | SimulatorPackage::BaseInstrument { imports, .. } => imports,
153        }
154    }
155
156    /// Whether the gauge should receive interaction events. BaseInstrument
157    /// gauges default to interactive; React/ReScript-React respect the
158    /// `is_interactive` flag (default true).
159    pub fn is_interactive(&self) -> bool {
160        match self {
161            SimulatorPackage::React { is_interactive, .. }
162            | SimulatorPackage::RescriptReact { is_interactive, .. } => *is_interactive,
163            SimulatorPackage::BaseInstrument { .. } => true,
164        }
165    }
166}
167
168/// Strip the Windows `\\?\` verbatim prefix from a canonicalized path.
169/// Rolldown/oxc_resolver treats verbatim paths as unresolvable URL-like
170/// strings (`//?/C:/...`), so we hand them plain drive-letter paths.
171#[cfg(windows)]
172fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
173    let s = path.as_os_str().to_string_lossy();
174    if let Some(rest) = s.strip_prefix(r"\\?\") {
175        // Keep UNC shares as `\\server\share\...`
176        if let Some(unc) = rest.strip_prefix(r"UNC\") {
177            return PathBuf::from(format!(r"\\{unc}"));
178        }
179        return PathBuf::from(rest.to_string());
180    }
181    path
182}
183
184#[cfg(not(windows))]
185fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
186    path
187}
188
189impl Instrument {
190    pub fn resolved_index(&self, project_root: &Path) -> BuildResult<PathBuf> {
191        let abs = project_root.join(&self.index);
192        let canonical = std::fs::canonicalize(&abs)
193            .map_err(|e| BuildError::invalid_path(abs, format!("entrypoint not found: {e}")))?;
194        Ok(strip_verbatim_prefix(canonical))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn deserializes_rescript_react_package() {
204        let raw = r#"
205            name = "PFD"
206            index = "src/Main.res.mjs"
207
208            [simulator_package]
209            type = "rescriptReact"
210            template_id = "PFD"
211            build_command = "bun run build"
212            build_dir = "ui"
213        "#;
214
215        let instrument: Instrument = toml::from_str(raw).unwrap();
216        match instrument.simulator_package.unwrap() {
217            SimulatorPackage::RescriptReact {
218                template_id,
219                build_command,
220                build_dir,
221                ..
222            } => {
223                assert_eq!(template_id.as_deref(), Some("PFD"));
224                assert_eq!(build_command.as_deref(), Some("bun run build"));
225                assert_eq!(build_dir.as_deref(), Some(Path::new("ui")));
226            }
227            other => panic!("expected RescriptReact, got {other:?}"),
228        }
229    }
230}