Files
Trilium/apps/standalone/vite.config.mts
T
Elian DoranandClaude Opus 5.5 fb8518b549 build(share-theme): bundle the share theme with the client's Vite build
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]>
2026-10-09 23:17:51 +03:00

469 lines
19 KiB
TypeScript

import fs from "fs";
import { join, resolve, sep } from "path";
import { codecovVitePlugin } from "@codecov/vite-plugin";
import prefresh from "@prefresh/vite";
import { defineConfig, type Plugin } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { shareTheme, stripUniverEmojiData, stripUniverHyphenation } from "../client/vite-plugins.mjs";
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
const isDev = process.env.NODE_ENV === "development";
const FULL_COVERAGE = { lines: 100, functions: 100, branches: 100, statements: 100 };
// The share pages resolve built-in assets against `assets/v<version>`, the same prefix the server
// serves them under. Read from trilium-core because that is the version `assetUrlFragment` is
// built from; `chore:update-version` keeps every package.json in step.
const coreVersion = JSON.parse(
fs.readFileSync(join(__dirname, "../../packages/trilium-core/package.json"), "utf-8")
).version;
// Watch client files and trigger reload in development
const clientWatchPlugin = () => ({
name: "client-watch",
configureServer(server: any) {
if (isDev) {
// Watch client source files (adjusted for new root)
server.watcher.add("../../client/src/**/*");
server.watcher.on("change", (file: string) => {
if (file.includes("../../client/src/")) {
server.ws.send({
type: "full-reload"
});
}
});
}
}
});
// Serve PDF.js files directly in dev mode to bypass SPA fallback
const pdfjsServePlugin = (): Plugin => ({
name: "pdfjs-serve",
configureServer(server) {
const pdfjsRoot = join(__dirname, "../../packages/pdfjs-viewer/dist");
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith("/pdfjs/")) {
return next();
}
// Map /pdfjs/web/... to dist/web/...
// Map /pdfjs/build/... to dist/build/...
// Strip query string (e.g., ?v=0.102.2) before resolving path
const urlWithoutQuery = req.url.split("?")[0];
const relativePath = urlWithoutQuery.replace(/^\/pdfjs\//, "");
const filePath = join(pdfjsRoot, relativePath);
// Security: resolve both paths to prevent prefix-collision attacks
// (e.g. pdfjsRoot="/foo/bar" matching "/foo/bar2/evil.js")
const resolvedRoot = resolve(pdfjsRoot);
const resolvedFilePath = resolve(filePath);
if (!resolvedFilePath.startsWith(resolvedRoot + sep)) {
return next();
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = filePath.split(".").pop() || "";
const mimeTypes: Record<string, string> = {
html: "text/html",
css: "text/css",
js: "application/javascript",
mjs: "application/javascript",
wasm: "application/wasm",
png: "image/png",
svg: "image/svg+xml",
json: "application/json"
};
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
});
}
});
// Remove the hashed sqlite3.wasm duplicate from the bundle.
// Vite detects `new URL("sqlite3.wasm", import.meta.url)` in the sqlite-wasm library and
// emits a hashed copy (e.g. sqlite3-B5ovX4lD.wasm). Since the service worker needs the
// unhashed filename anyway (provided by viteStaticCopy), we drop the hashed duplicate.
const sqliteWasmDedupePlugin = (): Plugin => ({
name: "sqlite-wasm-dedupe",
generateBundle(_options, bundle) {
for (const fileName of Object.keys(bundle)) {
const asset = bundle[fileName];
if (asset.type === "asset" && /sqlite3-\w+\.wasm$/.test(fileName)) {
delete bundle[fileName];
}
}
}
});
// Copy SQLite WASM files so they're available to the service worker at runtime.
// The .wasm file must be copied with its original name because the sqlite-wasm library
// resolves it by convention inside the worker (where Vite's hashed import.meta.url won't work).
const sqliteWasmPlugin = viteStaticCopy({
targets: [
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
dest: "assets",
rename: { stripBase: true }
},
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
dest: "assets",
rename: { stripBase: true }
}
]
});
let plugins: any = [
stripUniverHyphenation(),
stripUniverEmojiData(),
sqliteWasmDedupePlugin(),
sqliteWasmPlugin,
viteStaticCopy({
targets: clientAssets.map((asset) => ({
src: `../../client/src/${asset}/**/*`,
dest: asset,
rename: { stripBase: 3 }
})),
// Enable watching in development
...(isDev && {
watch: {
reloadPageOnChange: true
}
})
}),
viteStaticCopy({
targets: [
{
src: [
"../../server/src/assets/**/*",
// Exclude the User Guide (~20 MB) since it's not needed in standalone mode.
"!../../server/src/assets/doc_notes/en/User Guide/**"
],
dest: "server-assets",
rename: { stripBase: 3 }
}
]
}),
// What the share theme's own pages load besides its bundle: the icon-pack fonts the client ships
// and the logo, each at the path content_renderer.ts writes into the page. The server answers
// these from express.static routes it registers in routes/assets.ts; here they are copied into
// the build instead.
viteStaticCopy({
targets: [
{
src: "../../client/src/fonts/**/*",
dest: "share/assets/fonts",
rename: { stripBase: 3 }
},
{
src: "../../server/src/assets/images/**/*",
dest: `assets/v${coreVersion}/images`,
rename: { stripBase: 4 }
}
]
}),
// PDF.js viewer for PDF preview support
// stripBase: 4 removes packages/pdfjs-viewer/dist/web (or /build)
viteStaticCopy({
targets: [
{
src: "../../../packages/pdfjs-viewer/dist/web/**/*",
dest: "pdfjs/web",
rename: { stripBase: 4 }
},
{
src: "../../../packages/pdfjs-viewer/dist/build/**/*",
dest: "pdfjs/build",
rename: { stripBase: 4 }
}
]
}),
// Watch client files for changes in development
...(isDev ? [
prefresh(),
clientWatchPlugin(),
pdfjsServePlugin()
] : []),
...shareTheme({ stubDir: "share/assets" })
];
if (!isDev) {
plugins = [
...plugins,
viteStaticCopy({
targets: [
{
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
dest: "",
}
]
})
]
}
// Include the integration test fixture database for e2e tests
if (process.env.TRILIUM_INTEGRATION_TEST) {
plugins = [
...plugins,
viteStaticCopy({
targets: [
{
// Forward slashes are required because fast-glob (used
// internally) treats backslashes as escape characters on
// Windows. `stripBase` drops the source's directory
// structure so the file lands flat at `test-fixtures/document.db`
// rather than mirroring the `packages/trilium-core/...` path.
src: join(__dirname, "../../packages/trilium-core/src/test/fixtures/document.db").replace(/\\/g, "/"),
dest: "test-fixtures",
rename: { stripBase: true }
}
]
})
]
}
if (!isDev) {
// Put the Codecov vite plugin after all other plugins.
// Gated on CODECOV_TOKEN so it stays a no-op locally and in the
// integration-test build (which sets no token).
plugins = [
...plugins,
codecovVitePlugin({
enableBundleAnalysis: !!process.env.CODECOV_TOKEN,
bundleName: "standalone",
uploadToken: process.env.CODECOV_TOKEN
})
];
}
export default defineConfig(() => ({
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
envDir: __dirname, // Load .env files from standalone directory, not src/
cacheDir: '../../../node_modules/.vite/apps/standalone',
base: "",
plugins,
// Use oxc for JSX transformation (Vite 8+ replaced the deprecated `esbuild` option with `oxc`)
oxc: {
jsx: {
runtime: 'automatic',
importSource: 'preact',
development: isDev
}
},
css: {
transformer: 'lightningcss',
devSourcemap: isDev
},
publicDir: join(__dirname, 'public'),
resolve: {
alias: [
{
find: "react",
replacement: "preact/compat"
},
{
find: "react-dom",
replacement: "preact/compat"
},
{
find: "@client",
replacement: join(__dirname, "../client/src")
},
// Bypass officeparser's `browser` entry — a prebuilt ~2.7 MB monolith that
// inlines pdfjs-dist for its PDF-parsing feature. The wrapper bundles the
// package's Node ESM entry instead (plus the Buffer polyfill it needs),
// keeping only the office-format parsers and the HTML generator (~0.4 MB).
// Guarded against upstream layout changes by office_preview.spec.ts.
{
find: /^officeparser$/,
replacement: join(__dirname, "src/stubs/officeparser_entry.ts")
},
// Heavy optional officeparser dependencies, only reachable via dynamic import
// on code paths office-format conversion never executes (PDF parsing, OCR,
// PDF generation via puppeteer). Without the stubs, Vite would either emit
// their multi-megabyte chunks or fail dev import-analysis on unresolvable
// specifiers.
{
find: /^pdfjs-dist(\/.*)?$/,
replacement: join(__dirname, "src/stubs/empty.ts")
},
{
find: /^tesseract\.js$/,
replacement: join(__dirname, "src/stubs/empty.ts")
},
{
find: /^puppeteer$/,
replacement: join(__dirname, "src/stubs/empty.ts")
},
// EJS renders the share pages. Its ESM entry imports `node:fs` and `node:path` for the
// file-loading it only reaches without an `includer`; the share renderer always passes
// one, so the package's own browser build serves it and resolves in this bundle.
{
find: /^ejs$/,
replacement: join(__dirname, "../../node_modules/ejs/ejs.min.js")
}
],
dedupe: [
"react",
"react-dom",
"preact",
"preact/compat",
"preact/hooks"
]
},
server: {
watch: {
// Watch workspace packages
ignored: ['!**/node_modules/@triliumnext/**'],
// Also watch client assets for live reload
usePolling: false,
interval: 100,
binaryInterval: 300
},
// Watch additional directories for changes
fs: {
allow: [
// Allow access to workspace root
'../../../',
// Explicitly allow client directory
'../../client/src/'
]
},
headers: {
// COOP is kept for security (prevents window.opener attacks).
// COEP is intentionally omitted: SAHPool (our primary SQLite VFS) does not
// require SharedArrayBuffer/COEP, and omitting it allows cross-origin iframes
// (e.g. in-app help pointing to docs.triliumnotes.org).
"Cross-Origin-Opener-Policy": "same-origin"
}
},
preview: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin"
}
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core'],
// Dynamically imported from the excluded @triliumnext/core, so the dep scanner
// never discovers it on its own. Pre-bundling matters beyond warm-up here: the
// officeparser alias resolves to CJS internals that only the dep optimizer
// converts to ESM in dev — without it, the dev server serves raw CJS and the
// import fails in the browser.
include: ['officeparser']
},
worker: {
format: "es" as const
},
commonjsOptions: {
transformMixedEsModules: true,
},
build: {
target: "esnext",
outDir: join(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
input: {
main: join(__dirname, 'src', 'index.html'),
sw: join(__dirname, 'src', 'sw.ts'),
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
},
output: {
// Everything below `src/` carries a content hash so a deployment switches over
// atomically: `index.html` is served uncached and can only ever reference chunks
// from its own build. Without the hash, a browser holding a still-fresh copy of
// one chunk mixes it with newly fetched ones — and since the minifier reassigns
// single-letter export names every build, a cross-build import silently binds to
// the wrong value ("X is not a function") until the cache expires.
entryFileNames: (chunkInfo) => {
// The service worker must keep a stable URL: `main.ts` registers it as
// `./sw.js`, and a hash would orphan the previously registered worker.
if (chunkInfo.name === "sw") {
return "[name].js";
}
return "src/[name]-[hash].js";
},
chunkFileNames: "src/[name]-[hash].js",
assetFileNames: "src/[name]-[hash].[ext]"
}
}
},
test: {
environment: "happy-dom",
setupFiles: [join(__dirname, "src/test_setup.ts")],
dir: join(__dirname),
reporters: [
"default",
// Absolute path on purpose: the Vite `root` above is `src`, so a
// relative outputFile would land in `src/test-output`. Anchor to the
// package dir to match the other apps and the CI upload path.
["junit", { outputFile: join(__dirname, "test-output/vitest/junit.xml"), addFileAttribute: true }]
],
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"../../packages/trilium-core/src/**/*.{test,spec}.{ts,tsx}"
],
coverage: {
// Absolute path for the same reason as the junit reporter above: the
// Vite `root` is `src`, so a relative path would land in
// `src/test-output`. Anchor to the package dir (matches the CI upload path).
reportsDirectory: join(__dirname, "test-output/vitest/coverage"),
provider: "v8" as const,
// trilium-core lives outside this project's `root` (which is `src`),
// so its files are only collected when `allowExternal` is enabled.
allowExternal: true,
// Vite `root` above is `src`, so coverage include globs resolve relative to src/.
// `../../../` walks src -> standalone -> apps -> repo root to reach the core package.
include: ["**/*.{ts,tsx}", "../../../packages/trilium-core/src/**/*.{ts,tsx}"],
exclude: [
"**/*.{test,spec}.{ts,mts,cts,tsx,js,jsx}",
"**/*.d.ts",
// Build/E2E config, not unit-testable.
"**/playwright.config.ts",
// Test harness, not product code.
"**/test_setup.ts",
// Thin re-export entry (forwards the client desktop entry).
"**/desktop.ts",
// The web-worker entry boots @triliumnext/core (and a second SQLite
// WASM init) on load, which conflicts with the unit-test harness's own
// core initialization; it is exercised by the Playwright e2e suite instead.
"**/local-server-worker.ts"
],
// Codecov resolves an lcov `SF:` path by matching it against the repo's file list.
// Vitest defaults the lcov reporter's `projectRoot` to the Vite `root` — here `src`,
// so paths would emit as bare `main.ts` / `../../../packages/trilium-core/src/…`,
// which are ambiguous in this monorepo and get attributed to whichever project wins
// the match. Anchor to the repo root so every path is unambiguous.
reporter: ["text", "html", ["lcov", { projectRoot: join(__dirname, "../..") }]],
// Threshold globs match paths relative to the root, `src`.
thresholds: {
"../../../packages/trilium-core/src/share/**": FULL_COVERAGE,
"lightweight/share_provider.ts": FULL_COVERAGE
}
},
server: {
deps: {
inline: ["@sqlite.org/sqlite-wasm"]
}
},
alias: {
// The package's `node.mjs` entry references a non-existent
// `sqlite3-node.mjs`. Force the browser-style entry which works
// under Node + happy-dom too.
"@sqlite.org/sqlite-wasm": join(
__dirname,
"../../node_modules/@sqlite.org/sqlite-wasm/index.mjs"
)
}
},
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
__TRILIUM_INTEGRATION_TEST__: JSON.stringify(process.env.TRILIUM_INTEGRATION_TEST ?? ""),
}
}));