render

tcmts.loader.render · #rqkekuhx · term · runs on the server

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

used by