mirror of
https://github.com/TriliumNext/Trilium.git
synced 2026-10-11 22:49:56 +00:00
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:
co-authored by
Claude Opus 5.5
parent
d9883a81e9
commit
fb8518b549
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}/**/*`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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-->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;
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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)}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+3
-9
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user