render
uses node:path
import { statementStarts } from "#codebase/tcmts/core/docs"; import { exportId } from "#codebase/tcmts/core/hash"; import { extRefString, REACT_MODULE, tcmModule } from "#codebase/tcmts/core/types"; import type { DefRecord, ExtRef } from "#codebase/tcmts/core/types"; import { groupImports, importLine, initOrder, isAugmentation, lastSegment, moduleFile, realPackage, sanitize, TRANSFORM_MARK, TYPE_KEYWORDS, } from "#codebase/tcmts/loader"; import type { DefSource, Layout, Rendered, RenderOptions } from "#codebase/tcmts/loader"; import { npmAlias } from "#codebase/tcmts/loader/packages"; import { inlineSourceMap, lineSourceMap } from "#codebase/tcmts/loader/sourcemap"; import type { LineMapping } from "#codebase/tcmts/loader/sourcemap"; import path from "node:path"; export function render( members: DefRecord[], src: DefSource, lay: Layout, opts: RenderOptions, ): Rendered { const remap = opts.remap ?? ((h: string) => h); const pin = opts.remapPackage ?? ((m: string) => m); const memberSet = new Set(members.map((m) => m.hash)); // (and names the text this goes with binds otherwise: an import takes another) const used = new Set<string>([...members.flatMap((m) => m.reservedNames), ...(opts.avoid ?? [])]); const uniq = (base: string) => { let name = sanitize(base); for (let i = 1; used.has(name); i++) name = `${sanitize(base)}_${i}`; used.add(name); return name; }; const nameOf = opts.nameOf ?? src.nameOf; const displayName = (hash: string) => { const n = nameOf(hash); return n ? lastSegment(n) : (src.get(hash)?.nameHint ?? "h"); }; const memberNames = new Map<string, string>(); // Functions and classes keep the name they were hashed with, so `.name` agrees with the hash. That original name // is safe for the definition itself (its own locals coexisted with it in the original code); it only can't // shadow a global some member uses, or be captured by another member's local where that member refers to it. const allGlobals = new Set(members.flatMap((m) => m.globalNames ?? m.reservedNames)); const taken = new Set<string>(); for (const m of members) { const original = m.nominal || opts.preserveText; const name = sanitize(m.nameHint); // A reference written as exactly `name` in the original code can't be captured by a local of that name (it // wouldn't have resolved to this definition then); one written differently (an alias, a rename) could be. const captured = members.some( (a) => a !== m && a.reservedNames.includes(name) && !(a.globalNames ?? a.reservedNames).includes(name) && a.spans.some((s) => s.hash === m.hash && a.sourceText.slice(s.start, s.end) !== name), ); if (original && m.globalNames && !allGlobals.has(name) && !taken.has(name) && !captured) { memberNames.set(m.hash, name); taken.add(name); used.add(name); continue; } const chosen = uniq(original ? m.nameHint : displayName(m.hash)); memberNames.set(m.hash, chosen); taken.add(chosen); } interface Imp { local: string; hash?: string; ext?: ExtRef; value: boolean; } const imports = new Map<string, Imp>(); const localFor = (m: DefRecord, span: DefRecord["spans"][number]): string => { if (span.hash && memberSet.has(span.hash)) return memberNames.get(span.hash)!; const target = span.hash ? remap(span.hash) : undefined; // (an external is imported as each definition wrote it: two that named it differently, rendered together, keep // their names, and read as each does alone) const written = m.sourceText.slice(span.start, span.end); const key = target ? `#${target}` : `${extRefString(span.ext!)} as ${written}`; let imp = imports.get(key); if (!imp) { // codebase refs take their current name; externals keep the local name they were imported as const base = target && !opts.preserveText ? displayName(target) : written; imp = { local: uniq(TYPE_KEYWORDS.has(base) ? `${base}_` : base), hash: target, ext: span.ext, value: false, }; imports.set(key, imp); } if (span.pos === "value") imp.value = true; return imp.local; }; // a pure constant evaluated before: its value, kept, rather than computed again (the code stays, uncalled, for its // type, which other modules import it with) const keptValues = new Map( opts.style === "cache" && opts.kept ? members.flatMap((m) => { const k = opts.kept!(m.hash); return k ? [[m.hash, k] as const] : []; }) : [], ); const keptName = keptValues.size ? uniq("__tcm_kept") : ""; const bodies = new Map<string, string>(); /** the modules members augment (`declare module "…"`), as written */ const augmented = new Set<string>(); /** per member: each codebase reference's name, at its offset in the member's spliced body */ const bodyRefs = new Map<string, { start: number; end: number; hash: string }[]>(); for (const stored of members) { const k = keptValues.get(stored.hash); // (on the lines the initializer was on: what the source map says stays true) const before = `${keptName}(() => (`; const after = `), ${k?.value})`; const m = k ? { ...stored, sourceText: `${stored.sourceText.slice(0, k.start)}${before}${stored.sourceText.slice(k.start, k.end)}${after}${stored.sourceText.slice(k.end)}`, spans: stored.spans.map((s) => { const by = s.start >= k.end ? before.length + after.length : s.start >= k.start ? before.length : 0; return by ? { ...s, start: s.start + by, end: s.end + by } : s; }), } : stored; let text = m.sourceText; const placed: { start: number; end: number; nameAt: number; local: string; hash?: string; growth: number; }[] = []; for (const span of [...m.spans].sort((a, b) => b.start - a.start)) { if (span.hash && (span.literal || span.augmented)) { // a module augmentation merges where the definition it augments is declared: in its module, through its export // there (`declare module "./<module>" { namespace h_<hash> … }`); a scratch file's names its namespace's shim and // its current name const h = remap(span.hash); const rec = src.get(h); if (!rec) throw new Error(`missing definition #${h}`); const name = nameOf(h); const ns = name && name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : ""; const replacement = span.literal ? JSON.stringify( opts.style === "scratch" ? ns ? `#codebase/${ns.replaceAll(".", "/")}` : "#codebase" : `./${moduleFile(rec.componentHash, src)}`, ) : opts.style === "scratch" ? name ? lastSegment(name) : rec.nameHint : exportId(h); // (and the module is imported: TypeScript finds an augmentation's module only among those it has) if (span.literal) augmented.add(replacement); text = text.slice(0, span.start) + replacement + text.slice(span.end); continue; } if (span.literal && span.ext) { // `import("pkg")`: point at the pinned version (the real package name in editable scratch files) const e = span.ext; const own = tcmModule(e.module); const spec = own ? opts.style === "scratch" ? own.spec : `./${own.file}` : e.module.startsWith("npm:") ? (opts.style === "scratch" ? realPackage(e.module) : npmAlias(pin(e.module))) + (e.subpath ? `/${e.subpath}` : "") : e.module; text = text.slice(0, span.start) + JSON.stringify(spec) + text.slice(span.end); continue; } const local = localFor(m, span); const replacement = span.shorthand && span.shorthand !== local ? `${span.shorthand}: ${local}` : local; text = text.slice(0, span.start) + replacement + text.slice(span.end); const hash = span.hash ? remap(span.hash) : undefined; placed.push({ start: span.start, end: span.end, nameAt: replacement.length - local.length, local, hash, growth: replacement.length - (span.end - span.start), }); } bodies.set(m.hash, text); // spliced from the end, so a span moved by the growth of every span before it let shift = 0; const refs: { start: number; end: number; hash: string }[] = []; for (const p of placed.reverse()) { if (p.hash) refs.push({ start: p.start + shift + p.nameAt, end: p.start + shift + p.nameAt + p.local.length, hash: p.hash, }); shift += p.growth; } bodyRefs.set(m.hash, refs); } const lines: string[] = []; // the first line tells tcm's module hook to compile this module (Node's type stripping can't run it) const jsx = members.find((m) => m.jsx)?.jsx; if (opts.style === "cache" && members.some((m) => m.transform || m.jsx)) lines.push(TRANSFORM_MARK); if (opts.style === "cache") lines.push(`// generated by tcm — do not edit. component ${members[0]!.componentHash}`); if (jsx && opts.style !== "scratch" && jsx.startsWith("npm:")) lines.push(`/** @jsxImportSource ${npmAlias(pin(jsx))} */`); for (const imp of [...imports.values()].sort((a, b) => a.local.localeCompare(b.local))) { if (imp.hash) { const rec = src.get(imp.hash); if (!rec) throw new Error(`missing definition #${imp.hash}`); const typeOnly = rec.kind === "type" || !imp.value; const name = nameOf(imp.hash); if (opts.style === "scratch" && name) { const ns = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : ""; lines.push( importLine( imp.local, lastSegment(name), ns ? `#codebase/${ns.replaceAll(".", "/")}` : "#codebase", typeOnly, ), ); } else { const mod = moduleFile(rec.componentHash, src); // (from the scratch file's directory: a session's is one of its own) const cache = path .relative(opts.dir ?? lay.root, lay.cacheDir) .split(path.sep) .join("/"); const file = opts.style === "scratch" ? `${cache.startsWith("../") ? "" : "./"}${cache}/${mod}` : `./${mod}`; lines.push(importLine(imp.local, exportId(imp.hash), file, typeOnly)); } } else { const e = imp.ext!; let from = e.module; if (e.module.startsWith("npm:")) from = (opts.style === "scratch" ? realPackage(e.module) : npmAlias(pin(e.module))) + (e.subpath ? `/${e.subpath}` : ""); // tcm's own modules (#tcm/host): generated into the cache, so every module in a process shares // one instance const own = tcmModule(e.module); if (own) from = opts.style === "scratch" ? own.spec : `./${own.file}`; // React's cache and cacheSignal: tcm's (`tcm-cache.js`), which work in the server code it runs, as React's own // only do in its server components' build if ( opts.style !== "scratch" && imp.value && e.module === REACT_MODULE && !e.subpath && (e.export === "cache" || e.export === "cacheSignal") ) from = "./tcm-cache.js"; lines.push(importLine(imp.local, e.export, from, !imp.value)); } } for (const spec of [...augmented].sort()) lines.push(`import type {} from ${spec};`); // as written by hand: one import per module if (opts.style === "scratch") lines.splice(0, lines.length, ...groupImports(lines)); // the header comments stay: the module hook and JSX compilers read them if (opts.omitImports) lines.length = opts.style === "cache" ? lines.filter((l) => l.startsWith("//") || l.startsWith("/**")).length : 0; if (lines.length) lines.push(""); if (keptName) lines.push(`const ${keptName} = <T,>(_compute: () => T, value: unknown): T => value as T;`, ""); const ordered = opts.style === "scratch" ? members : initOrder(members); const bodyAt = new Map<string, { line: number; inserts: number[] }>(); // the bindings of one destructuring statement share its source: emit the statement once const emitted = new Set<string>(); for (const m of ordered) { if (emitted.has(m.sourceText)) continue; emitted.add(m.sourceText); if ((opts.style === "scratch" || opts.docs) && m.doc) lines.push( `/**\n${m.doc .split("\n") .map((l) => ` * ${l}`.trimEnd()) .join("\n")}\n */`, ); const note = opts.style === "scratch" ? opts.annotate?.(m.hash, memberNames.get(m.hash)!) : undefined; if (note) lines.push(note); // `export` before each statement (every overload of a function needs one), after any comments above it, so // `// @ts-expect-error` lines stay directly above the declaration const body = bodies.get(m.hash)!; const inserts = opts.exportDeclarations ? statementStarts(body, !!m.jsx) : []; let exported = body; for (const at of [...inserts].reverse()) exported = exported.slice(0, at) + "export " + exported.slice(at); bodyAt.set(m.hash, { line: lines.length, inserts }); lines.push(exported, ""); } // a definition with no imports must still be a module, or its names would be globals shared with other files if (opts.style === "internal") lines.push("export {};"); // an augmentation declares no name to export, but must be in a module (`declare global` is only allowed there) if (opts.style !== "internal" && members.some(isAugmentation)) lines.push("export {};"); if (opts.style === "cache") { for (const m of members) { if (isAugmentation(m)) continue; // what the host provides (`declare const process: …`): read from globalThis, typed as declared if (m.kind !== "type" && m.ir.startsWith('["Def","Declare"')) { lines.push( `export const ${exportId(m.hash)}: typeof ${memberNames.get(m.hash)} = (globalThis as any)[${JSON.stringify(m.nameHint)}];`, ); continue; } lines.push( `export ${m.kind === "type" ? "type " : ""}{ ${memberNames.get(m.hash)} as ${exportId(m.hash)} };`, ); } // functions carry their hash at runtime, so code can name them durably (e.g. a background job records which // exact function version to run) for (const m of members) { if (m.kind === "type" || m.ir.startsWith('["Def","Declare"')) continue; const local = memberNames.get(m.hash)!; // and so do React contexts and lazy components, which a server component's tree can send to the browser lines.push( // (once: a constant holding another definition's function, `const constVoid = constUndefined`, is that function, // which keeps the hash it has) `if ((typeof ${local} === "function" || [Symbol.for("react.context"), Symbol.for("react.lazy")].includes((${local} as any)?.$$typeof)) && !Object.hasOwn(${local}, Symbol.for("tcm.hash"))) Object.defineProperty(${local}, Symbol.for("tcm.hash"), { value: ${JSON.stringify(m.hash)} });`, ); // a table learns its row type's schema here (the type itself is erased) if (m.table) lines.push( `Object.defineProperty(${local}, Symbol.for("tcm.schema"), { value: ${JSON.stringify(m.table)} });`, ); } } // offsets in the joined text, for editors: each member's declaration and the references in it const lineStart: number[] = []; lines.reduce((at, l, i) => ((lineStart[i] = at), at + l.length + 1), 0); const refs: Rendered["refs"] = []; const starts = new Map<string, number>(); for (const [hash, b] of bodyAt) { const base = lineStart[b.line]!; const at = (o: number) => base + o + 7 * b.inserts.filter((i) => i <= o).length; starts.set(hash, base); for (const r of bodyRefs.get(hash) ?? []) refs.push({ start: at(r.start), end: at(r.end), hash: r.hash, from: hash }); } let text = lines.join("\n").trimEnd() + "\n"; // a module maps each member's lines to where the member reads as written: its view, or its scratch file if (opts.style === "cache" && src.viewOf) { const mappings: LineMapping[] = []; for (const [hash, b] of bodyAt) { const view = src.viewOf(hash); if (!view) continue; const first = text.slice(0, lineStart[b.line]).split("\n").length - 1; const body = bodies.get(hash)!; const source = path.relative(lay.cacheDir, view.file).split(path.sep).join("/"); // where the view puts `export ` in front of a statement: a line's columns shift past it const inserts = (view.inserts ?? []).map((o) => { const before = body.slice(0, o).split("\n"); return { line: before.length - 1, col: before.at(-1)!.length }; }); body.split("\n").forEach((lineText, i) => { const columns: [number, number][] = [[0, 0]]; for (const t of lineText.matchAll(/\S+/g)) if (t.index) columns.push([t.index, t.index]); for (const c of columns) c[1] += 7 * inserts.filter((x) => x.line === i && x.col <= c[0]).length; mappings.push({ line: first + i, source, sourceLine: view.line + i, columns }); }); } if (mappings.length) text += inlineSourceMap(lineSourceMap(moduleFile(members[0]!.componentHash, src), mappings)) + "\n"; } return { text, memberNames, refs, starts, inserts: new Map([...bodyAt].map(([h, b]) => [h, b.inserts])), }; }
uses
- tcmts.core.docs.statementStarts
- tcmts.core.hash.exportId
- tcmts.core.types.DefRecord
- tcmts.core.types.ExtRef
- tcmts.core.types.REACT_MODULE
- tcmts.core.types.extRefString
- tcmts.core.types.tcmModule
- tcmts.loader.DefSource
- tcmts.loader.Layout
- tcmts.loader.RenderOptions
- tcmts.loader.Rendered
- tcmts.loader.TRANSFORM_MARK
- tcmts.loader.TYPE_KEYWORDS
- tcmts.loader.groupImports
- tcmts.loader.importLine
- tcmts.loader.initOrder
- tcmts.loader.isAugmentation
- tcmts.loader.lastSegment
- tcmts.loader.moduleFile
- tcmts.loader.packages.npmAlias
- tcmts.loader.realPackage
- tcmts.loader.sanitize
- tcmts.loader.sourcemap.LineMapping
- tcmts.loader.sourcemap.inlineSourceMap
- tcmts.loader.sourcemap.lineSourceMap
used by
- tcmts.cli.codebase.Codebase
- tcmts.cli.diff.diffReport
- tcmts.cli.docs.renderDoc
- tcmts.cli.fmt.formatStored
- tcmts.cli.lint.lintDefinitions
- tcmts.cli.main.edit
- tcmts.cli.main.patchLocations
- tcmts.cli.main.patchedTogether
- tcmts.cli.main.spelledAlone
- tcmts.cli.main.view
- tcmts.cli.main.withCycle
- tcmts.cli.merge.diffDefinitions
- tcmts.cli.merge.scratchText
- tcmts.cli.pack.pack
- tcmts.cli.pages.definitionPage
- tcmts.cli.views.viewText
- tcmts.loader.materialize
- tcmts.loader.writePairModule
- tcmts.share.definitionLines