pack
uses node:path
import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { exportFiles, fileContent, fileName, filesIn } from "#codebase/tcmts/cli/files"; import { exportsOf, forPackage, fromSiblings, provide } from "#codebase/tcmts/cli/pack"; import type { PackOptions } from "#codebase/tcmts/cli/pack"; import { exportId } from "#codebase/tcmts/core/hash"; import { createProgram } from "#codebase/tcmts/core/program"; import { SERVER_HOST_MODULE } from "#codebase/tcmts/core/types"; import { externalsOf, render } from "#codebase/tcmts/loader"; import type { DefSource } from "#codebase/tcmts/loader"; import { npmAlias, parseModule } from "#codebase/tcmts/loader/packages"; import fs from "node:fs"; import path from "node:path"; import { ScriptTarget as ts_ScriptTarget } from "typescript/ast"; import { ModuleKind as ts_ModuleKind, ModuleResolutionKind as ts_ModuleResolutionKind } from "typescript/sync"; export function pack( cb: Codebase, opts: PackOptions, ): { dir: string; modules: number; dependencies: Record<string, string> } { const subpaths = new Map<string, string[]>(); for (const e of opts.entries) subpaths.set(e.subpath, [...(subpaths.get(e.subpath) ?? []), e.target]); const names = opts.names ?? cb.store.names(); const entries = [...subpaths].map(([subpath, targets]) => ({ subpath, exports: exportsOf(names, targets), })); const valueAt = (target: string) => { if (!opts.names) return cb.lookupOne(target, "value"); const hs = names.filter((n) => n.path === target && n.kind !== "type").map((n) => n.hash); if (hs.length !== 1) throw new Error(hs.length ? `'${target}' is conflicted` : `'${target}' isn't a value`); return hs[0]!; }; const bins = opts.bins.map((b) => ({ ...b, hash: valueAt(b.target) })); const roots = [ ...entries.flatMap((e) => e.exports.map((x) => x.hash)), ...bins.map((b) => b.hash), ]; // every component the code needs, to compile it; those it carries are the ones other packages don't provide const comps = [...cb.closureComponents(roots)]; const { kept, provided, links } = provide(cb, roots, opts.siblings ?? []); // generated without source maps: the published modules are the code const base = cb.source(); const src: DefSource = { get: base.get, members: base.members, nameOf: base.nameOf }; // the package's dependencies: every package the code imports, at its locked version const modules = new Set<string>(); for (const c of kept) for (const e of externalsOf(src.members(c))) if (e.module.startsWith("npm:")) modules.add(e.module); const byName = new Map<string, string[]>(); for (const m of modules) { const { name } = parseModule(m); byName.set(name, [...(byName.get(name) ?? []), m]); } const dependencies: Record<string, string> = { ...links }; const aliases = new Map<string, string>(); for (const [name, ms] of byName) { for (const m of ms) { const { version: range } = parseModule(m); const version = cb.store.lockedVersion(name, range); if (!version) throw new Error( `${name} ${range} isn't locked; run the code once (tcm check or tcm run) so tcm installs it`, ); // DefinitelyTyped types where the package has none: the declarations refer to them const typesName = `@types/${name.replace(/^@/, "").replace("/", "__")}`; const types = cb.store.lockedVersion(typesName, range); if (ms.length === 1) { dependencies[name] = version; if (types) dependencies[typesName] = types; aliases.set(npmAlias(m), name); } else { dependencies[npmAlias(m)] = `npm:${name}@${version}`; if (types) dependencies[`@types/${npmAlias(m)}`] = `npm:${typesName}@${types}`; } } } for (const w of opts.with) { const at = w.lastIndexOf("@"); const [name, version] = at > 0 ? [w.slice(0, at), w.slice(at + 1)] : [w, undefined]; const locked = cb.store .lockedPackages() .filter((l) => l.name === name) .at(-1)?.version; dependencies[name] = version ?? locked ?? "*"; } // the closure as TypeScript, next to the cache so the pinned packages resolve; compiled to JavaScript // with declarations const dir = path.resolve(cb.lay.root, opts.out); // the output is replaced, so it must be empty or an earlier pack: never the codebase, or files of yours const earlier = fs.existsSync(path.join(dir, "modules")) && fs.existsSync(path.join(dir, "package.json")); if ( fs.existsSync(dir) && fs.readdirSync(dir).length && (!earlier || !path.relative(dir, cb.lay.root).startsWith("..")) ) { throw new Error( `--out ${opts.out}: ${dir} isn't empty or an earlier tcm pack; pick another directory`, ); } const work = fs.mkdtempSync(path.join(cb.lay.cacheDir, "_pack-")); try { const files: string[] = []; let server = false; for (const c of comps) { const members = src.members(c); const text = render(members, src, cb.lay, { style: "cache", docs: true }).text; // (by what it imports, not its text: code mentioning the file's name isn't code using it) if ( kept.has(c) && members.some((m) => m.spans.some((s) => s.ext?.module === SERVER_HOST_MODULE)) ) server = true; const file = path.join(work, `${c}${members.some((m) => m.jsx) ? ".tsx" : ".ts"}`); fs.writeFileSync(file, text); files.push(file); } fs.rmSync(dir, { recursive: true, force: true }); const outModules = path.join(dir, "modules"); const ownFiles = ["tcm-host.js", "tcm-host.d.ts"]; if (server) { for (const f of ownFiles) fs.copyFileSync(path.join(cb.lay.cacheDir, f), path.join(work, f)); } const { program } = createProgram(files, { ...cb.recheckOptions(undefined), noEmit: false, declaration: true, outDir: outModules, rootDir: work, rewriteRelativeImportExtensions: true, allowImportingTsExtensions: false, sourceMap: false, noEmitOnError: false, module: ts_ModuleKind.ESNext, moduleResolution: ts_ModuleResolutionKind.Bundler, target: ts_ScriptTarget.ES2022, skipLibCheck: true, }); const emitted = program.emitToString(); for (const [file, { text }] of emitted.outputFiles) { // what another package provides is imported from it, not shipped again if (!kept.has(path.basename(file).replace(/\.(d\.ts|js)$/, ""))) continue; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, fromSiblings(forPackage(text, aliases), kept, provided)); } const missing = files .filter((f) => kept.has(path.basename(f).replace(/\.tsx?$/, ""))) .filter( (f) => !fs.existsSync(path.join(outModules, path.basename(f).replace(/\.tsx?$/, ".js"))), ); if (missing.length) { const why = emitted.diagnostics .map((d) => d.text) .slice(0, 3) .join("\n"); throw new Error(`couldn't compile ${missing.length} module(s)${why ? `:\n${why}` : ""}`); } if (server) for (const f of ownFiles) fs.copyFileSync(path.join(work, f), path.join(outModules, f)); } finally { fs.rmSync(work, { recursive: true, force: true }); } // entry points: each definition exported under its name, from its module const exportsField: Record<string, { types: string; default: string }> = {}; for (const e of entries) { const at = e.subpath === "." ? "" : e.subpath.replace(/^\.\//, ""); const entryDir = path.join(dir, at); const rel = path.relative(entryDir, path.join(dir, "modules")).split(path.sep).join("/"); const from = rel.startsWith(".") ? rel : `./${rel}`; const js: string[] = ["// generated by tcm pack"]; const dts: string[] = ["// generated by tcm pack"]; for (const x of e.exports) { const comp = cb.store.getDef(x.hash)!.componentHash; const line = (type: boolean) => `export ${type ? "type " : ""}{ ${exportId(x.hash)} as ${x.name} } from "${from}/${comp}.js";`; if (x.kind !== "type") js.push(line(false)); dts.push(line(x.kind === "type")); } fs.mkdirSync(entryDir, { recursive: true }); fs.writeFileSync(path.join(entryDir, "index.js"), js.join("\n") + "\n"); fs.writeFileSync(path.join(entryDir, "index.d.ts"), dts.join("\n") + "\n"); exportsField[e.subpath === "." ? "." : `./${at}`] = { types: `./${at ? `${at}/` : ""}index.d.ts`, default: `./${at ? `${at}/` : ""}index.js`, }; } // bins: the definition run as `tcm run` runs it (called with the arguments); a number it returns is the exit code const binField: Record<string, string> = {}; for (const b of bins) { const comp = cb.store.getDef(b.hash)!.componentHash; fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); fs.writeFileSync( path.join(dir, "bin", `${b.command}.js`), `#!/usr/bin/env node\n// generated by tcm pack: runs ${b.target}\nimport { ${exportId(b.hash)} as main } from "../modules/${comp}.js";\n` + `const result = typeof main === "function" ? await main(...process.argv.slice(2)) : main;\n` + `if (typeof result === "number") process.exitCode = result;\nelse if (result !== undefined) console.log(result);\n`, { mode: 0o755 }, ); binField[b.command] = `bin/${b.command}.js`; } if (opts.changelog) fs.writeFileSync(path.join(dir, "CHANGELOG.md"), opts.changelog); for (const f of opts.files) { const from = path.resolve(cb.lay.root, f); // or one of the codebase's files, or a namespace of them (pages, images), written out under its last name if (!fs.existsSync(from) && filesIn(cb, f).size) { const leaf = f.slice(f.lastIndexOf(".") + 1); const file = cb.store .hashesAt(f) .map((h) => cb.store.getDef(h)!) .find((d) => d.kind === "file"); if (file) fs.writeFileSync( path.join(dir, fileName(leaf, file)), fileContent(cb, file, f.slice(0, Math.max(0, f.lastIndexOf(".")))), ); exportFiles(cb, f, path.join(dir, leaf), false); continue; } if (!fs.existsSync(from)) throw new Error(`--files: ${f} doesn't exist, and isn't a namespace with files in it`); const rel = path.relative(cb.lay.root, from); fs.cpSync(from, path.join(dir, rel.startsWith("..") ? path.basename(from) : rel), { recursive: true, }); } // what package.json starts from (a VS Code extension's contributes), without what tcm works out itself const { dependencies: givenDeps, devDependencies: _dd, peerDependencies: _p, scripts: _s, ...given } = opts.packageJson ?? {}; // of the dependencies it gives, those shipped with the package (`file:./plugin`) stay: tcm can't see them const shipped = Object.entries((givenDeps ?? {}) as Record<string, string>).filter(([, v]) => v.startsWith("file:"), ); const entry = (exportsField["."] as { default?: string } | undefined)?.default; const pkg = { ...given, name: typeof given.name === "string" ? given.name : opts.name, version: opts.version, ...(opts.description && !given.description ? { description: opts.description } : {}), type: "module", ...(Object.keys(binField).length ? { bin: binField } : {}), ...(Object.keys(exportsField).length ? { exports: exportsField } : {}), // (an editor loads an extension from `main`) ...(given.main !== undefined && entry ? { main: entry } : {}), engines: { ...(given.engines as Record<string, string> | undefined), node: ">=22.18" }, dependencies: Object.fromEntries([...Object.entries(dependencies), ...shipped].sort()), }; fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); return { dir, modules: kept.size, dependencies: pkg.dependencies }; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.files.exportFiles
- tcmts.cli.files.fileContent
- tcmts.cli.files.fileName
- tcmts.cli.files.filesIn
- tcmts.cli.pack.PackOptions
- tcmts.cli.pack.exportsOf
- tcmts.cli.pack.forPackage
- tcmts.cli.pack.fromSiblings
- tcmts.cli.pack.provide
- tcmts.core.hash.exportId
- tcmts.core.program.createProgram
- tcmts.core.types.SERVER_HOST_MODULE
- tcmts.loader.DefSource
- tcmts.loader.externalsOf
- tcmts.loader.packages.npmAlias
- tcmts.loader.packages.parseModule
- tcmts.loader.render