377 lines · 15.8 KB · TypeScript
RawDownload
import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import path from "node:path";import { fileURLToPath } from "node:url";import { defineConfig, minifySync, type Plugin } from "vite";import babel from "@rolldown/plugin-babel";import react, { reactCompilerPreset } from "@vitejs/plugin-react";import { lingui, linguiTransformerBabelPreset } from "@lingui/vite-plugin";import tailwindcss from "@tailwindcss/vite";import { privateFontsPlugin } from "./scripts/private-fonts.ts";import { reactCompilerSuppressionRules } from "./scripts/react-compiler-suppressions.ts"; const host = process.env.TAURI_DEV_HOST;// `LATTICE_FLAVOR=beta` builds Lattice Beta (docs/release-process.md), read by// src-tauri/build.rs too. Any other value is a typo that would silently build// the release flavor.const flavor = process.env.LATTICE_FLAVOR ?? "";if (flavor !== "" && flavor !== "beta") throw new Error(`LATTICE_FLAVOR=${flavor}: the only flavor is "beta"`);// `LATTICE_WEB_DEMO=1` builds the website's demo (scripts/web-demo.mjs): the// app swaps what needs the Mac (the Agent, whiteboards, Open Slide) for cards// saying so, and those parts are left out of the build.const webDemo = process.env.LATTICE_WEB_DEMO ?? "";if (webDemo !== "" && webDemo !== "1") throw new Error(`LATTICE_WEB_DEMO=${webDemo}: set it to 1 or leave it unset`); /** * `@pierre/diffs` imports the full `shiki` bundle, whose bundledLanguages and * bundledThemes maps dynamically import every grammar and theme shiki ships — * ~450 chunks, ~13 MB of the built app. At runtime only a closed set is ever * loadable: src/history/pierre-diff.ts is the only module that calls * registerCustomLanguage/registerCustomTheme, and its pierreLanguageForPath * clamps every filetype outside tex/bibtex/markdown to "text". Everything * outside that set is replaced with an inert stub grammar/theme so the chunks * shrink to ~100 bytes. * * If new code can highlight more languages at runtime, extend keepLangs below. * A missing grammar degrades silently to unhighlighted text rather than * throwing, so no test notices; the build fails instead when a module under * src/ imports a grammar or theme that is stubbed. Code that reaches one only * through shiki's own bundled maps is not caught, which is why the allowlist * comments name the one caller. */function shikiTrimPlugin(): {  plugin: Plugin;  isStubbed: (id: string) => boolean;} {  const keepLangs = new Set([    // The only grammars any runtime path can reach. src/history/pierre-diff.ts is    // the sole caller of registerCustomLanguage, and pierreLanguageForPath    // clamps every other filetype to "text", which needs no grammar.    "tex",    "bibtex",    "markdown",  ]);  const keepThemes = new Set([    // Pierre themes (src/history/pierre-diff.ts), the sole caller of    // registerCustomTheme.    "github-light",    "github-dark",  ]);  // The package only exports grammar subpaths, so locate dist/ via one of them.  const langsDist = path.dirname(    fileURLToPath(import.meta.resolve("@shikijs/langs/markdown")),  );  // Kept grammars statically import the grammars they embed (vue pulls html,  // css, …), so keep the transitive closure or embedded regions lose their  // rules.  const queue = [...keepLangs];  while (queue.length > 0) {    const name = queue.pop()!;    let source = "";    try {      source = readFileSync(path.join(langsDist, `${name}.mjs`), "utf8");    } catch {      continue;    }    for (const match of source.matchAll(/import \w+ from ['"]\.\/([\w.+-]+)\.mjs['"]/g)) {      if (!keepLangs.has(match[1])) {        keepLangs.add(match[1]);        queue.push(match[1]);      }    }  }  const moduleName = (id: string, kind: "langs" | "themes") => {    const match = id.match(new RegExp(`@shikijs/${kind}/dist/([\\w.+-]+)\\.mjs$`));    return match ? match[1] : null;  };  const isStubbed = (id: string) => {    const lang = moduleName(id, "langs");    if (lang) return !keepLangs.has(lang);    const theme = moduleName(id, "themes");    if (theme) return !keepThemes.has(theme);    return false;  };  const plugin: Plugin = {    name: "shiki-trim",    apply: "build",    load(id) {      const lang = moduleName(id, "langs");      if (lang && !keepLangs.has(lang)) {        return `export default [{ name: ${JSON.stringify(lang)}, scopeName: ${JSON.stringify(`source.${lang}`)}, patterns: [] }];`;      }      const theme = moduleName(id, "themes");      if (theme && !keepThemes.has(theme)) {        return `export default { name: ${JSON.stringify(theme)}, type: "dark", colors: {}, tokenColors: [] };`;      }      return null;    },    buildEnd(error) {      if (error) return;      const srcRoot = path.resolve("src") + path.sep;      const offenders = [...this.getModuleIds()].flatMap((id) => {        if (!isStubbed(id)) return [];        const importers = this.getModuleInfo(id)?.importers.filter((importer) => importer.startsWith(srcRoot)) ?? [];        return importers.map((importer) => `${path.relative(".", importer)} imports ${path.basename(id, ".mjs")}`);      });      if (offenders.length > 0) {        this.error(          `shiki-trim: these imports get an empty stub, so their code would render unhighlighted. Add the name to keepLangs or keepThemes in vite.config.ts:\n  ${offenders.join("\n  ")}`,        );      }    },  };  return { plugin, isStubbed };} /** * Univer's render engine (the spreadsheet editor, src/editor/spreadsheet/) * maps 77 hyphenation dictionaries to dynamic imports, ~4.6 MB of chunks, but * only asks for a closed set of them: its language detector maps what franc * recognises onto 33 dictionaries (LANG_MAP_TO_HYPHEN_LANG), and the * hyphenator itself loads en-gb and bundles en-us. The other 45 (~2.7 MB: * Norwegian, the 1996 and Swiss German spellings, …) can never be loaded, * so their loaders are dropped and their chunks are not emitted. * * The build fails instead when an upgrade changes how dictionaries are asked * for (a new loadPattern caller, a detector that no longer goes through the * map), so a dictionary a new path needs is never silently missing. */function univerHyphenTrimPlugin(): Plugin {  const engine = /\/@univerjs\/engine-render\/lib\/es\/index\.js$/;  return {    name: "univer-hyphen-trim",    apply: "build",    transform(code, id) {      if (!engine.test(id)) return null;      const fail = (why: string): never => this.error(        `univer-hyphen-trim: ${why}. Check which hyphenation dictionaries @univerjs/engine-render can load now, and update univerHyphenTrimPlugin in vite.config.ts.`,      );      const loaders = /^const PATTERN_LOADERS = \{\n([\s\S]*?)\n\};$/m.exec(code) ?? fail("PATTERN_LOADERS is gone");      const detector = /^const LANG_MAP_TO_HYPHEN_LANG = \{\n([\s\S]*?)\n\};$/m.exec(code) ?? fail("LANG_MAP_TO_HYPHEN_LANG is gone");      // Every way a dictionary is asked for, as of 0.25.1: the loader map is      // read in one place, the detector's map in one, and loadPattern is      // called with a literal or with the detector's answer, nothing else.      const count = (pattern: RegExp) => code.match(pattern)?.length ?? 0;      if (count(/PATTERN_LOADERS/g) !== 2) fail("PATTERN_LOADERS is read in a new place");      if (count(/LANG_MAP_TO_HYPHEN_LANG/g) !== 2) fail("LANG_MAP_TO_HYPHEN_LANG is read in a new place");      if (!/LANG_MAP_TO_HYPHEN_LANG\[franc\(text\)\]/.test(code)) fail("the language detector no longer maps franc's answer");      const calls = [...code.matchAll(/\.loadPattern\(([^)]*)\)/g)].map((match) => match[1]);      const literal = calls.flatMap((argument) => /^"([\w-]+)"$/.exec(argument)?.[1] ?? []);      if (calls.length !== literal.length + 1 || !/const lang = languageDetector\.detect\(content\);[^]{0,400}hyphen\.loadPattern\(lang\)/.test(code)) {        fail(`loadPattern is called in a new way (${calls.join(", ")})`);      }      const keep = new Set([        ...[...detector[1].matchAll(/:\s*"([\w-]+)"/g)].map((match) => match[1]),        ...literal,      ]);      const entries = loaders[1].split("\n");      const kept = entries.filter((entry) => {        const key = /^\t\["([\w-]+)"\]: \(\) => /.exec(entry)?.[1] ?? fail(`unexpected loader entry ${entry.trim()}`);        return keep.has(key);      });      if (kept.length === entries.length) return null;      return {        code: code.replace(loaders[0], `const PATTERN_LOADERS = {\n${kept.join("\n")}\n};`),        map: null,      };    },  };} /** Ship pdf.js CMaps + standard fonts next to the web assets (needed offline in Tauri). */function pdfjsAssetsPlugin(): Plugin {  const copy = () => {    const pdfjsRoot = path.dirname(fileURLToPath(import.meta.resolve("pdfjs-dist/package.json")));    const outRoot = path.resolve("public/pdfjs");    mkdirSync(outRoot, { recursive: true });    cpSync(path.join(pdfjsRoot, "cmaps"), path.join(outRoot, "cmaps"), { recursive: true });    cpSync(path.join(pdfjsRoot, "standard_fonts"), path.join(outRoot, "standard_fonts"), {      recursive: true,    });  };  return {    name: "pdfjs-assets",    buildStart: copy,    configureServer() {      copy();    },  };} /** * `virtual:webkit-polyfills`: public/polyfills.js as a module, for the PDF.js * worker to import ahead of PDF.js (src/pdf/pdf.worker.ts). index.html runs * the same file as a classic script before the app; Vite will not import a * file out of public/ directly. */function webkitPolyfillsModulePlugin(): Plugin {  const id = "virtual:webkit-polyfills";  const file = path.resolve("public/polyfills.js");  return {    name: "webkit-polyfills-module",    resolveId: (source) => (source === id ? `\0${id}` : null),    load(loadId) {      if (loadId !== `\0${id}`) return null;      this.addWatchFile(file);      return readFileSync(file, "utf8");    },  };} /** * Minifies dist/polyfills.js, which Vite copies out of public/ as it is. The * window loads it before the app on every launch, so it counts against the * eager JavaScript budget (scripts/app-size-report.mjs); the readable source, * comments included, is several times the size. The worker's copy is already * minified as part of its bundle. */function minifyPolyfillsPlugin(): Plugin {  let outDir = "";  return {    name: "minify-polyfills",    apply: "build",    configResolved(config) {      outDir = path.resolve(config.root, config.build.outDir);    },    closeBundle() {      const file = path.join(outDir, "polyfills.js");      const { code, errors } = minifySync(file, readFileSync(file, "utf8"));      if (errors.length) throw new Error(`Could not minify polyfills.js: ${errors.map((error) => error.message).join("; ")}`);      writeFileSync(file, code);    },  };} const shikiTrim = shikiTrimPlugin(); const manualChunkName = (id: string) => {  // Stubbed shiki grammars/themes are ~100 bytes each; folding them into one  // chunk avoids emitting ~450 near-empty asset files.  if (shikiTrim.isStubbed(id)) return "shiki-stubs";  if (id.includes("node_modules/@lezer")) return "parser";  if (id.includes("/node_modules/@codemirror/lang-markdown/") || id.includes("/node_modules/@codemirror/language-data/")) return undefined;  if (id.includes("/node_modules/@replit/codemirror-vim/") || id.includes("/node_modules/@replit/codemirror-emacs/")) return undefined;  if (id.includes("/node_modules/@codemirror/") || id.includes("/node_modules/@uiw/react-codemirror/")) return "editor";  if (id.includes("pdfjs-dist")) return "pdf-reader";  if (id.includes("gsap")) return "motion";  // Match react/react-dom exactly: the loose prefix also caught react-joyride,  // react-medium-image-zoom, etc., forcing them into the eager ui chunk.  if (id.includes("/node_modules/react/") || id.includes("/node_modules/react-dom/") || id.includes("/node_modules/scheduler/") || id.includes("node_modules/lucide-react")) return "ui";  return undefined;}; // https://vite.dev/config/export default defineConfig(() => ({  define: {    "import.meta.env.LATTICE_FLAVOR": JSON.stringify(flavor),    "import.meta.env.LATTICE_WEB_DEMO": JSON.stringify(webDemo === "1"),  },  plugins: [    // React Compiler auto-memoizes components it can prove safe, and silently    // skips the rest (e.g. anything the react-hooks lint still flags), so it is    // safe to enable across the app before every warning is cleaned up.    react(),    lingui(),    // Babel applies presets right-to-left: Lingui macros must expand first so    // the compiler sees ordinary React rather than macro-generated components.    babel({      presets: [        reactCompilerPreset({ target: "19", eslintSuppressionRules: reactCompilerSuppressionRules }),        linguiTransformerBabelPreset(),      ],    }),    tailwindcss(),    pdfjsAssetsPlugin(),    privateFontsPlugin(),    shikiTrim.plugin,    univerHyphenTrimPlugin(),    webkitPolyfillsModulePlugin(),    minifyPolyfillsPlugin(),  ],   resolve: {    alias: {      "@": path.resolve(fileURLToPath(new URL(".", import.meta.url)), "src"),    },  },   // The PDF.js worker (src/pdf/pdfjs-runtime.ts) is the only worker. PDF.js  // starts it as a module, and its wasm loaders read `import.meta.url`.  worker: {    format: "es",    plugins: () => [webkitPolyfillsModulePlugin()],  },   // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`  //  // 1. prevent Vite from obscuring rust errors  clearScreen: false,  build: {    // Vite's modulepreload polyfill is startup JS every window runs, for    // browsers without <link rel="modulepreload">. The macOS 14 minimum    // (tauri.conf.json) ships Safari 17's WebKit, which has it natively.    modulePreload: { polyfill: false },    rolldownOptions: {      // One entry, on purpose: scripts/app-size-report.mjs expects the app's      // chunks only.      input: {        app: path.resolve("index.html"),      },      output: {        codeSplitting: {          groups: [            {              name(id) {                const explicitName = manualChunkName(id);                if (explicitName) return explicitName;                 // Rolldown's automatic common chunks are split by every lazy                // entry signature, which shatters startup into many small                // requests. There is exactly one html entry, so every remaining                // `$initial` module is reachable from it: naming them all `app`                // recovers Rollup's coarser startup boundary. (While the icon                // lab was a second build input, modules shared with it were                // pulled out into a `provided-icons` chunk; with a single entry                // that split cannot arise.)                return "app";              },              tags: ["$initial"],              includeDependenciesRecursively: false,              priority: 1,            },            {              name: manualChunkName,              includeDependenciesRecursively: false,            },          ],        },      },    },  },  // 2. tauri expects a fixed port, fail if that port is not available  server: {    port: 1420,    strictPort: true,    host: host || false,    // Permit Amp's HTTPS portal host when reviewing isolated development pages.    allowedHosts: true,    hmr: host      ? {          protocol: "ws",          host,          port: 1421,        }      : undefined,    watch: {      // 3. tell Vite to ignore watching `src-tauri`      //      // `literature-worker/.wrangler` is a local `wrangler dev` Worker's Durable      // Object state. Its SQLite WAL files are rewritten on every request, and      // watching them made Vite full-reload the app mid-session.      //      // Matched as regexes rather than globs: a glob is evaluated per path, so      // events for a directory tree the dev server recreates (wrangler restart)      // slipped through before the pattern applied to its descendants.      ignored: [/[\\/]src-tauri[\\/]/, /[\\/]literature-worker[\\/]\.wrangler[\\/]/],    },  },}));