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]>
469 lines
19 KiB
TypeScript
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 ?? ""),
|
|
}
|
|
})); |