Skip to content

Commit d27e729

Browse files
authored
esm compat (#1831)
- **esm compat** - **Support esm modules** <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Adds ESM module support, modifies TypeScript import paths for ESM, and includes tests and integration setup for ESM compatibility. > > - **ESM Support**: > - Adds ESM module format support in `parse_generator()` in `v2.rs`. > - Updates `generators.baml` to include `lang_typescript_esm` with `module_format esm`. > - **TypeScript Imports**: > - Adds `replace_ts_imports_with_js()` in `typescript/mod.rs` to modify import paths to `.js` for ESM. > - Uses `modify_files()` in `dir_writer.rs` to apply import path changes. > - **Testing**: > - Adds tests for `replace_ts_imports_with_js()` in `typescript/mod.rs`. > - Adds `typescript-esm` integration test setup with `package.json`, `main.ts`, and `README.md`. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=BoundaryML%2Fbaml&utm_source=github&utm_medium=referral)<sup> for c3dd2b4. You can [customize](https://app.ellipsis.dev/BoundaryML/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
1 parent c5c7fc6 commit d27e729

File tree

39 files changed

+35826
-15
lines changed

39 files changed

+35826
-15
lines changed

engine/Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/baml-lib/baml-core/src/configuration.rs

+8-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ pub enum Generator {
3434
BoundaryCloud(CloudProject),
3535
}
3636

37+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38+
pub enum ModuleFormat {
39+
Cjs,
40+
Esm,
41+
}
42+
3743
// TODO: we should figure out how to model generator fields using serde, since
3844
// the generator blocks are essentially a serde_json parse
3945
// problem is that serde_json has atrocious error messages and we need to provide
@@ -48,7 +54,8 @@ pub struct CodegenGenerator {
4854
output_dir: PathBuf,
4955
pub version: String,
5056
pub client_package_name: Option<String>,
51-
57+
// For TS generators, we can choose between CJS and ESM module formats
58+
pub module_format: Option<ModuleFormat>,
5259
pub span: crate::ast::Span,
5360
}
5461

engine/baml-lib/baml-core/src/validate/generator_loader/v2.rs

+26-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use strum::VariantNames;
1212

1313
use crate::configuration::{
1414
CloudProject, CloudProjectBuilder, CodegenGeneratorBuilder, Generator,
15-
GeneratorDefaultClientMode, GeneratorOutputType,
15+
GeneratorDefaultClientMode, GeneratorOutputType, ModuleFormat,
1616
};
1717

1818
fn parse_required_key<'a>(
@@ -200,6 +200,30 @@ pub(crate) fn parse_generator(
200200
}
201201
}
202202

203+
match parse_optional_key(&args, "module_format") {
204+
Ok(Some("cjs")) => {
205+
builder.module_format(Some(ModuleFormat::Cjs));
206+
}
207+
Ok(Some("esm")) => {
208+
builder.module_format(Some(ModuleFormat::Esm));
209+
}
210+
Ok(Some(name)) => {
211+
errors.push(DatamodelError::new_validation_error(
212+
&format!("'{}' is not supported. Use one of: 'cjs' or 'esm'", name),
213+
args.get("module_format")
214+
.map(|arg| arg.span().clone())
215+
.unwrap_or_else(|| ast_generator.span().clone()),
216+
));
217+
}
218+
Ok(None) => {
219+
// TODO: add a warning if not set?
220+
builder.module_format(None);
221+
}
222+
Err(err) => {
223+
errors.push(err);
224+
}
225+
}
226+
203227
if !errors.is_empty() {
204228
return Err(errors);
205229
}
@@ -298,6 +322,7 @@ fn check_property_allowlist<'ir>(
298322
"on_generate",
299323
"project",
300324
"client_package_name",
325+
"module_format",
301326
];
302327

303328
let mut errors = vec![];

engine/baml-runtime/src/cli/generate.rs

+1
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ impl GenerateArgs {
9191
} else {
9292
None
9393
},
94+
None,
9495
)
9596
.context("Failed while resolving .baml paths in baml_src/")?,
9697
)

