///|
/// Vendor a single TypeScript package's types into the consumer's
/// `/internal/generated//` sub-package.
///
/// `pkg_spec` is an npm-style specifier (`hono`, `@types/react`,
/// `@scope/foo`). The resolver walks the consumer's `node_modules` to
/// find the type entrypoint, generates a MoonBit bridge package, and
/// writes it under `/internal/generated//`.
///
/// `module_spec_override` overrides the runtime import the generated
/// `bridge.js` uses. Defaults to `pkg_spec` (or, for `@types/`
/// inputs, the unscoped runtime name).
///
/// `vendor_root_override` overrides the output root. Defaults to
/// `/internal/generated`.
pub async fn ts2mbt_vendor_package(
pkg_spec : String,
module_spec_override? : String? = None,
vendor_root_override? : String? = None,
print_import_hint? : Bool = true,
) -> Bool {
let vendor_root = match vendor_root_override {
Some(root) => root
None =>
match resolve_default_vendor_root() {
Some(root) => root
None => {
println(
"vendor: could not resolve `/internal/generated`; run inside a moon module or pass --out ",
)
return false
}
}
}
let entry_path = match
@parser.resolve_type_module_specifier("__tsmbt_entry__.ts", pkg_spec) {
Some(path) => path
None => {
println(
"vendor: could not resolve TypeScript types for '\{pkg_spec}'; check that it's installed in node_modules",
)
return false
}
}
let runtime_spec = match module_spec_override {
Some(spec) => spec
None => default_runtime_module_spec(pkg_spec)
}
let safe_dir = vendor_safe_pkg_dir(pkg_spec)
let output_dir = main_join_path(vendor_root, safe_dir)
let bare_specifier = "@tsmbt-bridge/\{safe_dir}"
println(
"vendor: \{pkg_spec} -> \{output_dir} (#module \"\{bare_specifier}\")",
)
// `emit_moonbit_scaffold_from_ts` writes `bridge.{mbti,mbt,js}`,
// `package.json`, `moon.pkg.json`, and refreshes the
// `/node_modules/@tsmbt-bridge/` symlink so the bare
// `#module("@tsmbt-bridge/")` baked into `bridge.mbt` resolves
// at build time.
if !emit_moonbit_scaffold_from_ts(
entry_path,
runtime_spec,
output_dir,
bare_module_specifier=Some(bare_specifier),
) {
return false
}
let _ = ensure_vendor_root_guardrails(vendor_root)
if print_import_hint {
print_vendor_import_hint(safe_dir)
print_vendor_module_type_hint()
}
true
}
///|
/// Drop a `.gitignore` and `AGENTS.md` at the vendor root so AI agents
/// (Claude / Cursor / Aider / etc.) know not to hand-edit the
/// generated files, and so consumers can leave the directory out of
/// version control with a single ignore rule. Both files are
/// idempotent — overwriting on every vendor run keeps them current.
async fn ensure_vendor_root_guardrails(vendor_root : String) -> Bool {
let _ = ensure_dir_tree(vendor_root) catch { _ => return false }
let gitignore_body = "# AUTO-GENERATED by `ts2mbt` (mizchi/ts). DO NOT EDIT.\n# Regenerate via `ts2mbt vendor ` or `ts2mbt sync`.\n*\n!.gitignore\n!AGENTS.md\n"
let gitignore_path = main_join_path(vendor_root, ".gitignore")
let _ = @fs.write_file(
gitignore_path,
string_to_bytes(gitignore_body),
create=0o644,
) catch {
_ => ()
}
let agents_body = "# Auto-generated TypeScript bridges\n\nFiles under this directory are generated by `ts2mbt vendor` / `ts2mbt sync`\nfrom upstream `.d.ts` declarations. Do not edit them by hand — your\nchanges will be lost on the next vendor run.\n\nTo regenerate:\n\n```\nts2mbt vendor # one package\nts2mbt sync # everything in package.json\n```\n\nIf the upstream typings change, re-run the same command. The runtime\n`bridge.js` is wired into `/node_modules/@tsmbt-bridge/`\nvia a relative symlink so the package stays portable across host paths.\n"
let agents_path = main_join_path(vendor_root, "AGENTS.md")
let _ = @fs.write_file(
agents_path,
string_to_bytes(agents_body),
create=0o644,
) catch {
_ => ()
}
true
}
///|
/// Print a copy-paste-ready `moon.pkg.json` import entry for a freshly
/// vendored package. Resolves the consumer's module name from
/// `moon.mod.json` so the suggestion is concrete, falling back to a
/// `` placeholder when no module is found.
async fn print_vendor_import_hint(safe_dir : String) -> Unit {
let module_name = read_consumer_module_name()
let import_path = "\{module_name}/internal/generated/\{safe_dir}"
println("")
println("Add to your consumer moon.pkg.json import list:")
println(" { \"path\": \"\{import_path}\", \"alias\": \"\{safe_dir}\" }")
}
///|
/// Resolve the consumer's moon module name from `moon.mod.json`. Falls
/// back to a `` placeholder so the printed hint still
/// reads as a template even outside a moon module.
async fn read_consumer_module_name() -> String {
let module_root = match find_nearest_moon_mod_dir(".") {
Some(root) => root
None => return ""
}
let path = main_join_path(module_root, "moon.mod.json")
let source = @fs.read_file(path).text() catch { _ => return "" }
match parse_moon_mod_string_field(source, "name") {
Some(name) => name
None => ""
}
}
///|
/// Detect a missing `"type": "module"` in the consumer's `package.json`
/// and surface a one-line hint. Generated `bridge.js` is ESM, so Node
/// prints a perf warning ("Reparsing as ES module ...") when the
/// containing package.json doesn't declare `type: module`.
async fn print_vendor_module_type_hint() -> Unit {
let module_root = match find_nearest_moon_mod_dir(".") {
Some(root) => root
None => return
}
let pkg_json_path = main_join_path(module_root, "package.json")
if !@fs.exists(pkg_json_path) {
return
}
let source = @fs.read_file(pkg_json_path).text() catch { _ => return }
if source.contains("\"type\"") {
return
}
println(
" (note) add `\"type\": \"module\"` to package.json to silence Node's ESM-reparse warning",
)
}
///|
/// Vendor every dependency listed in a `package.json` (`dependencies` +
/// `devDependencies`). Emits a per-package summary at the end and
/// returns `true` only when every package generated successfully.
///
/// `package_json_path_override` defaults to `./package.json`.
pub async fn ts2mbt_sync_package_json(
package_json_path_override? : String? = None,
vendor_root_override? : String? = None,
) -> Bool {
let package_json_path = match package_json_path_override {
Some(path) => path
None => "package.json"
}
let deps = read_package_json_dependency_names(package_json_path)
if deps.length() == 0 {
println(
"sync: no dependencies / devDependencies found in '\{package_json_path}'",
)
return true
}
let succeeded : Array[String] = []
let failed : Array[String] = []
for pkg_spec in deps {
if ts2mbt_vendor_package(
pkg_spec,
vendor_root_override~,
print_import_hint=false,
) {
succeeded.push(pkg_spec)
} else {
failed.push(pkg_spec)
}
}
println("")
println(
"sync summary: \{succeeded.length()} ok, \{failed.length()} failed (of \{deps.length()})",
)
if failed.length() > 0 {
println("failed packages:")
for pkg in failed {
println(" - \{pkg}")
}
}
// Print one consolidated import block at the end so the user gets a
// copy-paste-ready chunk for their `moon.pkg.json`.
if succeeded.length() > 0 {
print_sync_import_block(succeeded)
print_vendor_module_type_hint()
}
failed.length() == 0
}
///|
/// Print one combined `moon.pkg.json` import block listing every
/// successfully vendored package.
async fn print_sync_import_block(pkg_specs : Array[String]) -> Unit {
let module_name = read_consumer_module_name()
println("")
println("Add to your consumer moon.pkg.json import list:")
for i, pkg_spec in pkg_specs {
let safe_dir = vendor_safe_pkg_dir(pkg_spec)
let suffix = if i == pkg_specs.length() - 1 { "" } else { "," }
println(
" { \"path\": \"\{module_name}/internal/generated/\{safe_dir}\", \"alias\": \"\{safe_dir}\" }\{suffix}",
)
}
}
///|
/// Sanitize an npm package specifier into a directory-name-safe slug.
/// `@scope/foo-bar` -> `scope__foo_bar`; `react` -> `react`.
fn vendor_safe_pkg_dir(name : String) -> String {
let mut result = ""
for c in name {
if c == '@' {
// Strip leading scope marker
()
} else if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') {
result += c.to_string()
} else if c == '/' {
result += "__"
} else {
result += "_"
}
}
if result == "" {
"package"
} else {
result
}
}
///|
/// Default runtime module spec for a package vendored from node_modules.
/// `@types/react` -> `react`; everything else passes through.
fn default_runtime_module_spec(pkg_spec : String) -> String {
let prefix = "@types/"
if pkg_spec.has_prefix(prefix) {
pkg_spec[prefix.length():pkg_spec.length()].to_string()
} else {
pkg_spec
}
}
///|
/// Pure-function variant of `resolve_default_vendor_root` for testing.
/// Given a moon module root and the raw `moon.mod.json` contents,
/// returns the directory the vendor pipeline should write into.
///
/// When `moon.mod.json` does not declare a `source` field MoonBit
/// treats the module root itself as the source directory, so the
/// fallback here is `"."` rather than `"src"`. Picking `"src"` for
/// missing-source projects would emit the bridge under a phantom
/// `/src/internal/generated/...` path that the moon module
/// can't see, and `moon build` would silently skip it.
pub fn compute_vendor_root(
module_root : String,
moon_mod_source : String,
) -> String {
let source_dir_rel = match
parse_moon_mod_string_field(moon_mod_source, "source") {
Some(value) => value
None => "."
}
let source_dir = if source_dir_rel == "" || source_dir_rel == "." {
module_root
} else {
main_join_path(module_root, source_dir_rel)
}
main_join_path(source_dir, "internal/generated")
}
///|
/// Resolve `/internal/generated` from the cwd's
/// nearest `moon.mod.json`. Returns `None` if no module is found.
async fn resolve_default_vendor_root() -> String? {
let module_root = match find_nearest_moon_mod_dir(".") {
Some(root) => root
None => return None
}
let moon_mod_path = main_join_path(module_root, "moon.mod.json")
let source = @fs.read_file(moon_mod_path).text() catch { _ => return None }
Some(compute_vendor_root(module_root, source))
}
///|
/// Read the names listed under `dependencies` and `devDependencies` in
/// the given `package.json`. Order: deps first, devDeps second, with
/// each block in the order JSON returned. Duplicates are de-duped.
async fn read_package_json_dependency_names(path : String) -> Array[String] {
let result : Array[String] = []
let source = @fs.read_file(path).text() catch {
_ => {
println("vendor: could not read '\{path}'")
return result
}
}
let json = @json.parse(source) catch {
_ => {
println("vendor: '\{path}' is not valid JSON")
return result
}
}
guard json is Object(members) else { return result }
for field in ["dependencies", "devDependencies"] {
for pair in members {
let (name, value) = pair
if name != field {
continue
}
guard value is Object(deps) else { continue }
for dep_pair in deps {
let (dep_name, _) = dep_pair
if !result.contains(dep_name) {
result.push(dep_name)
}
}
}
}
result
}