diff --git a/CHANGELOG.md b/CHANGELOG.md index 7242e51d6..20b0e5469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ to customize the displayed navigation tree, #2287. Note: This change renders `navigation.fullTree` obsolete. If you set it, TypeDoc will warn that it is being ignored. It will be removed in v0.26. +- The search index is now compressed before writing, which reduces most search index sizes by ~5-10x. - TypeDoc will now attempt to cache icons when `DefaultThemeRenderContext.icons` is overwritten by a custom theme. Note: To perform this optimization, TypeDoc relies on `DefaultThemeRenderContext.iconCache` being rendered within each page. TypeDoc does it in the `defaultLayout` template. diff --git a/src/lib/output/plugins/JavascriptIndexPlugin.ts b/src/lib/output/plugins/JavascriptIndexPlugin.ts index a72129682..027dc3ea2 100644 --- a/src/lib/output/plugins/JavascriptIndexPlugin.ts +++ b/src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -8,8 +8,12 @@ import { } from "../../models"; import { Component, RendererComponent } from "../components"; import { IndexEvent, RendererEvent } from "../events"; -import { Option, writeFileSync } from "../../utils"; +import { Option, writeFile } from "../../utils"; import { DefaultTheme } from "../themes/default/DefaultTheme"; +import { gzip } from "zlib"; +import { promisify } from "util"; + +const gzipP = promisify(gzip); /** * Keep this in sync with the interface in src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -52,6 +56,14 @@ export class JavascriptIndexPlugin extends RendererComponent { return; } + this.owner.preRenderAsyncJobs.push((event) => + this.buildSearchIndex(event), + ); + } + + private async buildSearchIndex(event: RendererEvent) { + const theme = this.owner.theme as DefaultTheme; + const rows: SearchDocument[] = []; const initialSearchResults = Object.values( @@ -105,7 +117,7 @@ export class JavascriptIndexPlugin extends RendererComponent { kind: reflection.kind, name: reflection.name, url: reflection.url, - classes: this.owner.theme.getReflectionClasses(reflection), + classes: theme.getReflectionClasses(reflection), }; if (parent) { @@ -136,10 +148,13 @@ export class JavascriptIndexPlugin extends RendererComponent { rows, index, }); + const data = await gzipP(Buffer.from(jsonData)); - writeFileSync( + await writeFile( jsonFileName, - `window.searchData = JSON.parse(${JSON.stringify(jsonData)});`, + `window.searchData = "data:application/octet-stream;base64,${data.toString( + "base64", + )}";`, ); } diff --git a/src/lib/output/plugins/NavigationPlugin.ts b/src/lib/output/plugins/NavigationPlugin.ts index bbcd8c5b0..595110da0 100644 --- a/src/lib/output/plugins/NavigationPlugin.ts +++ b/src/lib/output/plugins/NavigationPlugin.ts @@ -1,9 +1,12 @@ import * as Path from "path"; import { Component, RendererComponent } from "../components"; import { RendererEvent } from "../events"; -import { writeFileSync } from "../../utils"; +import { writeFile } from "../../utils"; import { DefaultTheme } from "../themes/default/DefaultTheme"; -import { gzipSync } from "zlib"; +import { gzip } from "zlib"; +import { promisify } from "util"; + +const gzipP = promisify(gzip); @Component({ name: "navigation-tree" }) export class NavigationPlugin extends RendererComponent { @@ -19,16 +22,24 @@ export class NavigationPlugin extends RendererComponent { return; } + this.owner.preRenderAsyncJobs.push((event) => + this.buildNavigationIndex(event), + ); + } + + private async buildNavigationIndex(event: RendererEvent) { const navigationJs = Path.join( event.outputDirectory, "assets", "navigation.js", ); - const nav = this.owner.theme.getNavigation(event.project); - const gz = gzipSync(Buffer.from(JSON.stringify(nav))); + const nav = (this.owner.theme as DefaultTheme).getNavigation( + event.project, + ); + const gz = await gzipP(Buffer.from(JSON.stringify(nav))); - writeFileSync( + await writeFile( navigationJs, `window.navigationData = "data:application/octet-stream;base64,${gz.toString( "base64", diff --git a/src/lib/output/renderer.ts b/src/lib/output/renderer.ts index c033150f2..c3d4a7a5b 100644 --- a/src/lib/output/renderer.ts +++ b/src/lib/output/renderer.ts @@ -235,12 +235,7 @@ export class Renderer extends ChildableComponent< const momento = this.hooks.saveMomento(); this.renderStartTime = Date.now(); - await loadHighlighter(this.lightTheme, this.darkTheme); - this.application.logger.verbose( - `Renderer: Loading highlighter took ${ - Date.now() - this.renderStartTime - }ms`, - ); + if ( !this.prepareTheme() || !(await this.prepareOutputDirectory(outputDirectory)) @@ -256,9 +251,7 @@ export class Renderer extends ChildableComponent< output.urls = this.theme!.getUrls(project); this.trigger(output); - - await Promise.all(this.preRenderAsyncJobs.map((job) => job(output))); - this.preRenderAsyncJobs = []; + await this.runPreRenderJobs(output); if (!output.isDefaultPrevented) { this.application.logger.verbose( @@ -281,6 +274,22 @@ export class Renderer extends ChildableComponent< this.hooks.restoreMomento(momento); } + private async runPreRenderJobs(output: RendererEvent) { + const start = Date.now(); + + this.preRenderAsyncJobs.push(this.loadHighlighter.bind(this)); + await Promise.all(this.preRenderAsyncJobs.map((job) => job(output))); + this.preRenderAsyncJobs = []; + + this.application.logger.verbose( + `Pre render async jobs took ${Date.now() - start}ms`, + ); + } + + private async loadHighlighter() { + await loadHighlighter(this.lightTheme, this.darkTheme); + } + /** * Render a single page. * diff --git a/src/lib/output/themes/default/assets/typedoc/components/Search.ts b/src/lib/output/themes/default/assets/typedoc/components/Search.ts index d7960eb75..7ea4910fe 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Search.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -22,7 +22,7 @@ interface IData { declare global { interface Window { - searchData?: IData; + searchData?: string; } } @@ -32,10 +32,30 @@ interface SearchState { index?: Index; } +async function updateIndex(state: SearchState, searchEl: HTMLElement) { + if (!window.searchData) return; + + const res = await fetch(window.searchData); + const json = new Blob([await res.arrayBuffer()]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + const data: IData = await new Response(json).json(); + + state.data = data; + state.index = Index.load(data.index); + + searchEl.classList.remove("loading"); + searchEl.classList.add("ready"); +} + export function initSearch() { const searchEl = document.getElementById("tsd-search"); if (!searchEl) return; + const state: SearchState = { + base: searchEl.dataset["base"] + "/", + }; + const searchScript = document.getElementById( "tsd-search-script", ) as HTMLScriptElement | null; @@ -46,12 +66,9 @@ export function initSearch() { searchEl.classList.add("failure"); }); searchScript.addEventListener("load", () => { - searchEl.classList.remove("loading"); - searchEl.classList.add("ready"); + updateIndex(state, searchEl); }); - if (window.searchData) { - searchEl.classList.remove("loading"); - } + updateIndex(state, searchEl); } const field = document.querySelector("#tsd-search input"); @@ -78,10 +95,6 @@ export function initSearch() { } }); - const state: SearchState = { - base: searchEl.dataset["base"] + "/", - }; - bindEvents(searchEl, results, field, state); } @@ -129,24 +142,12 @@ function bindEvents( }); } -function checkIndex(state: SearchState, searchEl: HTMLElement) { - if (state.index) return; - - if (window.searchData) { - searchEl.classList.remove("loading"); - searchEl.classList.add("ready"); - state.data = window.searchData; - state.index = Index.load(window.searchData.index); - } -} - function updateResults( searchEl: HTMLElement, results: HTMLElement, query: HTMLInputElement, state: SearchState, ) { - checkIndex(state, searchEl); // Don't clear results if loading state is not ready, // because loading or error message can be removed. if (!state.index || !state.data) return; @@ -189,6 +190,7 @@ function updateResults( for (let i = 0, c = Math.min(10, res.length); i < c; i++) { const row = state.data.rows[Number(res[i].ref)]; + const icon = ``; // Bold the matched part of the query in the search results let name = boldMatches(row.name, searchText); @@ -196,10 +198,8 @@ function updateResults( name += ` (score: ${res[i].score.toFixed(2)})`; } if (row.parent) { - name = `${boldMatches( - row.parent, - searchText, - )}.${name}`; + name = ` + ${boldMatches(row.parent, searchText)}.${name}`; } const item = document.createElement("li"); @@ -207,7 +207,7 @@ function updateResults( const anchor = document.createElement("a"); anchor.href = state.base + row.url; - anchor.innerHTML = name; + anchor.innerHTML = icon + name; item.append(anchor); results.appendChild(item); diff --git a/src/lib/utils/perf.ts b/src/lib/utils/perf.ts index b1e7407b8..e63dfcbca 100644 --- a/src/lib/utils/perf.ts +++ b/src/lib/utils/perf.ts @@ -57,21 +57,8 @@ export function Bench( }; } -const anon = { name: "measure()", calls: 0, time: 0 }; export function measure(cb: () => T): T { - if (anon.calls === 0) { - benchmarks.unshift(anon); - } - - anon.calls++; - const start = performance.now(); - let result: T; - try { - result = cb(); - } finally { - anon.time += performance.now() - start; - } - return result; + return bench(cb, "measure()")(); } process.on("exit", () => { diff --git a/static/style.css b/static/style.css index f29b14450..108428c3f 100644 --- a/static/style.css +++ b/static/style.css @@ -910,8 +910,9 @@ a.tsd-index-link { box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } #tsd-search .results li { - padding: 0 10px; background-color: var(--color-background); + line-height: initial; + padding: 4px; } #tsd-search .results li:nth-child(even) { background-color: var(--color-background-secondary); @@ -924,7 +925,10 @@ a.tsd-index-link { background-color: var(--color-accent); } #tsd-search .results a { - display: block; + display: flex; + align-items: center; + padding: 0.25rem; + box-sizing: border-box; } #tsd-search .results a:before { top: 10px;