mirror of
https://github.com/TriliumNext/Trilium.git
synced 2026-10-11 22:49:56 +00:00
The share theme had a build of its own (esbuild, `packages/share-theme/scripts/build.ts`) whose
`dist/` the server, desktop, edit-docs, build-docs and standalone builds copied. It could share
nothing with the app: the previous commit had to load the client's mermaid through a dedicated
`share_mermaid` entry and a manifest to avoid shipping it twice. Any further library the share
pages want from the app (FullCalendar for collections) would need the same machinery.
`shareTheme()` in `apps/client/vite-plugins.mts` now builds the theme as part of the app, in both
the client and the standalone build:
- `scripts.js` is emitted as a chunk of the app's build, into `src/` beside the app's chunks, so
the two share what both import. Mermaid is a plain `import("mermaid")` again; the
`share_mermaid` entry, its manifest and the `/share/assets/client/` route are gone.
- `scripts.css` comes from a build of its own. In the app's build, the stylesheets the theme
shares with the app (tabs, multicolumn, collapsible blocks, to-do lists) land in shared chunks,
which put them before `ck-content.css` and changed the cascade. Built alone with
`cssCodeSplit: false`, the stylesheet keeps the order `index.ts` imports in: a rule-by-rule
comparison with the esbuild output shows the same rules in the same order, apart from
lightningcss rewrites and six heading rules it drops as overridden.
- `tree.js` also builds alone, so it stays one file: the first paint waits for it, and the app's
build would move what it shares with `scripts.js` into a chunk.
- Both separate builds target `chrome96`, as the esbuild build did, so `scripts.css` keeps
`max-width` media queries rather than range syntax. `scripts.js` and its chunks use the
client's `esnext`, as the shared mermaid already did.
- `src/share_theme.json` lists the files a page can load and, apart, the files only mermaid
loads (130 files, 5 MB). The static export copies the first set always and the second only
for a subtree with a diagram (`getShareThemeExportFiles()`). The stylesheets of the chunks
`scripts.js` imports statically are in `scripts.css`, so they are listed only when a chunk
loaded on demand imports them, since Vite's preload helper requests them then.
Serving:
- The server maps `/share/assets/` to the client build's `src/`. In development, the client's Vite
middleware answers `scripts.js` and `tree.js` from source and `scripts.css` empty, since Vite
injects the styles from the modules; a script or style change no longer needs
`pnpm --filter share-theme dev`.
- A static host cannot map `/share/assets/` to `src/`, so the standalone build also writes the
three files to `share/assets/`, `scripts.js` and `scripts.css` as one-line stubs importing their
namesakes in `src/`.
- A share-theme export needs the client build: a development server without one now fails with
a message instead of exporting without mermaid, and standalone's development server, which
builds no share theme, says it needs a production build.
The eager load of a page is 19 KB of JavaScript (14 KB before), as the shared chunks bring
Vite's preload helper; the stylesheet is 94 KB (91 KB).
The share theme's own scripts, `esbuild` and `sass` go, along with `share-theme` in `prepare`,
the `triggerBuildAndCopyTo` calls and their `script` parameter, and the copy of `dist/` in the
Nix build-docs package.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
import { execSync } from "child_process";
|
|
import { build as esbuild } from "esbuild";
|
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
import { delimiter, join } from "path";
|
|
|
|
export default class BuildHelper {
|
|
|
|
rootDir: string;
|
|
projectDir: string;
|
|
outDir: string;
|
|
|
|
constructor(projectPath: string) {
|
|
this.rootDir = join(__dirname, "..");
|
|
this.projectDir = join(this.rootDir, projectPath);
|
|
this.outDir = join(this.projectDir, "dist");
|
|
|
|
rmSync(this.outDir, { recursive: true, force: true });
|
|
mkdirSync(this.outDir, { recursive: true });
|
|
}
|
|
|
|
copy(projectDirPath: string, outDirPath: string) {
|
|
let sourcePath: string;
|
|
if (projectDirPath.startsWith("/") || projectDirPath.startsWith("\\")) {
|
|
sourcePath = join(this.rootDir, projectDirPath.substring(1));
|
|
} else {
|
|
sourcePath = join(this.projectDir, projectDirPath);
|
|
}
|
|
|
|
if (outDirPath.endsWith("/")) {
|
|
mkdirSync(join(this.outDir, outDirPath), { recursive: true });
|
|
}
|
|
cpSync(sourcePath, join(this.outDir, outDirPath), { recursive: true, dereference: true });
|
|
}
|
|
|
|
deleteFromOutput(path: string) {
|
|
rmSync(join(this.outDir, path), { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* @param entryPoints source entry points to bundle.
|
|
* @param opts.importMetaUrlShim redirects `import.meta.url` to the bundle's own file so
|
|
* bundled ESM deps calling `createRequire(import.meta.url)` work in CJS output (defaults
|
|
* to `true`). Must be disabled for scripts that run in Electron's sandboxed renderer (e.g.
|
|
* the desktop preload), where the injected `require("node:url")` banner throws. Ignored
|
|
* for ESM output, where `import.meta.url` needs no shim.
|
|
* @param opts.format output format (defaults to `"cjs"`). `"esm"` emits `.mjs` with code
|
|
* splitting: dynamic `import()` boundaries become separate chunks under `chunks/`, so
|
|
* V8 never parses or retains the source of a subsystem until it is first used. The
|
|
* desktop preload must stay `"cjs"` — Electron's sandboxed renderer cannot load ESM.
|
|
*/
|
|
async buildBackend(entryPoints: string[], opts: { importMetaUrlShim?: boolean; format?: "cjs" | "esm" } = {}) {
|
|
const { importMetaUrlShim = true, format = "cjs" } = opts;
|
|
const esm = format === "esm";
|
|
const result = await esbuild({
|
|
entryPoints: entryPoints.map(e => join(this.projectDir, e)),
|
|
tsconfig: join(this.projectDir, "tsconfig.app.json"),
|
|
platform: "node",
|
|
bundle: true,
|
|
outdir: this.outDir,
|
|
outExtension: {
|
|
".js": esm ? ".mjs" : ".cjs"
|
|
},
|
|
format,
|
|
splitting: esm,
|
|
chunkNames: "chunks/[name]-[hash]",
|
|
external: [
|
|
"electron",
|
|
"better-sqlite3",
|
|
"pdfjs-dist",
|
|
"./xhr-sync-worker.js",
|
|
"vite",
|
|
// tesseract.js requires it only when `global.fetch` is missing, which no
|
|
// supported Node or Electron version is. Bundling it would pull in
|
|
// `encoding` and a second iconv-lite.
|
|
"node-fetch",
|
|
// Test fixtures referenced via require.resolve from
|
|
// integration-test-only code paths in apps/server. These
|
|
// paths are gated at runtime by TRILIUM_INTEGRATION_TEST and
|
|
// never reached in production, but esbuild can't see through
|
|
// the gate during static analysis. Marking them external
|
|
// suppresses the spurious "require.resolve not external"
|
|
// warning without affecting the bundle behavior.
|
|
"@triliumnext/core/src/test/*",
|
|
// schema.sql is read via core_assets.ts, which prefers a
|
|
// bundled copy at RESOURCE_DIR/schema.sql (placed there by
|
|
// apps/server/scripts/build.ts) and only falls back to
|
|
// require.resolve in dev/test mode. In bundled production
|
|
// the require.resolve branch is unreachable, but esbuild
|
|
// still sees the static string and warns. External marker
|
|
// suppresses the warning without changing runtime behavior.
|
|
"@triliumnext/core/src/assets/*",
|
|
// The Codex ACP adapter is copied to RESOURCE_DIR/codex-acp.mjs by the
|
|
// build; codex_binary.ts only require.resolve()s the package when running
|
|
// from source.
|
|
"@agentclientprotocol/codex-acp"
|
|
],
|
|
metafile: true,
|
|
loader: {
|
|
".css": "text",
|
|
".ejs": "text"
|
|
},
|
|
define: {
|
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
// CJS output has no `import.meta`, so esbuild rewrites
|
|
// `import.meta.url` to `{}` (→ undefined). Bundled ESM deps
|
|
// (e.g. @anthropic-ai/claude-agent-sdk) call
|
|
// `createRequire(import.meta.url)` at module top level, which
|
|
// then throws `ERR_INVALID_ARG_VALUE`. Redirect it to the
|
|
// bundle's own file so createRequire()/`.resolve()` anchor at
|
|
// dist/ and can still locate sibling node_modules packages.
|
|
...(importMetaUrlShim && !esm && { "import.meta.url": "__bundleImportMetaUrl" }),
|
|
},
|
|
// The CJS banner defines the redirect target above. It uses
|
|
// `require("node:url")`, which throws in Electron's sandboxed
|
|
// renderer, so it must be omitted for preload-style bundles.
|
|
// The ESM banner is the mirror image: bundled CJS deps reference
|
|
// `require`, `__filename` and `__dirname`, which do not exist in
|
|
// ESM, so each output file defines them from `import.meta.url`.
|
|
// esbuild's `__require` interop helper picks up the banner's
|
|
// `require` binding for external packages (better-sqlite3 etc.).
|
|
// `__dirname` in a chunk resolves to the bundle root, not
|
|
// `chunks/`: bundled code uses it to find siblings of the entry
|
|
// (preload.cjs, image_worker.cjs, assets/), and which chunk a
|
|
// module lands in must not change what the path means. The
|
|
// `chunks` suffix check matches `chunkNames` above.
|
|
...(esm ? {
|
|
banner: {
|
|
js: [
|
|
`import { createRequire as __bundleCreateRequire } from "node:module";`,
|
|
`import { fileURLToPath as __bundleFileURLToPath } from "node:url";`,
|
|
`import { dirname as __bundleDirname } from "node:path";`,
|
|
`const require = __bundleCreateRequire(import.meta.url);`,
|
|
`const __filename = __bundleFileURLToPath(import.meta.url);`,
|
|
`const __bundleFileDir = __bundleDirname(__filename);`,
|
|
`const __dirname = /[\\\\/]chunks$/.test(__bundleFileDir) ? __bundleDirname(__bundleFileDir) : __bundleFileDir;`
|
|
].join("\n")
|
|
}
|
|
} : importMetaUrlShim && {
|
|
banner: {
|
|
js: `const __bundleImportMetaUrl = require("node:url").pathToFileURL(__filename).href;`
|
|
}
|
|
}),
|
|
minify: true
|
|
});
|
|
writeFileSync(join(this.outDir, "meta.json"), JSON.stringify(result.metafile));
|
|
}
|
|
|
|
/**
|
|
* Bundles the Tesseract.js OCR worker into the bundle root and copies the engine builds beside
|
|
* it, where their loaders read them from, one per CPU feature level.
|
|
*
|
|
* Only the full engine builds are copied, not the `-lstm` ones. The Node worker's `getCore()`
|
|
* compares its `lstmOnly` boolean against OEM numbers, so it never selects an `-lstm` build.
|
|
*
|
|
* @param entryPoint the path of `tesseract_worker.ts`, relative to the project.
|
|
*/
|
|
async buildTesseractWorker(entryPoint: string) {
|
|
await this.buildBackend([ entryPoint ]);
|
|
for (const variant of [ "", "-simd", "-relaxedsimd" ]) {
|
|
const file = `tesseract-core${variant}.wasm`;
|
|
this.copy(`/node_modules/tesseract.js-core/${file}`, file);
|
|
}
|
|
}
|
|
|
|
buildFrontend() {
|
|
this.triggerBuildAndCopyTo("apps/client", "public/");
|
|
|
|
// pdf.js
|
|
this.triggerBuildAndCopyTo("packages/pdfjs-viewer", "pdfjs-viewer");
|
|
}
|
|
|
|
/** Builds another workspace package and copies its `dist` into this one. */
|
|
triggerBuildAndCopyTo(projectToBuild: string, destPath: string) {
|
|
const projectDir = join(this.rootDir, projectToBuild);
|
|
execSync("pnpm build", { cwd: projectDir, stdio: "inherit" });
|
|
cpSync(join(projectDir, "dist"), join(this.projectDir, "dist", destPath), { recursive: true });
|
|
}
|
|
|
|
copyNodeModules(nodeModules: string[]) {
|
|
for (const moduleName of nodeModules) {
|
|
const sourceDir = tryPath([
|
|
join(this.projectDir, "node_modules", moduleName),
|
|
join(this.rootDir, "node_modules", moduleName)
|
|
]);
|
|
|
|
const destDir = join(this.outDir, "node_modules", moduleName);
|
|
mkdirSync(destDir, { recursive: true });
|
|
cpSync(sourceDir, destDir, { recursive: true, dereference: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strips everything from the copied better-sqlite3 that a packaged artifact
|
|
* never uses. Since v13 the package bundles a prebuilt binary for all eight
|
|
* platforms it supports (~17 MB) plus the SQLite sources needed to compile
|
|
* from scratch (~10 MB), but an artifact only ever loads one binary and never
|
|
* compiles: 27 MB -> ~2.2 MB.
|
|
*
|
|
* @param opts.platform the platform being built *for*. Defaults to the host's,
|
|
* but pass it explicitly for cross-packaging.
|
|
* @param opts.arch the architecture being built *for* -- not always the host's,
|
|
* as the macOS runners are arm64 and also package darwin-x64.
|
|
* @param opts.includeMusl whether to keep the musl build alongside the glibc one
|
|
* on Linux (defaults to `true`). Only the server needs it; see below.
|
|
*/
|
|
trimBetterSqlite3(opts: { platform?: string; arch?: string; includeMusl?: boolean } = {}) {
|
|
const { platform = process.platform, arch = targetArch(), includeMusl = true } = opts;
|
|
const moduleDir = join(this.outDir, "node_modules", "better-sqlite3");
|
|
const prebuildDir = join(moduleDir, "prebuilds");
|
|
|
|
// Every v13 install ships prebuilds/, so its absence means the module that
|
|
// got copied is not the one the lockfile pins. The usual cause is a stale
|
|
// pre-v13 tree in <app>/node_modules, which copyNodeModules prefers over the
|
|
// hoisted root -- pnpm's `hoisted` linker never creates those, so anything
|
|
// there is leftover residue. Fail here rather than shipping it: a pre-v13
|
|
// module needs the `bindings` package that is no longer copied, so the
|
|
// artifact would instead die at startup with a confusing resolution error.
|
|
if (!existsSync(prebuildDir)) {
|
|
const version = JSON.parse(readFileSync(join(moduleDir, "package.json"), "utf-8")).version;
|
|
throw new Error(
|
|
`better-sqlite3 ${version} in ${moduleDir} has no prebuilds/ directory. `
|
|
+ `Expected a v13+ layout -- delete any <app>/node_modules/better-sqlite3 and reinstall.`
|
|
);
|
|
}
|
|
|
|
// Keep both libc variants on Linux by default. The server's dist is built
|
|
// once on a glibc runner and then consumed by both the Debian and the Alpine
|
|
// images, and better-sqlite3 resolves linuxmusl-* at runtime on the latter --
|
|
// dropping it there would break the amd64 image at startup. Electron-based
|
|
// artifacts have no such consumer (Electron ships no musl builds), so they
|
|
// opt out and save the extra ~2.3 MB.
|
|
const keep = new Set([ `${platform}-${arch}.node` ]);
|
|
if (platform === "linux" && includeMusl) {
|
|
keep.add(`linuxmusl-${arch}.node`);
|
|
}
|
|
|
|
const available = readdirSync(prebuildDir);
|
|
if (!available.includes(`${platform}-${arch}.node`)) {
|
|
throw new Error(
|
|
`better-sqlite3 ships no prebuild for ${platform}-${arch} (found: ${available.join(", ")}). `
|
|
+ `Refusing to trim, since that would leave the artifact with no native addon.`
|
|
);
|
|
}
|
|
|
|
for (const file of available) {
|
|
if (!keep.has(file)) {
|
|
rmSync(join(prebuildDir, file));
|
|
}
|
|
}
|
|
|
|
// Compile-only: the SQLite amalgamation, the addon's own C++ sources, the
|
|
// gyp manifest and node-gyp's scratch output. Nothing in lib/ reads them.
|
|
for (const path of [ "deps", "src", "build", "binding.gyp" ]) {
|
|
rmSync(join(moduleDir, path), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
writeJson(relativePath: string, data: any) {
|
|
const fullPath = join(this.outDir, relativePath);
|
|
const dirPath = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
if (dirPath) {
|
|
mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
writeFileSync(fullPath, JSON.stringify(data, null, 4), "utf-8");
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* The architecture currently being built for. The release workflows set these when
|
|
* a job's target differs from the runner it happens to be executing on.
|
|
*/
|
|
function targetArch() {
|
|
return process.env.TARGET_ARCH || process.env.MATRIX_ARCH || process.arch;
|
|
}
|
|
|
|
function tryPath(paths: string[]) {
|
|
for (const path of paths) {
|
|
if (existsSync(path)) {
|
|
return path;
|
|
}
|
|
}
|
|
|
|
console.error("Unable to find any of the paths:", paths);
|
|
process.exit(1);
|
|
}
|