engine/baml-runtime/src/cli/serve/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ Tip: test that the server is up using `curl http://localhost:{}/_debug/ping`
574574
Vec::new(),
575575
None,
576576
None,
577+
None,
577578
)
578579
.map_err(|_| BamlError::InternalError {
579580
message: "Failed to make placeholder generator".to_string(),

engine/baml-runtime/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,7 @@ impl BamlRuntime {
855855
generator.on_generate.clone(),
856856
Some(generator.output_type),
857857
generator.client_package_name.clone(),
858+
generator.module_format,
858859
)?,
859860
))
860861
})

engine/language_client_codegen/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal-baml-core.workspace = true
2424
either.workspace = true
2525
env_logger.workspace = true
2626
log.workspace = true
27+
regex.workspace = true
2728
pathdiff = "0.1.0"
2829
serde.workspace = true
2930
serde_json.workspace = true

engine/language_client_codegen/src/dir_writer.rs

+6
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ impl<L: LanguageFeatures + Default> FileCollector<L> {
126126
);
127127
}
128128

129+
pub(super) fn modify_files(&mut self, mut modify: impl FnMut(&mut String)) {
130+
for (_path, content) in self.files.iter_mut() {
131+
modify(content);
132+
}
133+
}
134+
129135
/// Ensure that a directory contains only files we generated before nuking it.
130136
///
131137
/// This is a safety measure to prevent accidentally deleting user files.

engine/language_client_codegen/src/go/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ class Foo {
282282
Vec::new(),
283283
Some(GeneratorOutputType::Go),
284284
Some("example.com/integ-tests".to_string()),
285+
None,
285286
)
286287
.unwrap()
287288
}

engine/language_client_codegen/src/lib.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use anyhow::{Context, Result};
22
use baml_types::{Constraint, ConstraintLevel, FieldType};
33
use indexmap::IndexMap;
44
use internal_baml_core::{
5-
configuration::{GeneratorDefaultClientMode, GeneratorOutputType},
5+
configuration::{GeneratorDefaultClientMode, GeneratorOutputType, ModuleFormat},
66
ir::repr::IntermediateRepr,
77
};
8+
use regex::Regex;
89
use std::{
910
collections::{BTreeMap, HashSet},
1011
path::{Path, PathBuf},
@@ -39,6 +40,9 @@ pub struct GeneratorArgs {
3940
// The type of client to generate
4041
client_type: Option<GeneratorOutputType>,
4142
client_package_name: Option<String>,
43+
44+
// For TS generators, we can choose between CJS and ESM module formats
45+
module_format: Option<ModuleFormat>,
4246
}
4347

4448
fn relative_path_to_baml_src(path: &Path, baml_src: &Path) -> Result<PathBuf> {
@@ -62,6 +66,7 @@ impl GeneratorArgs {
6266
on_generate: Vec<String>,
6367
client_type: Option<GeneratorOutputType>,
6468
client_package_name: Option<String>,
69+
module_format: Option<ModuleFormat>,
6570
) -> Result<Self> {
6671
let baml_src = baml_src_dir.into();
6772
let input_file_map: BTreeMap<PathBuf, String> = input_files
@@ -80,6 +85,7 @@ impl GeneratorArgs {
8085
on_generate,
8186
client_type,
8287
client_package_name,
88+
module_format,
8389
})
8490
}
8591

engine/language_client_codegen/src/python/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ class Foo {
464464
Vec::new(),
465465
Some(GeneratorOutputType::PythonPydantic),
466466
None,
467+
None,
467468
)
468469
.unwrap()
469470
}

engine/language_client_codegen/src/typescript/mod.rs

+155-3
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ use baml_types::LiteralValue;
1111
use generate_types::{render_docstring, type_name_for_checks};
1212
use indexmap::IndexMap;
1313
use internal_baml_core::{
14-
configuration::{GeneratorDefaultClientMode, GeneratorOutputType},
14+
configuration::{GeneratorDefaultClientMode, GeneratorOutputType, ModuleFormat},
1515
ir::{repr::IntermediateRepr, FieldType, IRHelper, IRHelperExtended},
1616
};
17+
use regex::Regex;
1718

1819
use self::typescript_language_features::{ToTypescript, TypescriptLanguageFeatures};
1920
use crate::{dir_writer::FileCollector, field_type_attributes};
@@ -290,9 +291,60 @@ pub(crate) fn generate(
290291
TypescriptFramework::None => {}
291292
}
292293

