exportStatic

tcmts.cli.web.exportStatic · #msjg2pwa · term · runs on the server

uses node:path

Write a build out as a static site, for any static host (tcm build --static <dir>). A component app's pages are rendered as it's built (#4ydmpw3v): each page it links to, from /, is a file (pageFile) with its first paint, server components included, and what they rendered is kept in _app/trees/, for the app to read when it goes to another page, or before. Each tree's file (Flight's text) is named by its content (t.<sha256>.txt), and so is the map of them the pages name (_app/site.<sha256>.json: the file each tree is in, and the trees each page shows), so a browser keeps them for good: only the pages are asked for again. Also: 404.html, the page that renders any other path in the browser (GitHub Pages, Cloudflare Pages and others serve it for a path with no file), the assets in _app/assets/, a PWA's service worker (sw.js), which keeps every page, and .nojekyll (so GitHub Pages serves _app). It's for the path the site is served under (base). An app that calls server functions can't be one: there's no server to answer. Files already in dir stay, so building into the last build's directory keeps the assets a tab still running it may load.

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { appsDir, assetsDir, pageFile, pageUnder, prerender, serviceWorkerSource } from "#codebase/tcmts/cli/web";
import type { BuildManifest } from "#codebase/tcmts/cli/web";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";

export async function exportStatic(
  cb: Codebase,
  build: BuildManifest,
  dir: string,
  base: string,
): Promise<{ files: string[]; pages: number; warnings: string[] }> {
  const calls = build.endpoints.filter((h) => !build.components?.includes(h));
  if (calls.length) {
    const names = calls.map((h) => cb.displayRef(`#${h}`)).join(", ");
    throw new Error(
      `${build.app} calls functions on the server (${names}), and a static site has no server: run it with \`tcm start\``,
    );
  }
  const from = path.join(appsDir(cb), build.hash);
  // (said to the page first: server components read what the build rendered)
  const shell = pageUnder(fs.readFileSync(path.join(from, "index.html"), "utf8"), base).replace(
    /<head>/,
    "<head>\n<script>var __TCM_STATIC__ = true;</script>",
  );
  const site = build.ssr
    ? await prerender(cb, build, shell, base)
    : {
        pages: new Map([["/", shell]]),
        trees: new Map<string, string>(),
        uses: new Map<string, string[]>(),
        warnings: [],
      };
  const written: string[] = [];
  const write = (rel: string, content: string | Uint8Array) => {
    const file = path.join(dir, rel);
    fs.mkdirSync(path.dirname(file), { recursive: true });
    fs.writeFileSync(file, content);
    written.push(rel);
  };
  // each tree in a file named by its content (two the same are one), and the map of them named so too, which every
  // page names: what a page reads, a browser keeps for good
  const named = (text: string) => createHash("sha256").update(text).digest("hex").slice(0, 32);
  const files = new Map<string, string>();
  const treeFiles: Record<string, string> = {};
  for (const [name, tree] of site.trees) {
    const file = `t.${named(tree)}`;
    treeFiles[name] = file;
    files.set(`_app/trees/${file}.txt`, tree);
  }
  const map = JSON.stringify({ trees: treeFiles, pages: Object.fromEntries(site.uses) });
  const mapFile = `site.${named(map)}`;
  if (site.trees.size) files.set(`_app/${mapFile}.json`, map);
  const said = (html: string) =>
    site.trees.size
      ? html.replace(
          "var __TCM_STATIC__ = true;",
          () => `var __TCM_STATIC__ = ${JSON.stringify(mapFile)};`,
        )
      : html;
  for (const [p, html] of site.pages) write(pageFile(p), said(html));
  write("404.html", said(shell));
  write(".nojekyll", "");
  for (const [rel, text] of files)
    if (!fs.existsSync(path.join(dir, rel))) write(rel, text);
    else written.push(rel);
  for (const f of build.assets) {
    const rel = `_app/assets/${f}`;
    // (a name is its content: one there already is this one)
    if (fs.existsSync(path.join(dir, rel))) written.push(rel);
    else write(rel, fs.readFileSync(path.join(assetsDir(cb), f)));
  }
  if (build.pwa) {
    // it keeps every page and what they rendered, and is new whenever any of it is (a browser updates when it changes)
    const kept = [
      ...[...site.pages.keys()].map((p) => `.${p}`),
      "./404.html",
      ...[...files.keys()].map((rel) => `./${rel}`),
      ...build.assets.map((f) => `./_app/assets/${f}`),
    ];
    const version = createHash("sha256").update(build.hash);
    for (const html of site.pages.values()) version.update(html);
    version.update(map);
    // (a static site has no server: it opens from the device first)
    write(
      "sw.js",
      serviceWorkerSource(version.digest("hex").slice(0, 52), kept, true, "./404.html"),
    );
  }
  return { files: written, pages: site.pages.size, warnings: site.warnings };
}

uses

used by