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]>
This commit is contained in:
Elian Doran
2026-10-09 23:17:51 +03:00
co-authored by Claude Opus 5.5
parent d9883a81e9
commit fb8518b549
32 changed files with 617 additions and 647 deletions
+21 -8
View File
@@ -30,12 +30,24 @@ packages/share-theme/
src/page/ page chrome: one script next to its CSS (layout, header, navigation,
search, toc, theme_switch, footer)
src/content/ content enhancements (math, mermaid) and content CSS
scripts/build.ts esbuild → dist/scripts.js + dist/scripts.css, and dist/tree.js on its own
(no code splitting, so it loads as one file) (run by tsx)
apps/client/vite-plugins.mts shareTheme(): the client and standalone builds bundle the theme
```
`packages/share-theme/package.json` exports `./templates/*` and `./model/*` **from source** (core
imports the model as `@triliumnext/share-theme/model/page`); everything else resolves to `dist/`.
imports the model as `@triliumnext/share-theme/model/page`). The theme has no build of its own:
`shareTheme()` in the client's Vite config writes it into the app's output.
- `scripts.js` and its chunks go to `src/`, beside the app's chunks, so the two share what both
import (Mermaid, commons, the tabs code). `/share/assets/` maps to that directory: an
`express.static` route on the server, and in standalone stubs in `share/assets/` that load their
namesakes in `src/`.
- `scripts.css` and `tree.js` come from builds of their own, targeting `chrome96`. `scripts.css`
keeps the import order of `index.ts`, which the app's chunking would change; `tree.js` is one
file, since the first paint waits for it.
- `src/share_theme.json` lists the files a page can load, and apart those only Mermaid loads.
The static export copies them into `assets/` (`getShareThemeExportFiles()`).
- A library the theme should load only on demand, and the export copy only when needed, goes in
`LAZY_LIBRARIES` with its own condition in the export.
## The render pipeline
@@ -121,8 +133,8 @@ template* page of the User Guide. So:
- Code shared with the app comes from commons or ckeditor5 by subpath
(`@triliumnext/commons/src/lib/…`, e.g. `enhanceLinkPreviews`, `getMermaidConfig`,
`applyTabs`); shared content CSS comes from `packages/ckeditor5/src/theme/`.
- Mermaid loads the client's `share_mermaid` entry through the `client/share_mermaid.json`
manifest, so diagrams match the app; it redraws on theme change.
- Mermaid is a plain `import("mermaid")`, the app's own copy, so diagrams match the app; it
redraws on theme change.
## Testing
@@ -168,9 +180,10 @@ can call any of them.
## Running and seeing a change
- The share theme bundle is served from `packages/share-theme/dist` in development: run
`pnpm --filter @triliumnext/share-theme build` (or `dev` to watch) after changing its scripts
or CSS.
- In development, the client's and standalone's Vite servers answer `scripts.js` and `tree.js`
from source and `scripts.css` empty (Vite injects the styles), so a script or style change
needs no rebuild. To see the built output, run `pnpm client:build`; an export from a
development server needs it too, since it copies the files `share_theme.json` lists.
- The Node provider **caches each template after the first read**, and core changes need the
server to restart: restart the dev server after changing a template or the renderer.
- Standalone bundles the templates (`?raw` imports): a **new partial** must be added to
+3 -5
View File
@@ -289,7 +289,7 @@ export async function buildDocsFromConfig(configPath?: string, gitRootDir?: stri
const config = await loadConfig(configPath);
if (gitRootDir) {
// Only a Trilium checkout (a gitRootDir) has the share theme and the client to build.
// Only a Trilium checkout (a gitRootDir) has the client to build the share theme with.
buildShareThemeAssets(gitRootDir);
}
@@ -323,12 +323,10 @@ export default async function buildDocs({ gitRootDir }: BuildContext) {
}
/**
* Builds the share theme and the client, whose mermaid the export copies next to the theme for
* the pages that have a diagram. The client builds for production: this process runs with
* `NODE_ENV=development`, under which the client's Vite config writes no `share_mermaid.json`.
* Builds the client, which writes the share theme the export copies. It builds for production
* rather than under this process's `NODE_ENV=development`.
*/
function buildShareThemeAssets(gitRootDir: string) {
execSync("pnpm run --filter share-theme dist", { stdio: "inherit", cwd: gitRootDir });
execSync("pnpm run --filter client build", {
stdio: "inherit",
cwd: gitRootDir,
-5
View File
@@ -1,5 +0,0 @@
/**
* The mermaid the share theme renders diagrams with. Shared pages load it through the manifest
* `shareMermaidManifest()` writes, so an installation ships one copy of mermaid, not two.
*/
export { default } from "mermaid";
+322 -60
View File
@@ -1,8 +1,14 @@
import { posix } from "node:path";
import type { ServerResponse } from "node:http";
import { join, posix } from "node:path";
import { fileURLToPath } from "node:url";
import type { ShareMermaidManifest } from "@triliumnext/commons";
import type { Plugin } from "vite";
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.
@@ -99,93 +105,349 @@ function replaceOnce(code: string, pattern: RegExp, replacer: (match: string, ..
return code.replace(pattern, replacer);
}
/** The name of the entry the share theme loads mermaid through, `src/share_mermaid.ts`. */
export const SHARE_MERMAID_ENTRY = "share_mermaid";
/** 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");
/**
* Adds `src/share_mermaid.ts` as the `share_mermaid` entry and writes its
* {@link ShareMermaidManifest} to `manifestPath`. Shared pages read the manifest to import the
* entry, and the share-theme export reads it to copy the files.
*
* The entry is emitted here rather than listed in `input` because an app build drops the exports
* of its entries, and shared pages import the entry's default export.
* 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.
*/
export function shareMermaidManifest(manifestPath: string): Plugin {
return {
name: "share-mermaid-manifest",
apply: "build",
buildStart() {
this.emitFile({
type: "chunk",
id: fileURLToPath(new URL("./src/share_mermaid.ts", import.meta.url)),
name: SHARE_MERMAID_ENTRY,
preserveSignature: "exports-only"
});
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);
});
}
},
generateBundle(_, bundle) {
this.emitFile({
type: "asset",
fileName: manifestPath,
source: JSON.stringify(buildShareMermaidManifest(bundle, manifestPath))
});
{
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;
name: string;
isEntry: boolean;
facadeModuleId: string | null;
imports: string[];
dynamicImports: string[];
viteMetadata?: { importedCss: Set<string>; importedAssets: Set<string> };
};
/**
* Collects the `share_mermaid` entry and everything it imports, statically or dynamically,
* including the CSS and assets Vite preloads alongside a chunk.
* 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 buildShareMermaidManifest(
export function buildShareThemeManifest(
bundle: Record<string, BundleOutput>,
manifestPath: string
): ShareMermaidManifest {
const entry = Object.values(bundle).find((output) =>
output.type === "chunk" && output.isEntry && output.name === SHARE_MERMAID_ENTRY);
if (!entry) {
throw new Error(`The bundle has no '${SHARE_MERMAID_ENTRY}' entry.`);
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 files = new Set<string>();
const pending = [ entry.fileName ];
for (let fileName = pending.pop(); fileName !== undefined; fileName = pending.pop()) {
const pageFiles = collectFiles(bundle, [ SCRIPTS_FILE ], { dynamic: false });
const onDemand = [ ...pageFiles ].flatMap((fileName) => {
const output = bundle[fileName];
if (!output || files.has(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);
if (output.type === "chunk") {
pending.push(
...output.imports,
...output.dynamicImports,
...(output.viteMetadata?.importedCss ?? []),
...(output.viteMetadata?.importedAssets ?? [])
);
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;
}
const directories = new Set([ ...files ].map((fileName) => posix.dirname(fileName)));
if (directories.size !== 1) {
const list = [ ...directories ].join(", ");
throw new Error(`The '${SHARE_MERMAID_ENTRY}' files span several directories: ${list}.`);
/** 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}.`);
}
const manifestDir = posix.dirname(manifestPath);
return {
entry: posix.relative(manifestDir, entry.fileName),
files: [ ...files ].sort().map((fileName) => posix.relative(manifestDir, fileName))
};
return files.map((file) => posix.basename(file)).sort();
}
+51 -37
View File
@@ -4,7 +4,7 @@ import { dirname, join } from "path";
import { describe, expect, it } from "vitest";
import {
buildShareMermaidManifest,
buildShareThemeManifest,
ENGINE_RENDER_ENTRY,
LANGUAGE_DETECTOR_PACKAGE,
resolveUniverHyphenationStub,
@@ -121,59 +121,73 @@ describe("stripUniverEmojiData", () => {
});
});
describe("buildShareMermaidManifest", () => {
const chunk = (fileName: string, extra: { name?: string; isEntry?: boolean; imports?: string[];
dynamicImports?: string[]; css?: string[] } = {}) => ({
describe("buildShareThemeManifest", () => {
const MERMAID_ID = "/node_modules/mermaid/dist/mermaid.core.mjs";
const chunk = (fileName: string, extra: { facade?: string; imports?: string[];
dynamicImports?: string[]; css?: string[]; assets?: string[] } = {}) => ({
type: "chunk" as const,
fileName,
name: extra.name ?? fileName,
isEntry: extra.isEntry ?? false,
facadeModuleId: extra.facade ?? null,
imports: extra.imports ?? [],
dynamicImports: extra.dynamicImports ?? [],
viteMetadata: { importedCss: new Set(extra.css ?? []), importedAssets: new Set<string>() }
viteMetadata: {
importedCss: new Set(extra.css ?? []),
importedAssets: new Set(extra.assets ?? [])
}
});
const asset = (fileName: string) => ({ type: "asset" as const, fileName });
const bundle = Object.fromEntries([
chunk("src/share_mermaid-a.js", {
name: "share_mermaid",
isEntry: true,
imports: [ "src/core-b.js" ]
chunk("src/scripts.js", {
imports: [ "src/shared-a.js", "src/shared-k.js" ],
dynamicImports: [ "src/fuse-b.js", "src/mermaid.core-c.js" ],
css: [ "src/scripts-j.css" ]
}),
chunk("src/core-b.js", {
dynamicImports: [ "src/flowchart-c.js", "src/elk-d.js" ],
css: [ "src/core-e.css" ]
chunk("src/shared-a.js", { css: [ "src/shared-a.css" ] }),
chunk("src/shared-k.js", { css: [ "src/shared-k.css" ] }),
chunk("src/fuse-b.js", {
imports: [ "src/shared-a.js" ],
css: [ "src/fuse-d.css" ],
assets: [ "src/font-e.woff2" ]
}),
chunk("src/flowchart-c.js", { imports: [ "src/core-b.js" ] }),
chunk("src/elk-d.js"),
{ type: "asset" as const, fileName: "src/core-e.css" },
chunk("src/index-f.js", { name: "index", isEntry: true, imports: [ "src/core-b.js" ] })
chunk("src/mermaid.core-c.js", {
facade: MERMAID_ID,
imports: [ "src/shared-k.js", "src/dagre-f.js" ],
dynamicImports: [ "src/flowchart-g.js" ]
}),
chunk("src/dagre-f.js"),
chunk("src/flowchart-g.js"),
chunk("src/index-h.js", { imports: [ "src/shared-a.js" ] }),
...[ "scripts-j.css", "shared-a.css", "shared-k.css", "fuse-d.css", "font-e.woff2" ]
.map((name) => asset(`src/${name}`))
].map((output) => [ output.fileName, output ]));
it("lists the entry and everything it loads, relative to the manifest", () => {
const files = [
"core-b.js", "core-e.css", "elk-d.js", "flowchart-c.js", "share_mermaid-a.js"
];
expect(buildShareMermaidManifest(bundle, "src/share_mermaid.json"))
.toEqual({ entry: "share_mermaid-a.js", files });
const standaloneManifest = "share/assets/client/share_mermaid.json";
expect(buildShareMermaidManifest(bundle, standaloneManifest)).toEqual({
entry: "../../../src/share_mermaid-a.js",
files: files.map((file) => `../../../src/${file}`)
it("lists what every page loads apart from what only mermaid loads", () => {
// The styles of the chunks `scripts.js` imports are in `scripts.css`, but a chunk loaded on
// demand preloads the stylesheets of the chunks it imports.
const styleAssets = [ "src/KaTeX-i.woff2" ];
const manifest = buildShareThemeManifest(bundle, { mermaid: MERMAID_ID }, styleAssets);
expect(manifest).toEqual({
files: [
"KaTeX-i.woff2", "font-e.woff2", "fuse-b.js", "fuse-d.css", "scripts.css",
"scripts.js", "shared-a.css", "shared-a.js", "shared-k.js", "tree.js"
],
lazy: {
mermaid: [ "dagre-f.js", "flowchart-g.js", "mermaid.core-c.js", "shared-k.css" ]
}
});
});
it("rejects a bundle without the entry or with files outside one directory", () => {
const MANIFEST = "src/share_mermaid.json";
const { "src/share_mermaid-a.js": _, ...withoutEntry } = bundle;
expect(() => buildShareMermaidManifest(withoutEntry, MANIFEST))
.toThrow("no 'share_mermaid' entry");
it("rejects a bundle without a library's chunk or with files outside one directory", () => {
expect(() => buildShareThemeManifest(bundle, { mermaid: "/elsewhere/mermaid.mjs" }, []))
.toThrow("no chunk for the share theme's 'mermaid'");
const nested = {
...bundle,
"src/elk-d.js": chunk("src/elk-d.js", { imports: [ "src/nested/g.js" ] }),
"src/nested/g.js": chunk("src/nested/g.js")
"src/fuse-b.js": chunk("src/fuse-b.js", { imports: [ "src/nested/k.js" ] }),
"src/nested/k.js": chunk("src/nested/k.js")
};
expect(() => buildShareMermaidManifest(nested, MANIFEST)).toThrow("several directories");
expect(() => buildShareThemeManifest(nested, { mermaid: MERMAID_ID }, []))
.toThrow("must all be in 'src/': src/nested/k.js");
});
});
+4 -3
View File
@@ -5,7 +5,7 @@ import { join } from 'path';
import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy'
import { shareMermaidManifest, stripUniverEmojiData, stripUniverHyphenation } from './vite-plugins.mjs';
import { shareTheme, stripUniverEmojiData, stripUniverHyphenation } from './vite-plugins.mjs';
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
@@ -19,13 +19,14 @@ if (isDev) {
// work on such a view; components then reload with the page instead of in place.
...(process.env.TRILIUM_NO_HMR ? [] : [ prefresh() ]),
stripUniverHyphenation(),
stripUniverEmojiData()
stripUniverEmojiData(),
...shareTheme()
];
} else {
plugins = [
stripUniverHyphenation(),
stripUniverEmojiData(),
shareMermaidManifest("src/share_mermaid.json"),
...shareTheme(),
viteStaticCopy({
targets: assets.map((asset) => ({
src: `src/${asset}/**/*`,
-1
View File
@@ -32,7 +32,6 @@ async function main() {
// The Codex ACP adapter runs as a script of its own in a worker thread. See the server's
// codex_binary.ts.
build.copy("/node_modules/@agentclientprotocol/codex-acp/dist/index.js", "assets/codex-acp.mjs");
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/", "dist");
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
// Copy node modules dependencies
-1
View File
@@ -11,7 +11,6 @@ async function main() {
// Copy assets from server (needed for DB initialization)
build.copy("/apps/server/src/assets", "assets/");
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/", "dist");
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
build.copy("/packages/ckeditor5/src/theme/multicolumn.css", "ckeditor5-multicolumn.css");
-1
View File
@@ -30,7 +30,6 @@ async function main() {
build.copy("/node_modules/@hyzyla/pdfium/dist/pdfium.wasm", "assets/pdfium.wasm");
// The Codex ACP adapter runs as a script of its own in a worker thread. See codex_binary.ts.
build.copy("/node_modules/@agentclientprotocol/codex-acp/dist/index.js", "assets/codex-acp.mjs");
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/", "dist");
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
// Copy node modules dependencies
+2 -2
View File
@@ -12,7 +12,7 @@ describe("assets path helpers", () => {
it("resolves dev paths under development", () => {
process.env.NODE_ENV = "development";
expect(getShareThemeAssetDir()).toContain("share-theme");
expect(getShareThemeAssetDir()).toBe(path.join(getClientBuildDir(), "src"));
expect(getPdfjsAssetDir()).toContain("pdfjs-viewer");
expect(getClientDir()).toContain("client");
expect(getClientBuildDir()).toBe(path.join(getClientDir(), "..", "dist"));
@@ -20,7 +20,7 @@ describe("assets path helpers", () => {
it("resolves resource-dir paths under production", () => {
process.env.NODE_ENV = "production";
expect(getShareThemeAssetDir()).toContain("share-theme");
expect(getShareThemeAssetDir()).toBe(path.join(getClientBuildDir(), "src"));
expect(getPdfjsAssetDir()).toContain("pdfjs-viewer");
expect(typeof getClientDir()).toBe("string");
expect(getClientBuildDir()).toBe(getClientDir());
+15 -13
View File
@@ -32,6 +32,8 @@ async function register(app: express.Application) {
max: 100 // limit each IP to 100 requests per windowMs
});
const clientFonts = express.static(path.join(getClientDir(), "fonts"), STATIC_OPTIONS);
app.use(`/share/assets/fonts/`, clientFonts);
if (process.env.NODE_ENV === "development") {
const { createServer: createViteServer } = await import("vite");
const clientDir = path.join(srcRoot, "../client");
@@ -80,8 +82,10 @@ async function register(app: express.Application) {
vite.middlewares(req, res, next);
});
app.use(`/node_modules/@excalidraw/excalidraw/dist/prod`, persistentCacheStatic(path.join(srcRoot, "../../node_modules/@excalidraw/excalidraw/dist/prod")));
app.get(`/share/assets/client/share_mermaid.json`, (_req, res) => {
res.json({ entry: `/${assetUrlFragment}/src/share_mermaid.ts`, files: [] });
// The client's Vite config answers the share theme's entries from source.
app.get(SHARE_THEME_ENTRIES.map((file) => `/share/assets/${file}`), (req, res, next) => {
req.url = `/${assetUrlFragment}${req.path}`;
vite.middlewares(req, res, next);
});
} else {
const publicDir = path.join(resourceDir, "public");
@@ -103,12 +107,8 @@ async function register(app: express.Application) {
app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(publicDir, "fonts")));
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
// The share theme loads the client's mermaid through `src/share_mermaid.json`.
const clientSrc = express.static(path.join(publicDir, "src"), STATIC_OPTIONS);
app.use(`/share/assets/client/`, clientSrc);
app.use(`/share/assets/`, express.static(getShareThemeAssetDir(), STATIC_OPTIONS));
}
app.use(`/share/assets/fonts/`, express.static(path.join(getClientDir(), "fonts"), STATIC_OPTIONS));
app.use(`/share/assets/`, express.static(getShareThemeAssetDir(), STATIC_OPTIONS));
app.use(`/pdfjs/`, persistentCacheStatic(getPdfjsAssetDir()));
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
@@ -121,13 +121,15 @@ async function register(app: express.Application) {
app.use(`/assets/vX/images`, express.static(path.join(resourceDir, "assets", "images"), STATIC_OPTIONS));
}
/** The fixed files of the share theme that every shared page loads. */
const SHARE_THEME_ENTRIES = [ "scripts.js", "tree.js", "scripts.css" ];
/**
* Where the client build writes the share theme, beside its own chunks: `src/` of the client's
* build output.
*/
export function getShareThemeAssetDir() {
if (process.env.NODE_ENV === "development") {
const srcRoot = path.join(__dirname, "..", "..");
return path.join(srcRoot, "../../packages/share-theme/dist");
}
const resourceDir = getResourceDir();
return path.join(resourceDir, "share-theme/assets");
return path.join(getClientBuildDir(), "src");
}
/** The client's build output: `public/` in a packaged app, `apps/client/dist` in development. */
@@ -1,16 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const MANIFEST = vi.hoisted(() => ({
entry: "share_mermaid-a.js",
files: [ "share_mermaid-a.js", "core-b.js" ]
files: [ "scripts.js", "scripts.css" ],
lazy: { mermaid: [ "mermaid.core-a.js" ] }
}));
const mockFs = vi.hoisted(() => ({
existsSync: vi.fn((_path: string) => true),
readdirSync: vi.fn(() => [ "styles.css", "scripts.js" ]),
readFileSync: vi.fn((path: string) => {
const normalized = path.split("\\").join("/");
if (normalized.endsWith("share_mermaid.json")) {
if (normalized.endsWith("share_theme.json")) {
return JSON.stringify(MANIFEST);
}
return new TextEncoder().encode(`content of ${normalized}`);
@@ -19,53 +18,44 @@ const mockFs = vi.hoisted(() => ({
vi.mock("fs", () => ({ default: mockFs, ...mockFs }));
vi.mock("../../../routes/assets", () => ({
getClientBuildDir: () => "/client-build",
getClientDir: () => "/client",
getShareThemeAssetDir: () => "/share-assets"
getShareThemeAssetDir: () => "/client-build/src"
}));
vi.mock("../../resource_dir", () => ({ RESOURCE_DIR: "/resource" }));
const registerShareProvider = vi.hoisted(() => vi.fn());
vi.mock("../../../share/share_provider.js", () => ({ registerShareProvider }));
const mockLog = vi.hoisted(() => ({ info: vi.fn() }));
vi.mock("@triliumnext/core/src/services/log.js", () => ({ getLog: () => mockLog }));
const { createShareThemeExportProvider } = await import("./share_theme.js");
describe("createShareThemeExportProvider", () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
mockLog.info.mockClear();
});
it("reads the share theme's files and the built-in fonts from disk", () => {
it("reads the files the manifest lists and the built-in fonts from disk", () => {
const { files, readBuiltinFont } = createAssets("<p>No diagrams.</p>");
expect(registerShareProvider).toHaveBeenCalled();
expect([ ...files.keys() ]).toEqual([ "icon-color.svg", "assets/styles.css", "assets/scripts.js" ]);
expect([ ...files.keys() ])
.toEqual([ "icon-color.svg", "assets/scripts.js", "assets/scripts.css" ]);
expect(decode(files.get("icon-color.svg"))).toBe("content of /resource/images/icon-color.svg");
expect(decode(files.get("assets/scripts.js"))).toBe("content of /share-assets/scripts.js");
expect(decode(files.get("assets/scripts.js")))
.toBe("content of /client-build/src/scripts.js");
expect(decode(readBuiltinFont("boxicons.woff2"))).toBe("content of /client/fonts/boxicons.woff2");
});
it("copies the client's mermaid from its manifest when a note has a diagram", () => {
it("adds the files of mermaid when a note has a diagram", () => {
const { files } = createAssets(MERMAID_BLOCK);
expect(JSON.parse(String(files.get("assets/client/share_mermaid.json")))).toEqual(MANIFEST);
for (const file of MANIFEST.files) {
expect(decode(files.get(`assets/client/${file}`)))
.toBe(`content of /client-build/src/${file}`);
}
expect(decode(files.get("assets/mermaid.core-a.js")))
.toBe("content of /client-build/src/mermaid.core-a.js");
});
it("exports without mermaid when the client build has no manifest", () => {
it("fails without a client build to read the share theme from", () => {
mockFs.existsSync.mockReturnValue(false);
const { files } = createAssets(MERMAID_BLOCK);
expect([ ...files.keys() ].filter((name) => name.startsWith("assets/client/"))).toEqual([]);
expect(mockLog.info)
.toHaveBeenCalledWith(expect.stringContaining("share_mermaid.json is missing"));
expect(() => createAssets("<p>No diagrams.</p>")).toThrow("share_theme.json is missing");
});
});
@@ -1,70 +1,41 @@
import type { ShareMermaidManifest } from "@triliumnext/commons";
import type { ShareThemeManifest } from "@triliumnext/commons";
import type { ZipExportProviderData } from "@triliumnext/core";
import ShareThemeExportProvider, {
hasMermaidDiagrams,
mapMermaidExportFiles,
getShareThemeExportFiles,
type ShareThemeExportAssets
} from "@triliumnext/core/src/services/export/zip/share_theme.js";
import { getLog } from "@triliumnext/core/src/services/log.js";
import { existsSync, readdirSync, readFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getClientBuildDir, getClientDir, getShareThemeAssetDir } from "../../../routes/assets";
import { getClientDir, getShareThemeAssetDir } from "../../../routes/assets";
import { registerShareProvider } from "../../../share/share_provider.js";
import { RESOURCE_DIR } from "../../resource_dir";
/**
* Builds a share-theme export over the theme's built files, the client's fonts and, when a note
* has a diagram, the client's mermaid, all read from disk.
* Builds a share-theme export over the files the client build lists in its share theme manifest
* and the client's fonts, all read from disk.
*/
export function createShareThemeExportProvider(data: ZipExportProviderData) {
registerShareProvider();
const assets = readShareThemeExportAssets();
if (hasMermaidDiagrams(data.branch.getNote())) {
addMermaidFiles(assets.files);
const assetDir = getShareThemeAssetDir();
const manifestPath = join(assetDir, "share_theme.json");
if (!existsSync(manifestPath)) {
throw new Error(`Unable to export with the share theme, since ${manifestPath} is missing.`
+ " Build the client first.");
}
return new ShareThemeExportProvider(data, assets);
}
function readShareThemeExportAssets(): ShareThemeExportAssets {
const shareThemeAssetDir = getShareThemeAssetDir();
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ShareThemeManifest;
const files = new Map<string, Uint8Array>([
[ "icon-color.svg", readFileSync(join(RESOURCE_DIR, "images", "icon-color.svg")) ]
]);
for (const file of readdirSync(shareThemeAssetDir)) {
files.set(`assets/${file}`, readFileSync(join(shareThemeAssetDir, file)));
for (const file of getShareThemeExportFiles(manifest, data.branch.getNote())) {
files.set(`assets/${file}`, readFileSync(join(assetDir, file)));
}
return {
const assets: ShareThemeExportAssets = {
files,
readBuiltinFont: (fileName) => readFileSync(join(getClientDir(), "fonts", fileName))
};
}
/**
* Copies the client's mermaid into the export. Without a client build to copy from, which a
* development server might lack, the exported pages show diagrams as code blocks.
*/
function addMermaidFiles(files: Map<string, string | Uint8Array>) {
const manifestDir = join(getClientBuildDir(), "src");
const manifestPath = join(manifestDir, "share_mermaid.json");
if (!existsSync(manifestPath)) {
getLog().info(`Exporting without mermaid, since ${manifestPath} is missing.`);
return;
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ShareMermaidManifest;
const mapped = mapMermaidExportFiles(manifest);
if (!mapped) {
getLog().info(`Exporting without mermaid, since ${manifestPath} lists no built files.`);
return;
}
files.set(mapped.manifest.path, mapped.manifest.content);
for (const { source, target } of mapped.files) {
files.set(target, readFileSync(join(manifestDir, source)));
}
return new ShareThemeExportProvider(data, assets);
}
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import BrowserZipProvider from "./zip_provider.js";
import { standaloneZipExportProviderFactory } from "./zip_export_provider_factory.js";
vi.mock("virtual:share-theme-assets", () => ({ default: [ "styles.css", "scripts.js" ] }));
// Vitest imports a stylesheet with `?raw` as an empty string, so the three stand in with markers.
vi.mock("@triliumnext/ckeditor5/src/theme/admonitions.css?raw", () => ({ default: ".admonition {}" }));
vi.mock("@triliumnext/ckeditor5/src/theme/ck-content.css?raw", () => ({ default: ".content {}" }));
@@ -45,9 +44,8 @@ describe("standaloneZipExportProviderFactory", () => {
expect(provider.constructor.name).toBe("MarkdownExportProvider");
});
it("creates a share-theme export provider over the theme files the build serves", async () => {
const fetchMock = vi.fn(async (url: string) => new Response(`content of ${url}`));
vi.stubGlobal("fetch", fetchMock);
it("creates a share-theme export provider over the files the manifest lists", async () => {
stubBuild();
const provider = await standaloneZipExportProviderFactory("share", makeData());
expect(provider.constructor.name).toBe("ShareThemeExportProvider");
@@ -56,53 +54,41 @@ describe("standaloneZipExportProviderFactory", () => {
files: Map<string, string | Uint8Array>;
readBuiltinFont(fileName: string): Uint8Array | undefined;
} }).assets;
expect([ ...files.keys() ]).toEqual([ "icon-color.svg", "assets/styles.css", "assets/scripts.js" ]);
expect([ ...files.keys() ])
.toEqual([ "icon-color.svg", "assets/scripts.js", "assets/scripts.css" ]);
expect(files.get("icon-color.svg")).toContain("<svg");
expect(new TextDecoder().decode(files.get("assets/styles.css") as Uint8Array))
.toBe("content of /share/assets/styles.css");
expect(new TextDecoder().decode(files.get("assets/scripts.css") as Uint8Array))
.toBe("content of /src/scripts.css");
expect(new TextDecoder().decode(readBuiltinFont("boxicons.woff2")))
.toBe("content of /share/assets/fonts/boxicons.woff2");
});
it("adds the client's mermaid through its manifest when a note has a diagram", async () => {
const manifest = { entry: "../../../src/entry-a.js", files: [ "../../../src/entry-a.js" ] };
vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(
url.endsWith("share_mermaid.json")
? JSON.stringify(manifest)
: `content of ${new URL(url, location.href).pathname}`
)));
it("adds the files of mermaid when a note has a diagram", async () => {
stubBuild();
const provider = await standaloneZipExportProviderFactory("share",
makeData(`<pre><code class="language-mermaid">graph TD;</code></pre>`));
type WithAssets = { assets: { files: Map<string, string | Uint8Array> } };
const { files } = (provider as unknown as WithAssets).assets;
expect(JSON.parse(files.get("assets/client/share_mermaid.json") as string))
.toEqual({ entry: "entry-a.js", files: [ "entry-a.js" ] });
expect(new TextDecoder().decode(files.get("assets/client/entry-a.js") as Uint8Array))
.toBe("content of /src/entry-a.js");
expect(new TextDecoder().decode(files.get("assets/mermaid.core-a.js") as Uint8Array))
.toBe("content of /src/mermaid.core-a.js");
});
it("exports without mermaid when the development server lists no built files", async () => {
const manifest = { entry: "/@fs/repo/apps/client/src/share_mermaid.ts", files: [] };
vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(
url.endsWith("share_mermaid.json") ? JSON.stringify(manifest) : "content"
)));
it("fails the share-theme export when the development server serves no manifest", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("<!doctype html>")));
const provider = await standaloneZipExportProviderFactory("share",
makeData(`<pre><code class="language-mermaid">graph TD;</code></pre>`));
type WithAssets = { assets: { files: Map<string, string | Uint8Array> } };
const names = [ ...(provider as unknown as WithAssets).assets.files.keys() ];
expect(names).toContain("assets/scripts.js");
expect(names.filter((name) => name.startsWith("assets/client/"))).toEqual([]);
await expect(standaloneZipExportProviderFactory("share", makeData()))
.rejects.toThrow("/src/share_theme.json is not its manifest");
});
it("fails the share-theme export when a theme file cannot be fetched", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 404 })));
vi.stubGlobal("fetch", vi.fn(async (url: string) => (url.endsWith("share_theme.json")
? new Response(JSON.stringify(MANIFEST))
: new Response("", { status: 404 }))));
await expect(standaloneZipExportProviderFactory("share", makeData()))
.rejects.toThrow("/share/assets/styles.css");
.rejects.toThrow("/src/scripts.js");
});
it("throws for an unsupported format", async () => {
@@ -111,3 +97,15 @@ describe("standaloneZipExportProviderFactory", () => {
).rejects.toThrow("Unsupported export format: 'pdf'");
});
});
const MANIFEST = {
files: [ "scripts.js", "scripts.css" ],
lazy: { mermaid: [ "mermaid.core-a.js" ] }
};
/** Serves {@link MANIFEST} and, for every other file, its path. */
function stubBuild() {
vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(
url.endsWith("share_theme.json") ? JSON.stringify(MANIFEST) : `content of ${url}`
)));
}
@@ -1,14 +1,12 @@
import type { ShareMermaidManifest } from "@triliumnext/commons";
import type { ShareThemeManifest } from "@triliumnext/commons";
import {
binary_utils,
type ExportFormat,
getLog,
icon_packs,
type ZipExportProviderData,
ZipExportProvider
} from "@triliumnext/core";
import type {
mapMermaidExportFiles,
ShareThemeExportAssets
} from "@triliumnext/core/src/services/export/zip/share_theme.js";
@@ -29,14 +27,13 @@ export async function standaloneZipExportProviderFactory(format: ExportFormat, d
return new MarkdownExportProvider(data);
}
case "share": {
const [ shareTheme, { registerShareProvider }, assets ] = await Promise.all([
const [ shareTheme, { registerShareProvider }, manifest ] = await Promise.all([
import("@triliumnext/core/src/services/export/zip/share_theme.js"),
import("./share_provider.js"),
loadShareThemeExportAssets()
loadShareThemeManifest()
]);
if (shareTheme.hasMermaidDiagrams(data.branch.getNote())) {
await addMermaidFiles(assets.files, shareTheme.mapMermaidExportFiles);
}
const files = shareTheme.getShareThemeExportFiles(manifest, data.branch.getNote());
const assets = await loadShareThemeExportAssets(files);
registerShareProvider();
return new shareTheme.default(data, assets);
}
@@ -46,21 +43,33 @@ export async function standaloneZipExportProviderFactory(format: ExportFormat, d
}
/**
* Fetches the share theme's files and the built-in icon fonts from `share/assets`, where the build
* copies them for the share pages. The export reads them synchronously, so they are all loaded
* before it starts.
* Fetches the manifest of the share theme, which the build writes beside its files in `src/`. The
* development server builds no share theme, so its exports fail here.
*/
async function loadShareThemeExportAssets(): Promise<ShareThemeExportAssets> {
const [ { default: themeFiles }, { default: iconColorSvg } ] = await Promise.all([
import("virtual:share-theme-assets"),
import("../../../server/src/assets/images/icon-color.svg?raw")
]);
async function loadShareThemeManifest(): Promise<ShareThemeManifest> {
const content = binary_utils.decodeUtf8(await fetchAsset(SHARE_THEME_MANIFEST));
try {
return JSON.parse(content) as ShareThemeManifest;
} catch {
throw new Error(`Unable to export with the share theme, since ${SHARE_THEME_MANIFEST} is`
+ " not its manifest. Exporting needs a production build.");
}
}
/**
* Fetches the share theme's `files` from `src/` and the built-in icon fonts from `share/assets`,
* where the build places them for the share pages. The export reads them synchronously, so they
* are all loaded before it starts.
*/
async function loadShareThemeExportAssets(themeFiles: string[]): Promise<ShareThemeExportAssets> {
const { default: iconColorSvg } =
await import("../../../server/src/assets/images/icon-color.svg?raw");
const fontFiles = icon_packs.getIconPacks()
.filter((iconPack) => iconPack.builtin)
.map((iconPack) => `${iconPack.fontAttachmentId}.${icon_packs.MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime]}`);
const [ themeContents, fontContents ] = await Promise.all([
Promise.all(themeFiles.map((file) => fetchAsset(`/share/assets/${file}`))),
Promise.all(themeFiles.map((file) => fetchAsset(`/src/${file}`))),
Promise.all(fontFiles.map((file) => fetchAsset(`/share/assets/fonts/${file}`)))
]);
@@ -76,33 +85,7 @@ async function loadShareThemeExportAssets(): Promise<ShareThemeExportAssets> {
};
}
/**
* Fetches the client's mermaid through the manifest the build writes next to the share theme. The
* development server's manifest lists no built files, so its exports show diagrams as code blocks.
*/
async function addMermaidFiles(
files: Map<string, string | Uint8Array>,
mapFiles: typeof mapMermaidExportFiles
) {
const manifestUrl = new URL(SHARE_MERMAID_MANIFEST, location.href);
const manifestBytes = await fetchAsset(manifestUrl.href);
const manifest = JSON.parse(binary_utils.decodeUtf8(manifestBytes)) as ShareMermaidManifest;
const mapped = mapFiles(manifest);
if (!mapped) {
getLog().info("Exporting without mermaid, since the manifest lists no built files.");
return;
}
const contents = await Promise.all(mapped.files.map(({ source }) =>
fetchAsset(new URL(source, manifestUrl).href)));
files.set(mapped.manifest.path, mapped.manifest.content);
for (const [ index, { target } ] of mapped.files.entries()) {
files.set(target, contents[index]);
}
}
const SHARE_MERMAID_MANIFEST = "/share/assets/client/share_mermaid.json";
const SHARE_THEME_MANIFEST = "/src/share_theme.json";
async function fetchAsset(url: string) {
const response = await fetch(url);
-5
View File
@@ -1,5 +0,0 @@
/** The file names in `packages/share-theme/dist`, listed by `shareThemeAssetListPlugin` in `vite.config.mts`. */
declare module "virtual:share-theme-assets" {
const files: string[];
export default files;
}
+9 -55
View File
@@ -6,16 +6,13 @@ import prefresh from "@prefresh/vite";
import { defineConfig, type Plugin } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { shareMermaidManifest, stripUniverEmojiData, stripUniverHyphenation } from "../client/vite-plugins.mjs";
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 };
// Next to the share theme's own files, where `scripts.js` looks for it.
const SHARE_MERMAID_MANIFEST = "share/assets/client/share_mermaid.json";
// 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.
@@ -23,27 +20,6 @@ const coreVersion = JSON.parse(
fs.readFileSync(join(__dirname, "../../packages/trilium-core/package.json"), "utf-8")
).version;
// Lists the share theme's built files as `virtual:share-theme-assets`, so the share-theme export
// can fetch each one from `share/assets`, where the static copy below places them.
const shareThemeAssetListPlugin = (): Plugin => {
const moduleId = "virtual:share-theme-assets";
const resolvedId = `\0${moduleId}`;
return {
name: "share-theme-asset-list",
resolveId: (id) => (id === moduleId ? resolvedId : undefined),
load(id) {
if (id !== resolvedId) {
return;
}
const distDir = join(__dirname, "../../packages/share-theme/dist");
const files = fs.existsSync(distDir) ? fs.readdirSync(distDir) : [];
return `export default ${JSON.stringify(files)};`;
}
};
};
// Watch client files and trigger reload in development
const clientWatchPlugin = () => ({
name: "client-watch",
@@ -62,19 +38,6 @@ const clientWatchPlugin = () => ({
}
});
// Points the share pages at the client's mermaid entry, which the build lists in its manifest.
const shareMermaidDevPlugin = (): Plugin => ({
name: "share-mermaid-dev",
configureServer(server) {
const entry = join(__dirname, "../client/src/share_mermaid.ts").replace(/\\/g, "/");
server.middlewares.use(`/${SHARE_MERMAID_MANIFEST}`, (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ entry: `/@fs/${entry.replace(/^\//, "")}`, files: [] }));
});
}
});
// Serve PDF.js files directly in dev mode to bypass SPA fallback
const pdfjsServePlugin = (): Plugin => ({
name: "pdfjs-serve",
@@ -162,7 +125,6 @@ let plugins: any = [
stripUniverEmojiData(),
sqliteWasmDedupePlugin(),
sqliteWasmPlugin,
shareThemeAssetListPlugin(),
viteStaticCopy({
targets: clientAssets.map((asset) => ({
src: `../../client/src/${asset}/**/*`,
@@ -189,17 +151,12 @@ let plugins: any = [
}
]
}),
// What the share theme's own pages load: its bundle (stylesheets, scripts, KaTeX fonts), 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.
// 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: "../../../packages/share-theme/dist/**/*",
dest: "share/assets",
rename: { stripBase: 3 }
},
{
src: "../../client/src/fonts/**/*",
dest: "share/assets/fonts",
@@ -232,11 +189,9 @@ let plugins: any = [
...(isDev ? [
prefresh(),
clientWatchPlugin(),
pdfjsServePlugin(),
shareMermaidDevPlugin()
] : [
shareMermaidManifest(SHARE_MERMAID_MANIFEST)
])
pdfjsServePlugin()
] : []),
...shareTheme({ stubDir: "share/assets" })
];
if (!isDev) {
@@ -404,8 +359,7 @@ export default defineConfig(() => ({
include: ['officeparser']
},
worker: {
format: "es" as const,
plugins: () => [ shareThemeAssetListPlugin() ]
format: "es" as const
},
commonjsOptions: {
transformMixedEsModules: true,
+12 -12
View File
@@ -7,15 +7,16 @@ The share theme represents the layout, styles and scripts behind the Share notes
* The HTML is defined in `src/templates` using EJS templating.
* `src/page` holds the parts of the page around the note (layout, header, navigation tree, theme switch, search, table of contents, footer) and `src/content` the styles and scripts for the note's own content (math, Mermaid, link embeds, adaptive colors). A script imports its own stylesheet, which sits next to it under the same name: `toc.ts` imports `./toc.css`.
* `src/index.ts` is the single entry point. It sets every script up and imports the stylesheets that have no script; the order of its imports is the order of the bundled stylesheet.
* The build emits `dist/scripts.js` and `dist/scripts.css`, the only stylesheet a shared page loads (`#shareOmitDefaultCss` drops it).
* The build emits `scripts.js`, `tree.js` and `scripts.css`, the only stylesheet a shared page loads (`#shareOmitDefaultCss` drops it).
## Building the share theme
* In `packages/share-theme`, run `pnpm build` to trigger a build. This will generate `dist` which will then be used by the server.
* Alternatively, use `pnpm dev` to watch for changes.
* `pnpm dist` is the same build, minified. It is what the app builds run, so a release ships the minified assets while local development keeps readable ones.
The share theme has no build of its own: the client's Vite build bundles it, through `shareTheme()` in `apps/client/vite-plugins.mts`, which standalone's build uses as well.
Both scripts clear `dist` first, since esbuild writes into it without removing what an earlier build left there — a minified release build would otherwise be copied alongside the unminified files `pnpm install` produces.
* `scripts.js` and its chunks are written to the client's `src/`, next to the app's chunks, so the theme and the app share one copy of what both use (such as Mermaid). The theme imports Mermaid with a plain `import("mermaid")`.
* `scripts.css` and `tree.js` are built on their own, for older browsers than the app (`chrome96`). `scripts.css` keeps the order in which `index.ts` imports the stylesheets, which the app's build would change by moving the ones the app shares into its chunks. `tree.js` builds to one file, since the page's first paint waits for it.
* `share_theme.json` lists the files a page can load, with those only Mermaid loads apart, for the static export.
* The development servers answer `scripts.js` and `tree.js` from source and `scripts.css` empty, since Vite injects the styles from the modules. A change to a script or a style needs no rebuild.
## Where the code lives
@@ -37,10 +38,9 @@ What differs per platform goes through the `ShareProvider` (`share_provider.ts`)
The server renders the templates using EJS templating from the share theme and hosts the assets.
* In dev mode, the templates and assets are served directly from `packages/share-theme/dist`.
* Modifications to the assets (scripts or styles) will reflect without having to restart the server. However the share theme needs to be built first (see previous section).
* In dev mode, the client's Vite server answers the theme's scripts from source (see the previous section), and the templates are read from `packages/share-theme/src/templates`.
* Changes to the template will require a restart of the server, since they are cached. Simply press Enter in the console with `pnpm server:start` to quickly trigger a restart.
* In production mode, the share theme is automatically built by the server build script and copied to `dist/share-theme`.
* In production mode, `/share/assets/` is served from the client build's `src/`, and the templates are copied to `dist/share-theme`.
`apps/server/src/share/routes.ts` is the Express adapter over the core handlers, and `apps/server/src/share/share_provider.ts` registers the provider above.
@@ -50,14 +50,14 @@ The server renders the templates using EJS templating from the share theme and h
* The subsystem is loaded by a dynamic `import()` on the first `/share/` request, which keeps EJS, the share theme and the syntax highlighter (~950 KB together) out of the worker's startup bundle. That only holds while nothing in the eager graph imports `share/index.ts`, which is why it is not re-exported from the `@triliumnext/core` barrel.
* `ejs` is aliased to its own browser build in `vite.config.mts`: the package's ESM entry imports `node:fs` and `node:path`, which it only needs when no `includer` is passed, and the renderer always passes one.
* The share theme's assets are copied into the build at the paths `content_renderer.ts` writes into the page (`share/assets`, `assets/v<version>/images`), in place of the `express.static` routes the server registers.
* The share theme's files are in `src/` with the app's chunks, and a static host cannot serve `share/assets` from there, so the build writes `scripts.js`, `scripts.css` and `tree.js` to `share/assets` as well, the first two loading their namesakes in `src/`. The icon fonts and images are copied to the paths `content_renderer.ts` writes into the page (`share/assets/fonts`, `assets/v<version>/images`), in place of the `express.static` routes the server registers.
* The pages are rendered by the tab holding the database, so at least one app tab must be open for a `/share/` URL to resolve.
## Exporting to static HTML files
The static export lives in `packages/trilium-core/src/services/export/zip/share_theme.ts`, so both the server and the standalone build offer it. It works quite similar to the normal sharing functionality, but it uses `BNote` instead of `SNote` (and so on for other entity types), in order to work regardless of whether a note is shared or not.
The same templates are used, except that the rendered pages are stored in the archive instead of served to web clients. The theme's built files and the built-in icon fonts reach the provider through `ShareThemeExportAssets`, which each platform fills in its zip export factory:
The same templates are used, except that the rendered pages are stored in the archive instead of served to web clients. The theme's built files and the built-in icon fonts reach the provider through `ShareThemeExportAssets`, which each platform fills in its zip export factory. Both copy the files `share_theme.json` lists into `assets/`, and those of Mermaid only when a note of the subtree has a diagram (`getShareThemeExportFiles()`):
* The server reads them from disk (`apps/server/src/services/export/zip/share_theme.ts`).
* The standalone build fetches them from `share/assets`, where it already copies them for the share pages. The file names come from the `virtual:share-theme-assets` module, which `vite.config.mts` generates from `packages/share-theme/dist` for both the page and the worker bundle.
* The server reads them from the client build's `src/` (`apps/server/src/services/export/zip/share_theme.ts`), so exporting from a development server needs `pnpm client:build` first.
* The standalone build fetches them from `/src/`. Its development server builds no share theme, so exporting needs a production build.
-4
View File
@@ -390,10 +390,6 @@
mkdir -p $out/opt/trilium-build-docs/client
cp --archive apps/client/dist/* $out/opt/trilium-build-docs/client/
# Copy share-theme (needed for exports)
mkdir -p $out/opt/trilium-build-docs/packages/share-theme
cp --archive packages/share-theme/dist/* $out/opt/trilium-build-docs/packages/share-theme/
# Create wrapper script
makeWrapper ${lib.getExe nodejs} $out/bin/trilium-build-docs \
--add-flags $out/opt/trilium-build-docs/cli.cjs \
+1 -1
View File
@@ -51,7 +51,7 @@
"dev:linter-check": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint .",
"dev:linter-fix": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint . --fix",
"postinstall": "pnpm prepare",
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall",
"prepare": "pnpm run --filter pdfjs-viewer build && pnpm run --filter web-clipper postinstall",
"update-contributors": "tsx ./scripts/update-contributors.ts"
},
"private": true,
+6 -5
View File
@@ -9,11 +9,12 @@ export interface Contributor {
role?: "lead-dev" | "original-dev";
}
/**
* Where the client's `share_mermaid` entry and every file it can load are, each path relative to
* the manifest. The client build writes it; shared pages read it to import mermaid, and the
* share-theme export reads it to copy the files. All the files sit in one directory.
* The files the share theme loads, which the app build writes beside its own chunks. The
* share-theme export reads it to copy them. Each path is relative to the manifest.
*/
export interface ShareMermaidManifest {
entry: string;
export interface ShareThemeManifest {
/** The files every page can load. */
files: string[];
/** The further files of each library loaded on demand, such as `mermaid`, by its name. */
lazy: Record<string, string[]>;
}
+1 -1
View File
@@ -6,4 +6,4 @@ The default theme for [shared notes](../../docs/User%20Guide/User%20Guide/Advanc
- `src/page/` — the parts of the page around the note (layout, header, navigation tree, search, table of contents, footer), and `src/content/` — the styles and enhancements for the note's own content (math, Mermaid, link embeds, adaptive colors). A script imports its own stylesheet, which shares its name and folder: `toc.ts` imports `./toc.css`.
- `src/index.ts` — the entry point. It sets every script up and imports the stylesheets that have no script; the order of its imports is the cascade order.
`pnpm --filter share-theme build` bundles them into `dist/scripts.js` and `dist/scripts.css`, which the server, desktop, standalone and static-export builds copy. `pnpm install` runs it, but no `*:start` script does, so rebuild after changing a script or style (or run `pnpm --filter share-theme dev` to watch).
The client's Vite build bundles them, with `shareTheme()` from `apps/client/vite-plugins.mts`, into `scripts.js`, `scripts.css` and `tree.js` beside its own chunks, so the theme and the app load one copy of the code they share, and lists the files in `share_theme.json` for the static export. The development servers answer the three files from source.
+3 -9
View File
@@ -4,15 +4,11 @@
"description": "Default theme for shared notes and static HTML exports",
"type": "module",
"scripts": {
"dev": "tsx scripts/build.ts --watch",
"build": "tsx scripts/build.ts",
"dist": "tsx scripts/build.ts --minify",
"test": "vitest run"
},
"exports": {
"./templates/*": "./src/templates/*",
"./model/*": "./src/model/*.ts",
"./*": "./dist/*"
"./model/*": "./src/model/*.ts"
},
"author": "Elian Doran <[email protected]>",
"contributors": [
@@ -22,16 +18,14 @@
"dependencies": {
"@triliumnext/commons": "workspace:*",
"fuse.js": "7.5.0",
"katex": "0.19.0"
"katex": "0.19.0",
"mermaid": "12.1.0"
},
"devDependencies": {
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@types/ejs": "3.1.5",
"ejs": "7.0.1",
"esbuild": "0.28.2",
"happy-dom": "20.14.5",
"sass": "1.105.1",
"typescript": "6.0.3",
"vitest": "5.0.3"
}
-119
View File
@@ -1,119 +0,0 @@
import path from "node:path";
import * as esbuild from "esbuild";
import { rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import {
buildVsCodeThemeCss, VS_CODE_DARK, VS_CODE_LIGHT
} from "@triliumnext/highlightjs/src/vs_code_theme.js";
import * as sass from "sass";
const packageJson = process.env.npm_package_json;
if (!packageJson) {
throw new Error("Run the build through pnpm, which sets npm_package_json.");
}
const rootDir = path.dirname(packageJson);
// Sass resolves relative paths on its own; bare specifiers such as
// "katex/src/styles/katex.scss" go through Node.
const nodeModulesImporter: sass.FileImporter<"sync"> = {
findFileUrl(url) {
if (url.startsWith(".") || url.startsWith("/")) {
return null;
}
return pathToFileURL(createRequire(path.join(rootDir, "package.json")).resolve(url));
}
};
// esbuild has no Sass support of its own. katex.scss needs it to build KaTeX's
// stylesheet without the woff and ttf faces.
const sassPlugin: esbuild.Plugin = {
name: "sass",
setup(build) {
build.onLoad({ filter: /\.scss$/ }, (args) => ({
contents: sass.compile(args.path, { importers: [nodeModulesImporter] }).css,
loader: "css",
resolveDir: path.dirname(args.path)
}));
}
};
// 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.
const codeThemesPlugin: esbuild.Plugin = {
name: "code-themes",
setup(build) {
build.onResolve({ filter: /^virtual:code-themes\.css$/ }, (args) => ({
path: args.path,
namespace: "code-themes"
}));
build.onLoad({ filter: /.*/, namespace: "code-themes" }, () => ({
contents: [
buildVsCodeThemeCss(VS_CODE_LIGHT, ":where(html.theme-light)"),
buildVsCodeThemeCss(VS_CODE_DARK, ":where(html.theme-dark)")
].join("\n"),
loader: "css"
}));
}
};
const outDir = path.join(rootDir, "dist");
async function runBuild(watch: boolean) {
const before = performance.now();
// esbuild leaves its outdir as it found it, and every `pnpm install` writes an unminified
// build there, so a minified release build would land beside those files and both sets would
// be copied into the app.
rmSync(outDir, { recursive: true, force: true });
const opts: esbuild.BuildOptions = {
entryPoints: [ { in: path.join(rootDir, "src", "index.ts"), out: "scripts" } ],
bundle: true,
splitting: true,
outdir: outDir,
format: "esm",
target: ["chrome96"],
loader: {
".png": "dataurl",
".gif": "dataurl",
".woff": "file",
".woff2": "file",
".ttf": "file",
".eot": "empty",
".svg": "empty",
".html": "text",
".css": "css"
},
plugins: [ sassPlugin, codeThemesPlugin ],
logLevel: "info",
metafile: true,
minify: process.argv.includes("--minify")
};
// `tree.js` runs before the page is first drawn, so it is built without splitting: the code it
// shares with `scripts.js` is inlined rather than moved into a chunk it would wait for.
const treeOpts: esbuild.BuildOptions = {
entryPoints: [ { in: path.join(rootDir, "src", "tree.ts"), out: "tree" } ],
bundle: true,
outdir: outDir,
format: "esm",
target: opts.target,
logLevel: "info",
minify: opts.minify
};
if (watch) {
const contexts = await Promise.all([ esbuild.context(opts), esbuild.context(treeOpts) ]);
await Promise.all(contexts.map((context) => context.watch()));
} else {
const [ result ] = await Promise.all([ esbuild.build(opts), esbuild.build(treeOpts) ]);
const after = performance.now();
writeFileSync("meta.json", JSON.stringify(result.metafile, null, 2));
console.log(`Build actually took ${(after - before).toFixed(2)}ms`);
}
}
const watch = process.argv.includes("--watch");
runBuild(watch).catch(console.error);
@@ -1,35 +1,25 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import setupMermaid, { loadMermaid } from "./mermaid.js";
import setupMermaid from "./mermaid.js";
describe("loadMermaid", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("imports the entry the manifest next to the script names", async () => {
const fetchMock = vi.fn(async (_url: URL) => new Response(JSON.stringify({
entry: `data:text/javascript,export default { name: "client mermaid" };`,
files: []
})));
vi.stubGlobal("fetch", fetchMock);
expect(await loadMermaid()).toEqual({ name: "client mermaid" });
expect(fetchMock.mock.calls[0][0].href)
.toBe(new URL("client/share_mermaid.json", import.meta.url).href);
});
it("fails when the manifest is missing", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 404 })));
await expect(loadMermaid()).rejects.toThrow("HTTP 404");
});
});
// Each test draws with a Mermaid of its own: a page's theme observer keeps the one it loaded.
const mermaidModule = vi.hoisted(() => ({
current: null as ReturnType<typeof createFakeMermaid> | null,
loads: 0
}));
vi.mock("mermaid", () => ({
get default() {
mermaidModule.loads++;
if (!mermaidModule.current) {
throw new Error("Failed to fetch the module");
}
return mermaidModule.current;
}
}));
describe("setupMermaid", () => {
afterEach(() => {
vi.unstubAllGlobals();
document.documentElement.removeAttribute("style");
document.documentElement.removeAttribute("class");
document.body.innerHTML = "";
@@ -108,10 +98,10 @@ describe("setupMermaid", () => {
});
it("keeps a Mermaid note's saved image when Mermaid cannot be loaded", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 404 })));
mermaidModule.current = null;
document.body.innerHTML = MERMAID_NOTE;
await expect(setupMermaid()).rejects.toThrow("HTTP 404");
await expect(setupMermaid()).rejects.toThrow("Failed to fetch the module");
expect(document.querySelector(".mermaid-note > img.mermaid-note-image")).not.toBeNull();
expect(document.querySelector(".mermaid")).toBeNull();
@@ -120,13 +110,11 @@ describe("setupMermaid", () => {
describe("setupMermaid without diagrams", () => {
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("loads nothing for other code blocks or a Mermaid note without its source", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const loads = mermaidModule.loads;
document.body.innerHTML = `<div id="content">`
+ `<pre><code class="language-javascript">graph TD;</code></pre>`
+ `<div class="mermaid-note"><img class="mermaid-note-image" src="diagram.svg"></div>`
@@ -134,7 +122,7 @@ describe("setupMermaid without diagrams", () => {
await setupMermaid();
expect(fetchMock).not.toHaveBeenCalled();
expect(mermaidModule.loads).toBe(loads);
expect(document.querySelectorAll("#content pre, #content img")).toHaveLength(2);
});
});
@@ -145,14 +133,15 @@ const MERMAID_NOTE =`<div id="content"><div class="mermaid-note">`
+ `<pre class="mermaid-note-source">graph TD; A--&gt;B</pre></details>`
+ `</div></div>`;
let stubCount = 0;
/**
* Serves a fake Mermaid, at a URL of its own: modules are cached by URL. It cannot draw a source
* containing `broken`.
*/
/** Gives the next page a Mermaid of its own. */
function stubMermaid() {
const fakeMermaid = {
mermaidModule.current = createFakeMermaid();
return mermaidModule.current;
}
/** A fake Mermaid, which cannot draw a source containing `broken`. */
function createFakeMermaid() {
return {
initialize: vi.fn(),
render: vi.fn(async (id: string, source: string) => {
if (source.includes("broken")) {
@@ -161,9 +150,4 @@ function stubMermaid() {
return { svg: `<svg id="${id}"></svg>` };
})
};
vi.stubGlobal("fakeMermaid", fakeMermaid);
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
entry: `data:text/javascript,export default globalThis.fakeMermaid; // ${++stubCount}`
}))));
return fakeMermaid;
}
+2 -22
View File
@@ -1,4 +1,5 @@
import { getMermaidConfig, type MermaidTheme, parseMermaidTheme } from "@triliumnext/commons/src/lib/mermaid_config.js";
import type { Mermaid } from "mermaid";
/**
* Draws the Mermaid diagrams on the page: code blocks in a text note, and Mermaid notes. A code
@@ -10,7 +11,7 @@ export default async function setupMermaid() {
return;
}
const mermaid = await loadMermaid();
const { default: mermaid } = await import("mermaid");
let theme = readMermaidTheme();
let rendering = renderDiagrams(mermaid, diagrams, theme);
@@ -63,11 +64,6 @@ function findDiagrams() {
});
}
interface Mermaid {
initialize(config: Record<string, unknown>): void;
render(id: string, source: string): Promise<{ svg: string }>;
}
let renderCount = 0;
/**
@@ -94,19 +90,3 @@ async function renderDiagrams(mermaid: Mermaid, diagrams: Diagram[], theme: Merm
function readMermaidTheme() {
return parseMermaidTheme(getComputedStyle(document.documentElement).getPropertyValue("--mermaid-theme"));
}
/**
* Imports the client's mermaid, which the server, the standalone build and the share-theme export
* each place at `client/` next to this script, described by `share_mermaid.json`.
*/
export async function loadMermaid(): Promise<Mermaid> {
const manifestUrl = new URL("client/share_mermaid.json", import.meta.url);
const response = await fetch(manifestUrl);
if (!response.ok) {
throw new Error(`Failed to load ${manifestUrl.href}: HTTP ${response.status}.`);
}
const { entry } = await response.json() as { entry: string };
const module = await import(new URL(entry, manifestUrl).href) as { default: Mermaid };
return module.default;
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* The script a shared page runs before it is first drawn, as the render-blocking first entry of
* `jsToLoad`: it restores the navigation tree, so the page never shows it at another position.
* It is built apart from `index.ts`, without code splitting, so it loads as one small file.
* The client build bundles it apart from `index.ts`, so it loads as one small file.
*/
import { whenActivated } from "./page/speculation.js";
import setupTreeState from "./page/tree_state.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [
{
// Stands in for the build script's `virtual:code-themes.css`, which `index.ts` imports.
// Stands in for the client build's `virtual:code-themes.css`, which `index.ts` imports.
name: "code-themes",
resolveId: (id) => (id === "virtual:code-themes.css" ? `\0${id}` : null),
load: (id) => (id === "\0virtual:code-themes.css" ? "" : null)
@@ -29,7 +29,7 @@ vi.mock("../../log.js", () => ({ getLog: () => mockLog }));
const {
default: ShareThemeExportProvider,
hasMermaidDiagrams,
mapMermaidExportFiles
getShareThemeExportFiles
} = await import("./share_theme.js");
// --- Test scaffolding -------------------------------------------------------
@@ -316,21 +316,6 @@ describe("ShareThemeExportProvider", () => {
});
describe("hasMermaidDiagrams", () => {
type FakeNote = { type: string; mime?: string; available?: boolean; content: string };
function subtreeOf(...notes: FakeNote[]) {
return {
getSubtree: () => ({
notes: notes.map(({ type, mime = "text/html", available = true, content }) => ({
type,
mime,
isContentAvailable: () => available,
getContent: () => content
}))
})
} as any;
}
it("finds a mermaid block only in a readable text note", () => {
const mermaid = `<pre><code class="language-mermaid">graph TD;</code></pre>`;
@@ -358,28 +343,37 @@ describe("hasMermaidDiagrams", () => {
});
});
describe("mapMermaidExportFiles", () => {
it("maps nothing from a development manifest, which lists no built files", () => {
expect(mapMermaidExportFiles({ entry: "/@fs/repo/apps/client/src/share_mermaid.ts", files: [] }))
.toBeUndefined();
});
describe("getShareThemeExportFiles", () => {
const manifest = {
files: [ "scripts.js", "scripts.css" ],
lazy: { mermaid: [ "mermaid.core-a.js" ] }
};
it("flattens the listed files into assets/client and rewrites the manifest to match", () => {
const entry = "../../../src/share_mermaid-abc.js";
const core = "../../../src/mermaid.core-def.js";
const mapped = mapMermaidExportFiles({ entry, files: [ entry, core ] });
if (!mapped) {
throw new Error("The manifest mapped to nothing.");
}
it("adds the files of mermaid only for a subtree with a diagram", () => {
const diagram = `<pre><code class="language-mermaid">graph TD;</code></pre>`;
expect(mapped.files).toEqual([
{ source: entry, target: "assets/client/share_mermaid-abc.js" },
{ source: core, target: "assets/client/mermaid.core-def.js" }
]);
expect(mapped.manifest.path).toBe("assets/client/share_mermaid.json");
expect(JSON.parse(mapped.manifest.content)).toEqual({
entry: "share_mermaid-abc.js",
files: [ "share_mermaid-abc.js", "mermaid.core-def.js" ]
});
expect(getShareThemeExportFiles(manifest, subtreeOf({ type: "text", content: diagram })))
.toEqual([ "scripts.js", "scripts.css", "mermaid.core-a.js" ]);
const withoutDiagram = subtreeOf({ type: "text", content: "<p>No diagram.</p>" });
expect(getShareThemeExportFiles(manifest, withoutDiagram))
.toEqual([ "scripts.js", "scripts.css" ]);
expect(getShareThemeExportFiles({ files: [ "scripts.js" ], lazy: {} },
subtreeOf({ type: "text", content: diagram }))).toEqual([ "scripts.js" ]);
});
});
type FakeNote = { type: string; mime?: string; available?: boolean; content: string };
/** A note whose subtree holds `notes`. */
function subtreeOf(...notes: FakeNote[]) {
return {
getSubtree: () => ({
notes: notes.map(({ type, mime = "text/html", available = true, content }) => ({
type,
mime,
isContentAvailable: () => available,
getContent: () => content
}))
})
} as any;
}
@@ -1,4 +1,4 @@
import type { ShareMermaidManifest } from "@triliumnext/commons";
import type { ShareThemeManifest } from "@triliumnext/commons";
import ejs from "ejs";
import { convert as convertToText } from "html-to-text";
import { t } from "i18next";
@@ -10,15 +10,13 @@ import type { ExportFormat, NoteMeta, NoteMetaFile } from "../../../meta.js";
import { readShareTemplate, renderNoteForExport } from "../../../share/index.js";
import * as iconPackService from "../../icon_packs.js";
import { getLog } from "../../log.js";
import { basename } from "../../utils/path.js";
import { ZipExportProvider, type ZipExportProviderData } from "./abstract_provider.js";
/** The static files a share-theme export copies into the archive, read by each platform its own way. */
export interface ShareThemeExportAssets {
/**
* The share theme's files, keyed by their path in the archive: `icon-color.svg`,
* `assets/<file>`, and the client's mermaid under `assets/client/` when
* {@link hasMermaidDiagrams} finds a diagram.
* The share theme's files, keyed by their path in the archive: `icon-color.svg`, and
* `assets/<file>` for each of {@link getShareThemeExportFiles}.
*/
files: Map<string, string | Uint8Array>;
/** Returns the font of a built-in icon pack, such as `boxicons.woff2`. */
@@ -184,13 +182,19 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
}
/** Where the exported pages find the client's mermaid: `client/` next to `assets/scripts.js`. */
const MERMAID_ARCHIVE_DIR = "assets/client";
/**
* Returns the files of `manifest` the export of `note` copies into `assets/`: those every page can
* load, and mermaid's only when {@link hasMermaidDiagrams} finds a diagram.
*/
export function getShareThemeExportFiles(manifest: ShareThemeManifest, note: BNote) {
const mermaidFiles = hasMermaidDiagrams(note) ? manifest.lazy.mermaid ?? [] : [];
return [ ...manifest.files, ...mermaidFiles ];
}
/**
* Whether `note` or a note below it has a mermaid code block the shared page renders: a text note's
* `language-mermaid` block or a Markdown note's fenced one. Only then does the export carry the
* client's mermaid, several megabytes the pages load on demand.
* files of mermaid, several megabytes the pages load on demand.
*/
export function hasMermaidDiagrams(note: BNote) {
return note.getSubtree().notes.some((subtreeNote) => {
@@ -208,28 +212,3 @@ export function hasMermaidDiagrams(note: BNote) {
}
const MARKDOWN_MERMAID_FENCE = /^ {0,3}(`{3,}|~{3,})\s*mermaid\b/m;
/**
* Maps the files `manifest` lists to their place in the archive, flattened into `assets/client/`,
* and returns the manifest the exported pages read there. Returns `undefined` when the manifest
* does not list its own entry: a development server's points at a source module and lists no files.
*/
export function mapMermaidExportFiles(manifest: ShareMermaidManifest) {
if (!manifest.files.includes(manifest.entry)) {
return undefined;
}
return {
manifest: {
path: `${MERMAID_ARCHIVE_DIR}/share_mermaid.json`,
content: JSON.stringify({
entry: basename(manifest.entry),
files: manifest.files.map(basename)
})
},
files: manifest.files.map((source) => ({
source,
target: `${MERMAID_ARCHIVE_DIR}/${basename(source)}`
}))
};
}
+3 -9
View File
@@ -1363,28 +1363,22 @@ importers:
katex:
specifier: 0.19.0
version: 0.19.0
mermaid:
specifier: 12.1.0
version: 12.1.0
devDependencies:
'@triliumnext/ckeditor5':
specifier: workspace:*
version: link:../ckeditor5
'@triliumnext/highlightjs':
specifier: workspace:*
version: link:../highlightjs
'@types/ejs':
specifier: 3.1.5
version: 3.1.5
ejs:
specifier: 7.0.1
version: 7.0.1
esbuild:
specifier: 0.28.2
version: 0.28.2
happy-dom:
specifier: 20.14.5
version: 20.14.5
sass:
specifier: 1.105.1
version: 1.105.1
typescript:
specifier: 6.0.3
version: 6.0.3
+3 -9
View File
@@ -169,16 +169,10 @@ export default class BuildHelper {
this.triggerBuildAndCopyTo("packages/pdfjs-viewer", "pdfjs-viewer");
}
/**
* Builds another workspace package and copies its `dist` into this one.
*
* `script` names the package script to run, for a package whose release build is not its
* plain `build` — `share-theme` keeps `build` unminified for development and minifies in
* `dist`.
*/
triggerBuildAndCopyTo(projectToBuild: string, destPath: string, script = "build") {
/** Builds another workspace package and copies its `dist` into this one. */
triggerBuildAndCopyTo(projectToBuild: string, destPath: string) {
const projectDir = join(this.rootDir, projectToBuild);
execSync(`pnpm ${script}`, { cwd: projectDir, stdio: "inherit" });
execSync("pnpm build", { cwd: projectDir, stdio: "inherit" });
cpSync(join(projectDir, "dist"), join(this.projectDir, "dist", destPath), { recursive: true });
}