294+
// if it's typescriopt and generator.esm is enabled, we need to change the imports in each file to use the .js extension
295+
if generator.module_format == Some(ModuleFormat::Esm) {
296+
baml_log::info!("Changing imports to .js for ESM");
297+
collector.modify_files(|content: &mut String| {
298+
*content = replace_ts_imports_with_js(content);
299+
});
300+
}
301+
293302
collector.commit(&generator.output_dir())
294303
}
295304

305+
fn replace_ts_imports_with_js(content: &str) -> String {
306+
// Regex to find import/export statements with module specifiers.
307+
// It captures the import/export part, quotes, and the path itself.
308+
// Escaped curly braces in the character set just in case.
309+
let re = Regex::new(r#"(import(?:["'\s]*(?:[\w\*\{\}\n\r\t, ]+)from\s*)?|export(?:["'\s]*(?:[\w\*\{\}\n\r\t, ]+)from\s*)?)(["'])([^"']+)(["'])"#).unwrap();
310+
311+
re.replace_all(content, |caps: &regex::Captures| {
312+
let import_export_part = &caps[1];
313+
let quote = &caps[2];
314+
let path = &caps[3];
315+
let closing_quote = &caps[4];
316+
317+
// Check if it's a relative path (starts with ./ or ../)
318+
if path.starts_with("./") || path.starts_with("../") {
319+
// Check if it already has a common JS/TS/CSS extension
320+
if !path.ends_with(".js") &&
321+
!path.ends_with(".mjs") &&
322+
!path.ends_with(".cjs") &&
323+
!path.ends_with(".jsx") && // Consider react specific extensions too
324+
!path.ends_with(".tsx") &&
325+
!path.ends_with(".css") && // Ignore CSS files
326+
!path.ends_with(".json")
327+
{
328+
// Remove existing .ts if present before adding .js
329+
let base_path = if path.ends_with(".ts") {
330+
&path[..path.len() - 3]
331+
} else {
332+
path
333+
};
334+
// Append .js
335+
format!("{import_export_part}{quote}{base_path}.js{closing_quote}")
336+
} else {
337+
// Already has a recognized extension, leave it as is.
338+
caps[0].to_string()
339+
}
340+
} else {
341+
// Not a relative path (e.g., external package like 'react' or '@boundaryml/baml'), leave it as is.
342+
caps[0].to_string()
343+
}
344+
})
345+
.to_string()
346+
}
347+
296348
impl TryFrom<(&'_ IntermediateRepr, &'_ crate::GeneratorArgs)> for TypescriptConfig {
297349
type Error = anyhow::Error;
298350

@@ -594,7 +646,9 @@ impl ToTypeReferenceInClientDefinition for FieldType {
594646
FieldType::WithMetadata { .. } => {
595647
unreachable!("distribute_metadata makes this field unreachable.")
596648
}
597-
FieldType::Arrow(_) => todo!("Arrow types should not be used in generated type definitions"),
649+
FieldType::Arrow(_) => {
650+
todo!("Arrow types should not be used in generated type definitions")
651+
}
598652
};
599653
let base_type_ref = if is_partial_type {
600654
base_rep
@@ -675,7 +729,9 @@ impl ToTypeReferenceInClientDefinition for FieldType {
675729
FieldType::Optional(inner) => {
676730
format!("{} | null", inner.to_type_ref(ir, use_module_prefix))
677731
}
678-
FieldType::Arrow(_) => todo!("Arrow types should not be used in generated type definitions"),
732+
FieldType::Arrow(_) => {
733+
todo!("Arrow types should not be used in generated type definitions")
734+
}
679735
FieldType::WithMetadata { base, .. } => match field_type_attributes(self) {
680736
Some(checks) => {
681737
let base_type_ref = base.to_type_ref(ir, use_module_prefix);
@@ -687,3 +743,99 @@ impl ToTypeReferenceInClientDefinition for FieldType {
687743
}
688744
}
689745
}
746+
747+
#[cfg(test)]
748+
mod tests {
749+
use super::*;
750+
751+
#[test]
752+
fn test_replace_ts_imports_with_js() {
753+
// Add .js to relative paths without extension
754+
assert_eq!(
755+
replace_ts_imports_with_js("import { Foo } from './bar';"),
756+
"import { Foo } from './bar.js';"
757+
);
758+
assert_eq!(
759+
replace_ts_imports_with_js("export * from \"../baz/qux\";"),
760+
"export * from \"../baz/qux.js\";"
761+
);
762+
assert_eq!(
763+
replace_ts_imports_with_js("import type { Bar } from './bar'"),
764+
"import type { Bar } from './bar.js'"
765+
);
766+
assert_eq!(
767+
replace_ts_imports_with_js("import {\n Thing1,\n Thing2\n} from \"./things\";"),
768+
"import {\n Thing1,\n Thing2\n} from \"./things.js\";"
769+
);
770+
771+
// Replace .ts with .js in relative paths
772+
assert_eq!(
773+
replace_ts_imports_with_js("import { Foo } from './bar.ts';"),
774+
"import { Foo } from './bar.js';"
775+
);
776+
assert_eq!(
777+
replace_ts_imports_with_js("export * from \"../baz/qux.ts\";"),
778+
"export * from \"../baz/qux.js\";"
779+
);
780+
781+
// Should ignore already correct .js paths
782+
assert_eq!(
783+
replace_ts_imports_with_js("import { Foo } from './bar.js';"),
784+
"import { Foo } from './bar.js';"
785+
);
786+
// Should ignore other extensions like .css, .mjs, .cjs
787+
assert_eq!(
788+
replace_ts_imports_with_js("import styles from './styles.css';"),
789+
"import styles from './styles.css';"
790+
);
791+
assert_eq!(
792+
replace_ts_imports_with_js("import config from './config.json';"),
793+
"import config from './config.json';"
794+
);
795+
assert_eq!(
796+
replace_ts_imports_with_js("import { util } from './util.mjs';"),
797+
"import { util } from './util.mjs';"
798+
);
799+
assert_eq!(
800+
replace_ts_imports_with_js("import { main } from '../main.cjs';"),
801+
"import { main } from '../main.cjs';"
802+
);
803+
assert_eq!(
804+
replace_ts_imports_with_js("import { Comp } from './Comp.tsx';"),
805+
"import { Comp } from './Comp.tsx';"
806+
);
807+
assert_eq!(
808+
replace_ts_imports_with_js("import { Button } from './Button.jsx';"),
809+
"import { Button } from './Button.jsx';"
810+
);
811+
812+
// Should ignore absolute paths or URLs
813+
assert_eq!(
814+
replace_ts_imports_with_js("import React from 'react';"),
815+
"import React from 'react';"
816+
);
817+
assert_eq!(
818+
replace_ts_imports_with_js("import { BamlClient } from '@boundaryml/baml';"),
819+
"import { BamlClient } from '@boundaryml/baml';"
820+
);
821+
assert_eq!(
822+
replace_ts_imports_with_js("const path = '/path/to/file.ts';"),
823+
"const path = '/path/to/file.ts';" // This is not an import/export statement
824+
);
825+
826+
// Empty string
827+
assert_eq!(replace_ts_imports_with_js(""), "");
828+
// String with no imports
829+
assert_eq!(
830+
replace_ts_imports_with_js("const x = 10; function y() {}"),
831+
"const x = 10; function y() {}"
832+
);
833+
// Mixed content
834+
assert_eq!(
835+
replace_ts_imports_with_js(
836+
"console.log('hello');\nimport { a } from './a.ts';\nimport { b } from './b';\nimport { c } from './c.js';\nimport { d } from 'd-lib';\nexport { e } from '../e.ts';\nconsole.log('world');"
837+
),
838+
"console.log('hello');\nimport { a } from './a.js';\nimport { b } from './b.js';\nimport { c } from './c.js';\nimport { d } from 'd-lib';\nexport { e } from '../e.js';\nconsole.log('world');"
839+
);
840+
}
841+
}

integ-tests/baml_src/generators.baml

+9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ generator lang_typescript {
1010
version "0.84.4"
1111
}
1212

13+
14+
generator lang_typescript_esm {
15+
output_type typescript
16+
output_dir "../typescript-esm"
17+
version "0.84.4"
18+
module_format esm
19+
}
20+
21+
1322
generator lang_typescript_react {
1423
output_type typescript/react
1524
output_dir "../react"

0 commit comments

Comments
 (0)