///|
/// 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`.
fn types_package_hint(pkg_spec : String) -> String {
if pkg_spec.has_prefix("@") {
match pkg_spec.find("/") {
Some(idx) =>
"@types/" +
pkg_spec[1:idx].to_owned() +
"__" +
pkg_spec[idx + 1:].to_owned()
None => "@types/\{pkg_spec}"
}
} else {
"@types/\{pkg_spec}"
}
}
///|
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
}
}
// A scriptish entry means the package ships no declarations at all —
// the generated surface will be near-empty (unsafeCast only). Say so
// instead of silently reporting success on a function-less bridge.
if entry_path.has_suffix(".js") ||
entry_path.has_suffix(".cjs") ||
entry_path.has_suffix(".mjs") {
let types_pkg_hint = types_package_hint(pkg_spec)
println(
"vendor: WARNING '\{pkg_spec}' ships no TypeScript declarations (resolved \{entry_path}); the generated API surface will be minimal — try `npm i -D \{types_pkg_hint}` and re-run",
)
}
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`. The bare specifier baked into
// `bridge.mbt` is a scoped npm name (`@tsmbt-bridge/`) that
// resolves through `node_modules/@tsmbt-bridge/`. Consumers
// wire it up by adding `"@tsmbt-bridge/": "file:./..."` to
// their `package.json` `dependencies` — `pnpm install` /
// `npm install` then create the link as a real dependency, which
// also lets `moon test --target js` resolve the require() chain
// (Node's CJS test scaffold writes a sibling empty `package.json`
// that would otherwise shadow a `package.json#imports` mapping).
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(vendor_root, safe_dir)
print_vendor_package_json_dependency_hint(vendor_root, [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 generate` or `ts2mbt vendor `.\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 generate` /\n`ts2mbt vendor` from upstream `.d.ts` declarations. Do not edit\nthem by hand -- your changes will be lost on the next regen.\n\nTo regenerate:\n\n```\nts2mbt generate # everything in package.json (typical)\nts2mbt vendor # just one package\n```\n\nIf the upstream typings change, re-run the same command. The\nruntime `bridge.js` files are wired up as scoped npm packages\nunder `@tsmbt-bridge/`; `ts2mbt` writes the matching `file:`\nentries into the consumer's `package.json` `dependencies` (or\nprints them when the file can't be edited safely), and\n`pnpm install` / `npm install` then links them into\n`node_modules/@tsmbt-bridge/`.\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` 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(
vendor_root : String,
safe_dir : String,
) -> Unit {
let prefix = vendor_import_path_prefix(vendor_root)
println("")
println("Add to your consumer moon.pkg import block:")
println(" \"\{prefix}/\{safe_dir}\" @\{safe_dir},")
}
///|
/// The moon import-path prefix for packages under `vendor_root`:
/// `/`.
/// Falls back to the documented default layout when the module name or
/// the relative position can't be resolved (e.g. `--out` points outside
/// the module's source tree).
async fn vendor_import_path_prefix(vendor_root : String) -> String {
let module_name = read_consumer_module_name()
let fallback = "\{module_name}/internal/generated"
let module_root = match find_nearest_moon_mod_dir(".") {
Some(root) => root
None => return fallback
}
let mod_source = main_join_path(module_root, "moon.mod.json")
let mod_text = @fs.read_file(mod_source).text() catch { _ => "" }
let source_field = match parse_moon_mod_string_field(mod_text, "source") {
Some(dir) => dir
None => "src"
}
let src_dir = main_join_path(module_root, source_field)
let src_real = @fs.realpath(src_dir) catch { _ => src_dir }
let vendor_real = @fs.realpath(vendor_root) catch { _ => vendor_root }
match strip_dir_prefix(src_real, vendor_real) {
Some(rel) if rel != "" => "\{module_name}/\{rel}"
_ => fallback
}
}
///|
/// 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 => ""
}
}
///|
/// Print copy-paste-ready `package.json#dependencies` entries for
/// the freshly-vendored bridges. Each entry maps a scoped name
/// (`@tsmbt-bridge/`) to a `file:` reference into the vendor
/// tree; `pnpm install` / `npm install` then materialize the bridge
/// as a real `node_modules/@tsmbt-bridge/` package, surviving
/// future installs. Stays silent on entries that are already wired
/// up so re-running `vendor` / `generate` is quiet on configured
/// projects.
///
/// Missing entries are written into `package.json` in place via a
/// line-oriented insertion into the `dependencies` block, so the rest
/// of the file's formatting / key order stays byte-identical. When the
/// file's shape defeats the inserter, the entries are printed for
/// copy-paste instead — never silently dropped.
async fn print_vendor_package_json_dependency_hint(
vendor_root : String,
safe_dirs : Array[String],
) -> Unit {
if safe_dirs.length() == 0 {
return
}
// Anchor on the moon module when inside one; `generate` / `vendor`
// is also useful before `moon.mod.json` exists, so fall back to the
// working directory's `package.json` (the file `generate` read the
// dependency list from).
let module_root = match find_nearest_moon_mod_dir(".") {
Some(root) => root
None => "."
}
let pkg_json_path = main_join_path(module_root, "package.json")
if !@fs.exists(pkg_json_path) {
return
}
let module_real = @fs.realpath(module_root) catch { _ => module_root }
let vendor_real = @fs.realpath(vendor_root) catch { _ => vendor_root }
let rel_to_vendor = match strip_dir_prefix(module_real, vendor_real) {
Some(rest) => rest
None => vendor_real
}
let source = @fs.read_file(pkg_json_path).text() catch { _ => return }
let json = @json.parse(source) catch { _ => return }
let missing : Array[(String, String)] = []
for safe_dir in safe_dirs {
let key = "@tsmbt-bridge/\{safe_dir}"
let target = "file:./\{rel_to_vendor}/\{safe_dir}"
if !package_json_dependency_entry_matches(json, key, target) {
missing.push((key, target))
}
}
if missing.length() == 0 {
return
}
// Vendor roots under `_build/` are throwaway scaffolds (example /
// fixture gates run `vendor` inside the repo); writing those into a
// real `package.json` pollutes it with paths that vanish on the next
// clean. Print instead of writing for those.
if rel_to_vendor.has_prefix("_build/") ||
rel_to_vendor.contains("/_build/") ||
rel_to_vendor == "_build" {
print_package_json_dependency_block(missing)
return
}
match insert_package_json_dependencies(source, missing) {
Some(updated) => {
@fs.write_file(pkg_json_path, string_to_bytes(updated)) catch {
_ => {
print_package_json_dependency_block(missing)
return
}
}
println("")
println("Wired into \{pkg_json_path} (`dependencies`):")
for entry in missing {
let (key, target) = entry
println(" \"\{key}\": \"\{target}\"")
}
println(
"Run `pnpm install` / `npm install` to materialize the links under node_modules/@tsmbt-bridge/.",
)
}
None => print_package_json_dependency_block(missing)
}
}
///|
/// Copy-paste fallback for when the in-place `package.json` edit can't
/// run (unwritable file, shape the line inserter doesn't understand).
fn print_package_json_dependency_block(
missing : Array[(String, String)],
) -> Unit {
println("")
println("Add to your consumer package.json (`dependencies` field):")
println(" \"dependencies\": {")
for idx in 0.. String? {
let lines : Array[String] = []
for line_view in source.split("\n") {
lines.push(line_view.to_owned())
}
fn leading_whitespace(line : String) -> String {
let mut idx = 0
while idx < line.length() && (line[idx] == ' ' || line[idx] == '\t') {
idx += 1
}
line[:idx].to_owned()
}
// Locate a multi-line `"dependencies": {` opener.
for i, line in lines {
if line.contains("\"dependencies\"") && line.trim().has_suffix("{") {
let indent = leading_whitespace(line)
let entry_indent = if i + 1 < lines.length() &&
lines[i + 1].trim() != "}" &&
lines[i + 1].trim() != "}," {
leading_whitespace(lines[i + 1])
} else {
indent + " "
}
// An empty block needs the last inserted entry comma-free; a
// populated block keeps commas on every inserted line because
// the existing first entry follows.
let block_is_empty = i + 1 < lines.length() &&
(lines[i + 1].trim() == "}" || lines[i + 1].trim() == "},")
let inserted : Array[String] = []
for idx in 0.. Bool {
guard json is Object(top) else { return false }
for pair in top {
let (name, val) = pair
if name != "dependencies" && name != "devDependencies" {
continue
}
guard val is Object(deps) else { continue }
for entry in deps {
let (k, v) = entry
if k == key {
guard v is String(s) else { return false }
if s == target {
return true
}
}
}
}
false
}
///|
/// 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",
)
}
///|
/// Generate MoonBit bridges for every dependency listed in a
/// `package.json` (`dependencies` + `devDependencies`) and write them
/// under `/internal/generated//`. 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_generate_from_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(
"generate: 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(
"generate 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`.
if succeeded.length() > 0 {
let vendor_root = match vendor_root_override {
Some(root) => root
None =>
match resolve_default_vendor_root() {
Some(root) => root
None => return failed.length() == 0
}
}
print_generate_import_block(vendor_root, succeeded)
let safe_dirs : Array[String] = []
for pkg_spec in succeeded {
safe_dirs.push(vendor_safe_pkg_dir(pkg_spec))
}
print_vendor_package_json_dependency_hint(vendor_root, safe_dirs)
print_vendor_module_type_hint()
}
failed.length() == 0
}
///|
/// Print one combined `moon.pkg` import block listing every
/// successfully generated package.
async fn print_generate_import_block(
vendor_root : String,
pkg_specs : Array[String],
) -> Unit {
let prefix = vendor_import_path_prefix(vendor_root)
println("")
println("Add to your consumer moon.pkg import block:")
for pkg_spec in pkg_specs {
let safe_dir = vendor_safe_pkg_dir(pkg_spec)
println(" \"\{prefix}/\{safe_dir}\" @\{safe_dir},")
}
}
///|
/// 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_owned()
} 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
/// module manifest. 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 source = match read_moon_mod_source(module_root) {
Some(source) => source
None => 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
// Skip bridges already vendored under `@tsmbt-bridge/...`; they
// are bridge artifacts the consumer wired up via `file:` deps,
// not upstream npm packages we should re-vendor.
if dep_name.has_prefix("@tsmbt-bridge/") {
continue
}
if !result.contains(dep_name) {
result.push(dep_name)
}
}
}
}
result
}