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]>
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
import type { ServerResponse } from "node:http";
|
|
import { join, posix } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import type { ShareThemeManifest } from "@triliumnext/commons";
|
|
import {
|
|
buildVsCodeThemeCss, VS_CODE_DARK, VS_CODE_LIGHT
|
|
} from "@triliumnext/highlightjs/src/vs_code_theme.js";
|
|
import {
|
|
build, type Connect, type InlineConfig, normalizePath, type Plugin, type ResolvedConfig, type Rollup
|
|
} from "vite";
|
|
|
|
/**
|
|
* Drops the hyphenation machinery `@univerjs/engine-render` carries for Univer Docs.
|
|
* `shaping()` hyphenates only a paragraph whose section sets `autoHyphenation`, which
|
|
* the spreadsheet note type never turns on, so none of it can run:
|
|
*
|
|
* - The 77 TeX pattern tables, 4.4 MB of lazy chunks (Hungarian alone 747 kB). The
|
|
* entry makes exactly 77 relative imports and every one is a table, so resolving
|
|
* them all to one empty module collapses them into a single stub chunk.
|
|
* - `franc-min`, whose 102 kB trigram model backs the `LanguageDetector` call that
|
|
* `shaping()` makes ahead of the hyphenation check, on every paragraph it lays out.
|
|
*
|
|
* Both stubs are keyed on the importer, so a second consumer of `franc-min` would still
|
|
* get the real package. Both the client and the standalone build bundle Univer, so both
|
|
* apply this. `vite-plugins.spec.ts` checks the rules still match the installed
|
|
* dependency.
|
|
*/
|
|
export function stripUniverHyphenation(): Plugin {
|
|
return {
|
|
name: "strip-univer-hyphenation",
|
|
enforce: "pre",
|
|
resolveId: resolveUniverHyphenationStub
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the stub for a hyphenation import made by `@univerjs/engine-render`'s entry,
|
|
* and `null` for everything else so other resolvers keep their turn.
|
|
*/
|
|
export function resolveUniverHyphenationStub(source: string, importer: string | undefined): string | null {
|
|
if (!importer?.replace(/\\/g, "/").endsWith(ENGINE_RENDER_ENTRY)) {
|
|
return null;
|
|
}
|
|
|
|
if (source === LANGUAGE_DETECTOR_PACKAGE) {
|
|
return stubPath("franc_min");
|
|
}
|
|
|
|
return source.startsWith("./") ? stubPath("univer_hyphenation_pattern") : null;
|
|
}
|
|
|
|
export const ENGINE_RENDER_ENTRY = "@univerjs/engine-render/lib/es/index.js";
|
|
export const LANGUAGE_DETECTOR_PACKAGE = "franc-min";
|
|
|
|
function stubPath(name: string): string {
|
|
return fileURLToPath(new URL(`./src/stubs/${name}.ts`, import.meta.url));
|
|
}
|
|
|
|
/**
|
|
* Empties the data `@univerjs/ui` generates for its emoji picker: the emoji table in the
|
|
* entry (about 200 kB) and the search index and titles in every locale (about 520 kB each
|
|
* in English). Only the Univer Docs ribbon opens the picker, so the spreadsheet note type
|
|
* never shows it. The table keeps its category keys, which `EMOJI_CATEGORIES` is built
|
|
* from, and `getEmojiLocaleData()` reads the search index and titles as optional.
|
|
* `vite-plugins.spec.ts` checks the rules still match the installed dependency.
|
|
*/
|
|
export function stripUniverEmojiData(): Plugin {
|
|
return {
|
|
name: "strip-univer-emoji-data",
|
|
enforce: "pre",
|
|
transform: (code, id) => stripUniverEmojiSource(code, id)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns `code` with the generated emoji data emptied when `id` is the `@univerjs/ui`
|
|
* entry or one of its locales, and `null` for every other module.
|
|
*/
|
|
export function stripUniverEmojiSource(code: string, id: string): string | null {
|
|
const path = id.replace(/\\/g, "/");
|
|
if (path.endsWith(UI_ENTRY)) {
|
|
return replaceOnce(code, EMOJI_TABLE, (_, region: string, table: string) => {
|
|
const categories = Object.keys(JSON.parse(table) as Record<string, unknown>);
|
|
return `${region}const emojis = ${JSON.stringify(Object.fromEntries(categories.map((key) => [ key, [] ])))};\n`;
|
|
});
|
|
}
|
|
|
|
if (UI_LOCALE.test(path)) {
|
|
return replaceOnce(code, EMOJI_LOCALE, (_, region: string) => `${region}const emojiLocale = {};\n`);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const UI_ENTRY = "@univerjs/ui/lib/es/index.js";
|
|
export const UI_LOCALE = /@univerjs\/ui\/lib\/es\/locale\/[^/]+\.js$/;
|
|
export const EMOJI_TABLE = /(\/\/#region src\/views\/emoji-picker\/emojis\.generated\.ts\n)const emojis = (\{[\s\S]*?\n\});\n/;
|
|
export const EMOJI_LOCALE = /(\/\/#region src\/locale\/emoji-locale\/[^\n]+\.generated\.ts\n)const emojiLocale = \{[\s\S]*?\n\};\n/;
|
|
|
|
function replaceOnce(code: string, pattern: RegExp, replacer: (match: string, ...groups: string[]) => string): string {
|
|
if (!pattern.test(code)) {
|
|
throw new Error(`strip-univer-emoji-data: ${pattern} no longer matches; update vite-plugins.mts.`);
|
|
}
|
|
return code.replace(pattern, replacer);
|
|
}
|
|
|
|
|
|
/** The share theme's sources, which the app build bundles into the files shared pages load. */
|
|
const SHARE_THEME_SRC = join(import.meta.dirname, "../../packages/share-theme/src");
|
|
|
|
/**
|
|
* The directory the share theme's files go to: the one the app's chunks go to, so the share theme
|
|
* imports the chunks it shares with the app by their file name.
|
|
*/
|
|
const SHARE_THEME_DIR = "src";
|
|
|
|
/** The page's files, at the fixed paths `content_renderer.ts` writes into every page. */
|
|
const SCRIPTS_FILE = `${SHARE_THEME_DIR}/scripts.js`;
|
|
const STYLES_FILE = `${SHARE_THEME_DIR}/scripts.css`;
|
|
const TREE_FILE = `${SHARE_THEME_DIR}/tree.js`;
|
|
export const SHARE_THEME_MANIFEST_FILE = `${SHARE_THEME_DIR}/share_theme.json`;
|
|
|
|
/**
|
|
* The browsers `tree.js` and `scripts.css` are built for. Shared pages are open to any visitor,
|
|
* so they target older browsers than the app.
|
|
*/
|
|
const SHARE_THEME_TARGET = "chrome96";
|
|
|
|
/**
|
|
* The libraries the share theme imports on demand that a share-theme export copies only for the
|
|
* pages that use them, keyed by their name in {@link ShareThemeManifest.lazy}.
|
|
*/
|
|
const LAZY_LIBRARIES: Record<string, { specifier: string; importer: string }> = {
|
|
mermaid: { specifier: "mermaid", importer: "content/mermaid.ts" }
|
|
};
|
|
|
|
const CODE_THEMES_ID = "virtual:code-themes.css";
|
|
|
|
interface ShareThemeOptions {
|
|
/**
|
|
* Another directory for the page's three files, each of which loads its namesake in `src/`. A
|
|
* static host serves `/share/assets/` from there, as it cannot map that path to `src/`.
|
|
*/
|
|
stubDir?: string;
|
|
}
|
|
|
|
/**
|
|
* Builds the share theme as part of the app: `scripts.js` and its chunks next to the app's, so
|
|
* both load one copy of what they share, and `scripts.css` and `tree.js` in builds of their own.
|
|
* Writes the {@link ShareThemeManifest} the share-theme export copies the files by. The
|
|
* development server serves the page's scripts from source instead.
|
|
*/
|
|
export function shareTheme(options: ShareThemeOptions = {}): Plugin[] {
|
|
let config: ResolvedConfig | undefined;
|
|
|
|
return [
|
|
codeThemesPlugin(),
|
|
{
|
|
name: "share-theme-dev",
|
|
apply: "serve",
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
serveFromSource(server.config.base, req, res, next);
|
|
});
|
|
}
|
|
},
|
|
{
|
|
name: "share-theme-build",
|
|
apply: "build",
|
|
// After `vite:css-post`, which merges chunks holding only styles into their importers.
|
|
enforce: "post",
|
|
configResolved(resolvedConfig) {
|
|
config = resolvedConfig;
|
|
},
|
|
buildStart() {
|
|
const id = join(SHARE_THEME_SRC, "index.ts");
|
|
this.emitFile({ type: "chunk", id, fileName: SCRIPTS_FILE });
|
|
},
|
|
async generateBundle(_, bundle) {
|
|
await emitShareTheme(this, bundle as Record<string, BundleOutput>, config, options);
|
|
}
|
|
}
|
|
];
|
|
}
|
|
|
|
/** The modules the development server answers the page's scripts with. */
|
|
const DEV_SOURCES: Record<string, string> = {
|
|
"scripts.js": "index.ts",
|
|
"tree.js": "tree.ts"
|
|
};
|
|
|
|
/** Answers the page's files under `${base}share/assets/` from the share theme's sources. */
|
|
function serveFromSource(
|
|
base: string,
|
|
req: Connect.IncomingMessage,
|
|
res: ServerResponse,
|
|
next: () => void
|
|
) {
|
|
const prefix = `${base}share/assets/`;
|
|
const [ path ] = (req.url ?? "").split("?");
|
|
if (!path.startsWith(prefix)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const file = path.slice(prefix.length);
|
|
if (file === "scripts.css") {
|
|
// The development server injects the styles of the modules `scripts.js` imports.
|
|
res.setHeader("Content-Type", "text/css");
|
|
res.end("");
|
|
return;
|
|
}
|
|
|
|
const source = DEV_SOURCES[file];
|
|
if (source) {
|
|
req.url = `${base}@fs/${normalizePath(join(SHARE_THEME_SRC, source)).replace(/^\//, "")}`;
|
|
}
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Adds the share theme's `tree.js`, `scripts.css` with the files it references, the manifest and
|
|
* the stubs of `options` to the app's `bundle`, which holds `scripts.js` and its chunks.
|
|
*/
|
|
async function emitShareTheme(
|
|
context: Pick<Rollup.PluginContext, "emitFile" | "resolve">,
|
|
bundle: Record<string, BundleOutput>,
|
|
config: ResolvedConfig | undefined,
|
|
options: ShareThemeOptions
|
|
) {
|
|
const lazyEntries: Record<string, string> = {};
|
|
for (const [ name, { specifier, importer } ] of Object.entries(LAZY_LIBRARIES)) {
|
|
const resolved = await context.resolve(specifier, join(SHARE_THEME_SRC, importer));
|
|
if (!resolved) {
|
|
throw new Error(`Unable to resolve '${specifier}' from the share theme's ${importer}.`);
|
|
}
|
|
lazyEntries[name] = resolved.id;
|
|
}
|
|
|
|
const [ tree, styles ] = await Promise.all([ buildTreeScript(config), buildStyles(config) ]);
|
|
const emit = (fileName: string, source: string | Uint8Array) =>
|
|
context.emitFile({ type: "asset", fileName, source });
|
|
emit(TREE_FILE, tree);
|
|
emit(STYLES_FILE, styles.source);
|
|
const styleAssets = styles.assets.map((asset) => ({
|
|
...asset,
|
|
fileName: `${SHARE_THEME_DIR}/${asset.fileName}`
|
|
}));
|
|
for (const asset of styleAssets) {
|
|
if (!(asset.fileName in bundle)) {
|
|
emit(asset.fileName, asset.source);
|
|
}
|
|
}
|
|
|
|
const assetNames = styleAssets.map((asset) => asset.fileName);
|
|
const manifest = buildShareThemeManifest(bundle, lazyEntries, assetNames);
|
|
emit(SHARE_THEME_MANIFEST_FILE, JSON.stringify(manifest));
|
|
|
|
const { stubDir } = options;
|
|
if (stubDir) {
|
|
const toSource = (file: string) => posix.relative(stubDir, file);
|
|
emit(`${stubDir}/tree.js`, tree);
|
|
emit(`${stubDir}/scripts.js`, `import "${toSource(SCRIPTS_FILE)}";\n`);
|
|
emit(`${stubDir}/scripts.css`, `@import url("${toSource(STYLES_FILE)}");\n`);
|
|
}
|
|
}
|
|
/**
|
|
* Serves `virtual:code-themes.css`: the VS Code highlighting themes code notes also use, one per
|
|
* share theme mode. `:where()` keeps the block rules as weak as a stock highlight.js theme's, so
|
|
* the share theme's own `.ck-content code` colors still win inside text notes.
|
|
*/
|
|
function codeThemesPlugin(): Plugin {
|
|
return {
|
|
name: "share-theme-code-themes",
|
|
resolveId: (id) => (id === CODE_THEMES_ID ? `\0${CODE_THEMES_ID}` : null),
|
|
load: (id) => (id === `\0${CODE_THEMES_ID}`
|
|
? [
|
|
buildVsCodeThemeCss(VS_CODE_LIGHT, ":where(html.theme-light)"),
|
|
buildVsCodeThemeCss(VS_CODE_DARK, ":where(html.theme-dark)")
|
|
].join("\n")
|
|
: null)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bundles `tree.ts` on its own, so that it loads as one file: in the app's build, the code it
|
|
* shares with `scripts.js` would move into a chunk the page's first paint waits for.
|
|
*/
|
|
async function buildTreeScript(config: ResolvedConfig | undefined) {
|
|
const outputs = await buildShareThemeFile(config, "tree.ts", {});
|
|
const chunks = outputs.filter((output) => output.type === "chunk");
|
|
if (chunks.length !== 1 || outputs.length !== 1) {
|
|
throw new Error(`tree.ts built to ${outputs.length} files instead of one.`);
|
|
}
|
|
return chunks[0].code;
|
|
}
|
|
|
|
/**
|
|
* Builds the styles of everything `index.ts` imports into one stylesheet, in the order it imports
|
|
* them. In the app's build, the stylesheets the theme shares with the app move into the shared
|
|
* chunks, which reorders the cascade. Returns the stylesheet and the files it references.
|
|
*/
|
|
async function buildStyles(config: ResolvedConfig | undefined) {
|
|
const outputs = await buildShareThemeFile(config, "index.ts", {
|
|
base: "./",
|
|
plugins: [ codeThemesPlugin() ],
|
|
css: { transformer: config?.css.transformer },
|
|
build: {
|
|
cssCodeSplit: false,
|
|
assetsDir: "",
|
|
rollupOptions: {
|
|
external: Object.values(LAZY_LIBRARIES).map(({ specifier }) => specifier)
|
|
}
|
|
}
|
|
});
|
|
const assets = outputs.flatMap((output) => (output.type === "asset" ? [ output ] : []));
|
|
const stylesheets = assets.filter((asset) => asset.fileName.endsWith(".css"));
|
|
if (stylesheets.length !== 1) {
|
|
const count = stylesheets.length;
|
|
throw new Error(`The share theme's styles built to ${count} stylesheets instead of one.`);
|
|
}
|
|
|
|
return {
|
|
source: String(stylesheets[0].source),
|
|
assets: assets.filter((asset) => asset !== stylesheets[0])
|
|
.map(({ fileName, source }) => ({ fileName, source }))
|
|
};
|
|
}
|
|
|
|
/** Builds `input` of the share theme without writing it, with the minification of `config`. */
|
|
async function buildShareThemeFile(
|
|
config: ResolvedConfig | undefined,
|
|
input: string,
|
|
overrides: InlineConfig
|
|
) {
|
|
const result = await build({
|
|
...overrides,
|
|
configFile: false,
|
|
root: SHARE_THEME_SRC,
|
|
logLevel: "warn",
|
|
build: {
|
|
...overrides.build,
|
|
write: false,
|
|
copyPublicDir: false,
|
|
target: SHARE_THEME_TARGET,
|
|
minify: config?.build.minify,
|
|
cssMinify: config?.build.cssMinify,
|
|
rollupOptions: {
|
|
...overrides.build?.rollupOptions,
|
|
input: join(SHARE_THEME_SRC, input)
|
|
}
|
|
}
|
|
});
|
|
|
|
return (Array.isArray(result) ? result : [ result ]).flatMap((output) =>
|
|
"output" in output ? output.output : []);
|
|
}
|
|
|
|
type BundleOutput =
|
|
| { type: "asset"; fileName: string }
|
|
| {
|
|
type: "chunk";
|
|
fileName: string;
|
|
facadeModuleId: string | null;
|
|
imports: string[];
|
|
dynamicImports: string[];
|
|
viteMetadata?: { importedCss: Set<string>; importedAssets: Set<string> };
|
|
};
|
|
|
|
/**
|
|
* Lists the files the share theme loads: the page's three files, `styleAssets` that `scripts.css`
|
|
* references, and every chunk `scripts.js` loads, statically or on demand, with what each loads in
|
|
* turn. The styles of the chunks `scripts.js` imports statically are in `scripts.css`, so their
|
|
* stylesheets are listed only when a chunk loaded on demand imports them, which preloads them. What
|
|
* only the libraries in `lazyEntries` load is listed under their name, each given as the module ID
|
|
* the build resolves it to.
|
|
*/
|
|
export function buildShareThemeManifest(
|
|
bundle: Record<string, BundleOutput>,
|
|
lazyEntries: Record<string, string>,
|
|
styleAssets: string[]
|
|
): ShareThemeManifest {
|
|
const lazyChunks = new Map(Object.values(bundle).flatMap((output) => {
|
|
const name = output.type === "chunk" && output.facadeModuleId
|
|
? Object.keys(lazyEntries).find((key) => lazyEntries[key] === output.facadeModuleId)
|
|
: undefined;
|
|
return name ? [ [ output.fileName, name ] as const ] : [];
|
|
}));
|
|
for (const name of Object.keys(lazyEntries)) {
|
|
if (![ ...lazyChunks.values() ].includes(name)) {
|
|
throw new Error(`The bundle has no chunk for the share theme's '${name}'.`);
|
|
}
|
|
}
|
|
|
|
const pageFiles = collectFiles(bundle, [ SCRIPTS_FILE ], { dynamic: false });
|
|
const onDemand = [ ...pageFiles ].flatMap((fileName) => {
|
|
const output = bundle[fileName];
|
|
return output?.type === "chunk" ? output.dynamicImports : [];
|
|
});
|
|
const files = new Set([
|
|
...pageFiles,
|
|
...collectFiles(bundle, onDemand, { dynamic: true, excluded: new Set(lazyChunks.keys()) }),
|
|
TREE_FILE,
|
|
STYLES_FILE,
|
|
...styleAssets
|
|
]);
|
|
|
|
const lazy: Record<string, string[]> = {};
|
|
for (const [ fileName, name ] of lazyChunks) {
|
|
const lazyFiles = collectFiles(bundle, [ fileName ], { dynamic: true });
|
|
lazy[name] = toManifestPaths([ ...lazyFiles ].filter((file) => !files.has(file)));
|
|
}
|
|
|
|
return { files: toManifestPaths([ ...files ]), lazy };
|
|
}
|
|
|
|
/**
|
|
* Collects `entries` and the chunks and assets they import, without descending into `excluded`.
|
|
* With `dynamic`, also the chunks they load on demand and the stylesheets they preload.
|
|
*/
|
|
function collectFiles(bundle: Record<string, BundleOutput>, entries: string[], options: {
|
|
dynamic: boolean;
|
|
excluded?: Set<string>;
|
|
}) {
|
|
const files = new Set<string>();
|
|
const pending = [ ...entries ];
|
|
for (let fileName = pending.pop(); fileName !== undefined; fileName = pending.pop()) {
|
|
if (files.has(fileName) || options.excluded?.has(fileName)) {
|
|
continue;
|
|
}
|
|
|
|
files.add(fileName);
|
|
const output = bundle[fileName];
|
|
if (output?.type === "chunk") {
|
|
pending.push(...output.imports, ...(output.viteMetadata?.importedAssets ?? []));
|
|
if (options.dynamic) {
|
|
pending.push(...output.dynamicImports, ...(output.viteMetadata?.importedCss ?? []));
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/** Sorts `files` and makes them relative to the manifest, checking they share its directory. */
|
|
function toManifestPaths(files: string[]) {
|
|
const outside = files.filter((file) => posix.dirname(file) !== SHARE_THEME_DIR);
|
|
if (outside.length) {
|
|
const list = outside.join(", ");
|
|
throw new Error(`The share theme's files must all be in '${SHARE_THEME_DIR}/': ${list}.`);
|
|
}
|
|
return files.map((file) => posix.basename(file)).sort();
|
|
}
|