diff --git a/.changeset/beige-suits-arrive.md b/.changeset/beige-suits-arrive.md new file mode 100644 index 00000000..ab5ba56b --- /dev/null +++ b/.changeset/beige-suits-arrive.md @@ -0,0 +1,8 @@ +--- +"hyperbook": minor +"@hyperbook/markdown": minor +"hyperbook-studio": minor +"@hyperbook/types": minor +--- + +Add option to enable search. Just set the search key to true in your hyperbook config and a search icon will be visible in the top right hand corner. diff --git a/packages/hyperbook/build.ts b/packages/hyperbook/build.ts index ba9696c1..ec28a92f 100644 --- a/packages/hyperbook/build.ts +++ b/packages/hyperbook/build.ts @@ -12,9 +12,10 @@ import { HyperbookContext, Navigation, } from "@hyperbook/types"; +import lunr from "lunr"; import { process as hyperbookProcess } from "@hyperbook/markdown"; -const ASSETS_FOLDER = "__hyperbook_assets"; +export const ASSETS_FOLDER = "__hyperbook_assets"; export async function runBuildProject( project: Hyperproject, @@ -129,6 +130,7 @@ async function runBuild( pagesAndSections.sections, pagesAndSections.pages, ); + const searchDocuments: any[] = []; let bookFiles = await vfile.listForFolder(root, "book"); if (filter) { @@ -147,6 +149,7 @@ async function runBuild( navigation, }; const result = await hyperbookProcess(file.markdown.content, ctx); + searchDocuments.push(...(result.data.searchDocuments || [])); for (let directive of Object.keys(result.data.directives || {})) { directives.add(directive); } @@ -204,6 +207,7 @@ async function runBuild( navigation, }; const result = await hyperbookProcess(file.markdown.content, ctx); + searchDocuments.push(...(result.data.searchDocuments || [])); for (let directive of Object.keys(result.data.directives || {})) { directives.add(directive); } @@ -292,9 +296,22 @@ async function runBuild( for (let asset of mainAssets) { const assetPath = path.join(assetsPath, asset); const assetOut = path.join(assetsOut, asset); - const stat = await fs.stat(assetPath); - if (stat.isFile()) { - await cp(assetPath, assetOut, { recursive: true }); + if (!asset.startsWith("directive-")) { + await cp(assetPath, assetOut, { + recursive: true, + filter: (src) => { + if (src.includes("lunr-languages")) { + return ( + hyperbookJson.language !== undefined && + hyperbookJson.language !== "en" && + (src.endsWith("lunr-languages") || + src.endsWith(`lunr.${hyperbookJson.language}.min.js`) || + src.endsWith(`lunr.stemmer.support.min.js`)) + ); + } + return true; + }, + }); } if (!process.env.CI) { readline.clearLine(process.stdout, 0); @@ -309,5 +326,52 @@ async function runBuild( } process.stdout.write("\n"); + if (hyperbookJson.search) { + const documents: Record = {}; + console.log(`${chalk.blue(`[${prefix}]`)} Building search index`); + + let foundLanguage = false; + if (hyperbookJson.language && hyperbookJson.language !== "en") { + try { + require("lunr-languages/lunr.stemmer.support.js")(lunr); + require(`lunr-languages/lunr.${hyperbookJson.language}.js`)(lunr); + foundLanguage = true; + } catch (e) { + console.log( + `${chalk.yellow(`[${prefix}]`)} ${hyperbookJson.language} is no valid value for the lanuage key. See https://github.com/MihaiValentin/lunr-languages for possible values. Falling back to English.`, + ); + } + } + const idx = lunr(function () { + if (foundLanguage) { + // @ts-ignore + this.use(lunr[hyperbookJson.language]); + } + this.ref("href"); + this.field("description"); + this.field("keywords"); + this.field("heading"); + this.field("content"); + this.metadataWhitelist = ["position"]; + + searchDocuments.forEach((doc) => { + const href = baseCtx.makeUrl(doc.href, "book"); + const docWithBase = { + ...doc, + href, + }; + this.add(docWithBase); + documents[href] = docWithBase; + }); + }); + + const js = ` +const LUNR_INDEX = ${JSON.stringify(idx)}; +const SEARCH_DOCUMENTS = ${JSON.stringify(documents)}; +`; + + await fs.writeFile(path.join(rootOut, ASSETS_FOLDER, "search.js"), js); + } + console.log(`${chalk.green(`[${prefix}]`)} Build success: ${rootOut}`); } diff --git a/packages/hyperbook/index.ts b/packages/hyperbook/index.ts index 0cb68ef8..cb94caa1 100644 --- a/packages/hyperbook/index.ts +++ b/packages/hyperbook/index.ts @@ -9,7 +9,6 @@ import { getPkgManager } from "./helpers/get-pkg-manager"; import { hyperproject } from "@hyperbook/fs"; import { runNew } from "./new"; import packageJson from "./package.json"; -import { deprecate } from "util"; const program = new Command(); diff --git a/packages/hyperbook/package.json b/packages/hyperbook/package.json index f6419615..cc6dc692 100644 --- a/packages/hyperbook/package.json +++ b/packages/hyperbook/package.json @@ -36,6 +36,7 @@ "@types/archiver": "6.0.2", "@types/async-retry": "1.4.8", "@types/cross-spawn": "6.0.6", + "@types/lunr": "^2.3.7", "@types/prompts": "2.4.9", "@types/tar": "6.1.13", "@types/ws": "^8.5.12", @@ -43,16 +44,20 @@ "archiver": "7.0.1", "async-retry": "1.3.3", "chalk": "5.2.0", + "chokidar": "3.6.0", "commander": "12.1.0", "cpy": "11.0.1", "cross-spawn": "7.0.3", + "domutils": "^3.1.0", "got": "12.6.0", + "htmlparser2": "^9.1.0", + "lunr": "^2.3.9", + "lunr-languages": "^1.14.0", + "mime": "^4.0.4", "prompts": "2.4.2", "rimraf": "5.0.7", "tar": "6.2.1", "update-check": "1.5.4", - "chokidar": "3.6.0", - "mime": "^4.0.4", "ws": "^8.18.0" }, "dependencies": { diff --git a/packages/markdown/assets/client.js b/packages/markdown/assets/client.js index 123395f5..99add069 100644 --- a/packages/markdown/assets/client.js +++ b/packages/markdown/assets/client.js @@ -33,13 +33,81 @@ var hyperbook = (function () { const tocDrawerEl = document.getElementById("toc-drawer"); tocDrawerEl.open = !tocDrawerEl.open; } + // search + + const searchInputEl = document.getElementById("search-input"); + searchInputEl.addEventListener("keypress", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + search(); + } + }); + + function searchToggle() { + const searchDrawerEl = document.getElementById("search-drawer"); + searchDrawerEl.open = !searchDrawerEl.open; + } + + function search() { + const resultsEl = document.getElementById("search-results"); + resultsEl.innerHTML = ""; + const query = searchInputEl.value; + const idx = window.lunr.Index.load(LUNR_INDEX); + const documents = SEARCH_DOCUMENTS; + const results = idx.search(query); + for (let result of results) { + const doc = documents[result.ref]; + + const container = document.createElement("a"); + container.href = doc.href; + container.classList.add("search-result"); + const heading = document.createElement("div"); + heading.textContent = doc.heading; + heading.classList.add("search-result-heading"); + const content = document.createElement("div"); + content.classList.add("search-result-content"); + const href = document.createElement("div"); + href.classList.add("search-result-href"); + href.textContent = doc.href; + + let contentHTML = ""; + const terms = Object.keys(result.matchData.metadata); + const term = terms[0]; + if (result?.matchData?.metadata?.[term]?.content?.position?.length > 0) { + const pos = result.matchData.metadata[term].content.position[0]; + const start = pos[0]; + const len = pos[1]; + let cutoffBefore = start - 50; + if (cutoffBefore < 0) { + cutoffBefore = 0; + } else { + contentHTML += "..."; + } + contentHTML += doc.content.slice(cutoffBefore, start); + + contentHTML += `${doc.content.slice(start, start + len)}`; + let cutoffAfter = start + len + 50; + + contentHTML += doc.content.slice(start + len, cutoffAfter); + if (cutoffAfter < doc.content.length) { + contentHTML += "..."; + } + } + + content.innerHTML = contentHTML; + + container.appendChild(heading); + container.appendChild(content); + container.appendChild(href); + resultsEl.appendChild(container); + } + } function qrcodeOpen() { const qrCodeDialog = document.getElementById("qrcode-dialog"); const qrcodeEls = qrCodeDialog.getElementsByClassName("make-qrcode"); const urlEls = qrCodeDialog.getElementsByClassName("url"); const qrcodeEl = qrcodeEls[0]; - const urlEl = urlEls[0]; const qrcode = new window.QRCode({ content: window.location.href, padding: 0, @@ -113,6 +181,8 @@ var hyperbook = (function () { toggleBookmark, navToggle, tocToggle, + searchToggle, + search, qrcodeOpen, qrcodeClose, }; diff --git a/packages/markdown/assets/content.css b/packages/markdown/assets/content.css index f09fd01e..e189abd7 100644 --- a/packages/markdown/assets/content.css +++ b/packages/markdown/assets/content.css @@ -377,6 +377,108 @@ figure { cursor: pointer; } +#search-toggle { + background-color: var(--color-text); + width: 24px; + height: 24px; + cursor: pointer; + margin-right: 16px; + mask-repeat: no-repeat; + mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImZlYXRoZXIgZmVhdGhlci1zZWFyY2giIGZpbGw9Im5vbmUiIGhlaWdodD0iMjQiIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMTEiIGN5PSIxMSIgcj0iOCIvPjxsaW5lIHgxPSIyMSIgeDI9IjE2LjY1IiB5MT0iMjEiIHkyPSIxNi42NSIvPjwvc3ZnPg==') +} + +#search-toggle:hover { + background-color: var(--color-brand); +} + +#search-drawer { + overflow-y: auto; + background: var(--color-nav); +} + +#search-drawer .search-input { + display: flex; + margin-top: 16px; + margin-bottom: 16px; + gap: 8px; + border: 1px solid var(--color-nav-border); + background-color: var(--color-nav); + padding: 8px; + border-radius: 4px; + align-items: center; + justify-content: center; +} + +#search-drawer .search-input .search-icon { + background-color: var(--color-text); + width: 24px; + height: 24px; + mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImZlYXRoZXIgZmVhdGhlci1zZWFyY2giIGZpbGw9Im5vbmUiIGhlaWdodD0iMjQiIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMTEiIGN5PSIxMSIgcj0iOCIvPjxsaW5lIHgxPSIyMSIgeDI9IjE2LjY1IiB5MT0iMjEiIHkyPSIxNi42NSIvPjwvc3ZnPg=='); + mask-repeat: no-repeat; +} + +#search-drawer .search-input input { + flex: 1; + background: none; + color: var(--color-text); + border: none; + width: 100%; +} + +#search-drawer .search-input .search-button { + background-color: var(--color-text); + width: 24px; + height: 24px; + cursor: pointer; + mask-repeat: no-repeat; + mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImZlYXRoZXIgZmVhdGhlci1hcnJvdy1yaWdodCIgZmlsbD0ibm9uZSIgaGVpZ2h0PSIyNCIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48bGluZSB4MT0iNSIgeDI9IjE5IiB5MT0iMTIiIHkyPSIxMiIvPjxwb2x5bGluZSBwb2ludHM9IjEyIDUgMTkgMTIgMTIgMTkiLz48L3N2Zz4=') +} + +#search-results { + display: flex; + flex-direction: column; + gap: 20px; +} + +#search-results .search-result { + display: flex; + gap: 4px; + flex-direction: column; + text-decoration: none; + color: var(--color-text); + font-size: 1rem; + border-radius: 8px; + background-color: var(--color-nav-border); + padding: 8px; +} + +#search-results .search-result-heading { + font-size: 1.2rem; + font-weight: 500; +} + +#search-results .search-result-content { + text-overflow: ellipsis; + font-size: 0.8rem; +} + +#search-results .search-result-href { + text-overflow: ellipsis; + overflow: hidden; + text-wrap: nowrap; + font-size: 0.8rem; + font-weight: 500; +} + +.search-drawer-content { + display: flex; + flex-direction: column; + padding: 8px; + height: calc(var(--app-height)); + overflow-y: auto; + gap: 8px; +} + #toc-drawer { overflow-y: auto; background: var(--color-nav); @@ -385,6 +487,7 @@ figure { .toc-drawer-content { display: flex; flex-direction: column; + height: calc(var(--app-height)); } .toc-drawer-content>nav { @@ -448,7 +551,6 @@ figure { display: flex; justify-content: center; align-items: center; - background: var(--color-background); border-radius: 8px; } @@ -466,11 +568,11 @@ figure { width: 100%; } -.hyperbook-markdown .close-icon { +#qrcode-dialog .close .close-icon { background-color: var(--color-text); width: 32px; height: 32px; - mask-image: url('data:image/svg+xml,'); + mask-image: url('data:image/svg+xml,'); } .hyperbook-markdown .qrcode-icon { @@ -500,7 +602,7 @@ figure { } .hyperbook-markdown #toc-toggle:hover, -.hyperbook-markdown #qrcode-toggle:hover { +.hyperbook-markdown #qrcode-open:hover { opacity: 1; } diff --git a/packages/markdown/assets/shell.css b/packages/markdown/assets/shell.css index 757e00ea..96151583 100644 --- a/packages/markdown/assets/shell.css +++ b/packages/markdown/assets/shell.css @@ -24,23 +24,13 @@ li { margin: 0; } -#nav-drawer { - background: var(--color-nav); - border-right-color: var(--color-nav-border); -} - side-drawer { position: absolute; - display: block; - visibility: hidden; - max-width: 0; - max-height: 0; } -side-drawer[open] { - visibility: visible; - max-width: 350px; - max-height: initial; +#nav-drawer { + background: var(--color-nav); + border-right-color: var(--color-nav-border); } .sidebar>.author, @@ -77,6 +67,7 @@ header.inverted { .branding { color: var(--color-brand-text); + flex: 1; } .branding:hover { diff --git a/packages/markdown/devBuild.mjs b/packages/markdown/devBuild.mjs index 122ed89e..f390abf7 100644 --- a/packages/markdown/devBuild.mjs +++ b/packages/markdown/devBuild.mjs @@ -6,10 +6,11 @@ const markdown = await fs.readFile("dev.md", "utf8"); const result = await process(markdown, { root: "", - qrcode: true, config: { name: "Hyperbook Dokumenation", description: "Dokumentation für Hyperbook erstellt mit Hyperbook", + qrcode: true, + search: true, author: { name: "OpenPatch", url: "https://openpatch.org", diff --git a/packages/markdown/package.json b/packages/markdown/package.json index 6a137479..a7bf7f13 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -42,6 +42,7 @@ "decircular": "^1.0.0", "handlebars": "^4.7.8", "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", "is-obj": "^3.0.0", "js-base64": "^3.7.7", "mdast-util-directive": "^3.0.0", @@ -65,6 +66,7 @@ "remark-unwrap-images": "4.0.0", "sort-keys": "^5.1.0", "unified": "^11.0.5", + "unist-util-find-after": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" }, @@ -80,6 +82,8 @@ "chalk": "^5.3.0", "chokidar": "3.6.0", "live-server": "^1.2.2", + "lunr": "^2.3.9", + "lunr-languages": "^1.14.0", "mermaid": "11.3.0", "ncp": "^2.0.0", "scratchblocks": "^3.6.4", diff --git a/packages/markdown/postbuild.mjs b/packages/markdown/postbuild.mjs index af3fa5dc..80ffb738 100644 --- a/packages/markdown/postbuild.mjs +++ b/packages/markdown/postbuild.mjs @@ -79,6 +79,14 @@ async function postbuild() { "hyperbook-excalidraw.umd.js", ), }, + { + src: path.join("./node_modules", "lunr", "lunr.min.js"), + dst: path.join("./dist", "assets", "lunr.min.js"), + }, + { + src: path.join("./node_modules", "lunr-languages", "min"), + dst: path.join("./dist", "assets", "lunr-languages"), + }, ]; for (let asset of assets) { diff --git a/packages/markdown/src/index.ts b/packages/markdown/src/index.ts index d6ce5cc2..535ffd4b 100644 --- a/packages/markdown/src/index.ts +++ b/packages/markdown/src/index.ts @@ -1,4 +1,4 @@ -import { process } from "./process"; +import { process, remark } from "./process"; declare module "vfile" { interface DataMap { @@ -14,6 +14,13 @@ declare module "vfile" { anchor: string; label: string; }[]; + searchDocuments: { + description: string; + keywords: string[]; + heading: string; + content: string; + href: string; + }[]; } } @@ -23,4 +30,4 @@ declare module "mdast" { } } -export { process }; +export { process, remark }; diff --git a/packages/markdown/src/mdastUtilToText.ts b/packages/markdown/src/mdastUtilToText.ts new file mode 100644 index 00000000..16d912fa --- /dev/null +++ b/packages/markdown/src/mdastUtilToText.ts @@ -0,0 +1,61 @@ +import { Nodes } from "mdast"; + +const emptyOptions: any = {}; + +export function toText(value: any, options?: any): string { + const settings = options || emptyOptions; + const includeImageAlt = + typeof settings.includeImageAlt === "boolean" + ? settings.includeImageAlt + : true; + const includeHtml = + typeof settings.includeHtml === "boolean" ? settings.includeHtml : true; + + return one(value, includeImageAlt, includeHtml); +} + +function one( + value: any, + includeImageAlt: boolean, + includeHtml: boolean, +): string { + if (node(value)) { + if ("value" in value) { + return value.type === "html" && !includeHtml ? "" : value.value; + } + + if (includeImageAlt && "alt" in value && value.alt) { + return value.alt; + } + + if ("children" in value) { + return all(value.children, includeImageAlt, includeHtml); + } + } + + if (Array.isArray(value)) { + return all(value, includeImageAlt, includeHtml); + } + + return ""; +} + +function all( + values: Array, + includeImageAlt: boolean, + includeHtml: boolean, +): string { + /** @type {Array} */ + const result: string[] = []; + let index = -1; + + while (++index < values.length) { + result[index] = one(values[index], includeImageAlt, includeHtml); + } + + return result.join(" "); +} + +function node(value: any): value is Nodes { + return Boolean(value && typeof value === "object"); +} diff --git a/packages/markdown/src/process.ts b/packages/markdown/src/process.ts index 5f367678..81573006 100644 --- a/packages/markdown/src/process.ts +++ b/packages/markdown/src/process.ts @@ -46,6 +46,7 @@ import remarkDirectiveTerm from "./remarkDirectiveTerm"; import remarkLink from "./remarkLink"; import remarkDirectivePagelist from "./remarkDirectivePagelist"; import rehypeQrCode from "./rehypeQrCode"; +import remarkCollectSearchDocuments from "./remarkCollectSearchDocuments"; export const remark = (ctx: HyperbookContext) => { const remarkPlugins: PluggableList = [ @@ -78,13 +79,14 @@ export const remark = (ctx: HyperbookContext) => { remarkDirectiveMermaid(ctx), remarkDirectiveExcalidraw(ctx), remarkDirectiveStruktog(ctx), - remarkCollectHeadings(ctx), remarkCode(ctx), remarkMath, remarkGemoji, remarkUnwrapImages, /* needs to be last directive */ remarkDirectiveProtect(ctx), + remarkCollectHeadings(ctx), + remarkCollectSearchDocuments(ctx), ]; const rehypePlugins: PluggableList = [ diff --git a/packages/markdown/src/rehypeHtmlStructure.ts b/packages/markdown/src/rehypeHtmlStructure.ts index 3e5839b2..ce50b398 100644 --- a/packages/markdown/src/rehypeHtmlStructure.ts +++ b/packages/markdown/src/rehypeHtmlStructure.ts @@ -14,6 +14,59 @@ function parseFont(font: string): [string, string] { return [parts[0], "100%"]; } +const makeSearchScripts = (ctx: HyperbookContext): ElementContent[] => { + const elements: ElementContent[] = []; + if (ctx.config.search) { + elements.push({ + type: "element", + tagName: "script", + properties: { + src: ctx.makeUrl(["lunr.min.js"], "assets"), + defer: true, + }, + children: [], + }); + + if (ctx.config.language && ctx.config.language !== "en") { + elements.push({ + type: "element", + tagName: "script", + properties: { + src: ctx.makeUrl( + ["lunr-languages", "lunr.stemmer.support.min.js"], + "assets", + ), + defer: true, + }, + children: [], + }); + elements.push({ + type: "element", + tagName: "script", + properties: { + src: ctx.makeUrl( + ["lunr-languages", `lunr.${ctx.config.language}.min.js`], + "assets", + ), + defer: true, + }, + children: [], + }); + } + elements.push({ + type: "element", + tagName: "script", + properties: { + src: ctx.makeUrl(["search.js"], "assets"), + defer: true, + }, + children: [], + }); + } + + return elements; +}; + const makeRootCssElement = ({ makeUrl, config: { colors, font, fonts }, @@ -222,7 +275,7 @@ export default (ctx: HyperbookContext) => () => { type: "element", tagName: "meta", properties: { - property: "description", + name: "description", content: `${currentPage?.description || config.description}`, }, children: [], @@ -240,7 +293,7 @@ export default (ctx: HyperbookContext) => () => { type: "element", tagName: "meta", properties: { - property: "keywords", + name: "keywords", content: currentPage?.keywords ? `${currentPage?.keywords.join("")}` : undefined, @@ -257,6 +310,15 @@ export default (ctx: HyperbookContext) => () => { children: [], }, makeRootCssElement(ctx), + { + type: "element", + tagName: "link", + properties: { + rel: "stylesheet", + href: makeUrl(["math", "katex.min.css"], "assets"), + }, + children: [], + }, { type: "element", tagName: "link", @@ -335,6 +397,7 @@ HYPERBOOK_ASSETS = "${makeUrl("/", "assets")}" }, children: [], }, + ...makeSearchScripts(ctx), { type: "element", tagName: "script", @@ -376,6 +439,7 @@ HYPERBOOK_ASSETS = "${makeUrl("/", "assets")}" tagName: "script", properties: { src: makeUrl(["client.js"], "assets"), + defer: true, }, children: [], }, @@ -393,6 +457,7 @@ HYPERBOOK_ASSETS = "${makeUrl("/", "assets")}" ["directive-" + directive, script], "assets", ), + defer: true, }, children: [], }) as ElementContent, @@ -407,6 +472,7 @@ HYPERBOOK_ASSETS = "${makeUrl("/", "assets")}" src: script.includes("://") ? script : makeUrl(script, "public"), + defer: true, }, children: [], }) as ElementContent, @@ -420,6 +486,7 @@ HYPERBOOK_ASSETS = "${makeUrl("/", "assets")}" src: script.includes("://") ? script : makeUrl(script, "public"), + defer: true, }, children: [], }) as ElementContent, diff --git a/packages/markdown/src/rehypeShell.ts b/packages/markdown/src/rehypeShell.ts index 1a7bca6c..93819881 100644 --- a/packages/markdown/src/rehypeShell.ts +++ b/packages/markdown/src/rehypeShell.ts @@ -585,6 +585,81 @@ const makeHeaderElements = (ctx: HyperbookContext): ElementContent[] => { }); } + if (ctx.config.search) { + elements.push({ + type: "element", + tagName: "button", + properties: { + id: "search-toggle", + onclick: "hyperbook.searchToggle()", + title: "Search", + }, + children: [], + }); + elements.push({ + type: "element", + tagName: "side-drawer", + properties: { + id: "search-drawer", + right: true, + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "search-drawer-content", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "search-input", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "search-icon", + }, + children: [], + }, + { + type: "element", + tagName: "input", + properties: { + id: "search-input", + placerholder: "...", + }, + children: [], + }, + { + type: "element", + tagName: "button", + properties: { + class: "search-button", + onclick: "hyperbook.search()", + }, + children: [], + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + id: "search-results", + }, + children: [], + }, + ], + }, + ], + }); + } + return [ { type: "element", diff --git a/packages/markdown/src/remarkCollectHeadings.ts b/packages/markdown/src/remarkCollectHeadings.ts index 3f8cd830..ffc2a372 100644 --- a/packages/markdown/src/remarkCollectHeadings.ts +++ b/packages/markdown/src/remarkCollectHeadings.ts @@ -2,9 +2,9 @@ /// // import { HyperbookContext } from "@hyperbook/types"; -import { Root } from "mdast"; +import { Node, Root } from "mdast"; import { visit } from "unist-util-visit"; -import { VFile } from "vfile"; +import { VFile, VFileData } from "vfile"; import { Root as MdastRoot, Heading as AstHeading } from "mdast"; import { toString } from "mdast-util-to-string"; @@ -15,7 +15,7 @@ export default (ctx: HyperbookContext) => () => { }; }; -const getAnchor = (heading: AstHeading): string => { +export const getAnchor = (heading: AstHeading): string => { // If we have a heading, make it lower case if ((heading?.data as any)?.id) { return (heading.data as any).id as string; @@ -32,12 +32,8 @@ const getAnchor = (heading: AstHeading): string => { return anchor; }; -const getHeadings = (root: MdastRoot) => { - const headingList: { - level: 1 | 2 | 3 | 4 | 5 | 6; - label: string; - anchor: string; - }[] = []; +const getHeadings = (root: MdastRoot): VFileData["headings"] => { + const headingList: VFileData["headings"] = []; visit(root, "heading", (node: AstHeading) => { const heading = { diff --git a/packages/markdown/src/remarkCollectSearchDocuments.ts b/packages/markdown/src/remarkCollectSearchDocuments.ts new file mode 100644 index 00000000..465266c0 --- /dev/null +++ b/packages/markdown/src/remarkCollectSearchDocuments.ts @@ -0,0 +1,48 @@ +// Register directive nodes in mdast: +/// +// +import { HyperbookContext } from "@hyperbook/types"; +import { Root } from "mdast"; +import { visit } from "unist-util-visit"; +import { findAfter } from "unist-util-find-after"; +import { Node } from "unified/lib"; +import { VFile, VFileData } from "vfile"; +import { getAnchor } from "./remarkCollectHeadings"; +import { toText } from "./mdastUtilToText"; + +export default (ctx: HyperbookContext) => () => { + return (tree: Root, file: VFile) => { + const searchDocuments: VFileData["searchDocuments"] = []; + + visit(tree, function (node, index, parent) { + if (node.type === "heading") { + const start = node; + const startIndex = index; + const depth = start.depth; + + const isEnd = (node: Node) => + (node.type === "heading" && node.depth <= depth) || + node.type === "export"; + const end = findAfter(parent, start, isEnd); + const endIndex = parent.children.indexOf(end); + + const between = parent.children.slice( + startIndex, + endIndex > 0 ? endIndex : undefined, + ); + + const anchor = getAnchor(node); + const content = toText(between); + searchDocuments.push({ + href: `${ctx.navigation.current?.href || ""}#${anchor}`, + heading: toText(node) || ctx.navigation.current?.name || "", + content, + keywords: ctx.navigation.current?.keywords || [], + description: ctx.navigation.current?.description || "", + }); + } + }); + + file.data.searchDocuments = searchDocuments; + }; +}; diff --git a/packages/markdown/tests/__snapshots__/process.test.ts.snap b/packages/markdown/tests/__snapshots__/process.test.ts.snap index b78c3ca2..031faada 100644 --- a/packages/markdown/tests/__snapshots__/process.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/process.test.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`process > should add showLineNumbers 1`] = ` -"Markdown Referenz - Hyperbook Dokumenation -
✎ GitHub© Copyright 2024 by OpenPatch
" +
✎ GitHub© Copyright 2024 by OpenPatch
" `; exports[`process > should result in two link 1`] = ` -"Hi - My Hyperbook
My Hyperbook
@@ -195,11 +195,11 @@ HYPERBOOK_ASSETS = "/assets/" in Python Teil des Programms .

-
© Copyright 2024
" +
© Copyright 2024
" `; exports[`process > should transform 1`] = ` -"Hi - My Hyperbook
My Hyperbook
@@ -309,11 +309,11 @@ HYPERBOOK_ASSETS = "/assets/"
-
© Copyright 2024
" +
© Copyright 2024
" `; exports[`process > should transfrom complex context 1`] = ` -"Markdown Referenz - Hyperbook Dokumenation -
✎ GitHub© Copyright 2024 by OpenPatch
" +
✎ GitHub© Copyright 2024 by OpenPatch
" `; diff --git a/packages/markdown/tests/remarkCollectSearchDocuments.test.ts b/packages/markdown/tests/remarkCollectSearchDocuments.test.ts new file mode 100644 index 00000000..6e08c486 --- /dev/null +++ b/packages/markdown/tests/remarkCollectSearchDocuments.test.ts @@ -0,0 +1,78 @@ +import { HyperbookContext } from "@hyperbook/types/dist"; +import { describe, expect, it } from "vitest"; +import remarkParse from "remark-parse"; +import remarkToRehype from "remark-rehype"; +import { unified } from "unified"; +import { realCtx } from "./mock"; +import remarkCollectSearchDocuments from "../src/remarkCollectSearchDocuments"; +import rehypeStringify from "rehype-stringify"; + +export const toData = (md: string, ctx: HyperbookContext) => { + return unified() + .use(remarkParse) + .use(remarkCollectSearchDocuments(ctx)) + .use(remarkToRehype) + .use(rehypeStringify, { + allowDangerousCharacters: true, + allowDangerousHtml: true, + }) + .processSync(md); +}; + +describe("remarkCollectSearchDocuments", () => { + it("should transform", async () => { + expect( + toData( + ` +# Heading + +heading + +## Sub-Heading 1 + +sub-heading 1 + +## Sub-Heading 2 + +sub-heading 2 + +### Subsub-heading 1 + +subsub-heading 1 +`, + realCtx, + ).data.searchDocuments, + ).toMatchInlineSnapshot(` + [ + { + "content": "Heading heading Sub-Heading 1 sub-heading 1 Sub-Heading 2 sub-heading 2 Subsub-heading 1 subsub-heading 1", + "description": "", + "heading": "Heading", + "href": "/markdown#heading", + "keywords": [], + }, + { + "content": "Sub-Heading 1 sub-heading 1", + "description": "", + "heading": "Sub-Heading 1", + "href": "/markdown#subheading-1", + "keywords": [], + }, + { + "content": "Sub-Heading 2 sub-heading 2 Subsub-heading 1 subsub-heading 1", + "description": "", + "heading": "Sub-Heading 2", + "href": "/markdown#subheading-2", + "keywords": [], + }, + { + "content": "Subsub-heading 1 subsub-heading 1", + "description": "", + "heading": "Subsub-heading 1", + "href": "/markdown#subsubheading-1", + "keywords": [], + }, + ] + `); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a3332825..b9f1085c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -78,6 +78,7 @@ export type HyperbookJson = { description?: string; logo?: string; allowDangerousHtml?: boolean; + search?: boolean; qrcode?: boolean; author?: { name?: string; diff --git a/platforms/vscode/schemas/hyperbook.schema.json b/platforms/vscode/schemas/hyperbook.schema.json index 4394761c..59ecedbc 100644 --- a/platforms/vscode/schemas/hyperbook.schema.json +++ b/platforms/vscode/schemas/hyperbook.schema.json @@ -311,6 +311,9 @@ }, "type": "array" }, + "search": { + "type": "boolean" + }, "styles": { "items": { "type": "string" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6c28914..4868867b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: '@types/cross-spawn': specifier: 6.0.6 version: 6.0.6 + '@types/lunr': + specifier: ^2.3.7 + version: 2.3.7 '@types/prompts': specifier: 2.4.9 version: 2.4.9 @@ -175,9 +178,21 @@ importers: cross-spawn: specifier: 7.0.3 version: 7.0.3 + domutils: + specifier: ^3.1.0 + version: 3.1.0 got: specifier: 12.6.0 version: 12.6.0 + htmlparser2: + specifier: ^9.1.0 + version: 9.1.0 + lunr: + specifier: ^2.3.9 + version: 2.3.9 + lunr-languages: + specifier: ^1.14.0 + version: 1.14.0 mime: specifier: ^4.0.4 version: 4.0.4 @@ -211,6 +226,9 @@ importers: hast-util-from-html: specifier: ^2.0.3 version: 2.0.3 + hast-util-to-text: + specifier: ^4.0.2 + version: 4.0.2 is-obj: specifier: ^3.0.0 version: 3.0.0 @@ -280,6 +298,9 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + unist-util-find-after: + specifier: ^5.0.0 + version: 5.0.0 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -320,6 +341,12 @@ importers: live-server: specifier: ^1.2.2 version: 1.2.2 + lunr: + specifier: ^2.3.9 + version: 2.3.9 + lunr-languages: + specifier: ^1.14.0 + version: 1.14.0 mermaid: specifier: 11.3.0 version: 11.3.0 @@ -1430,6 +1457,9 @@ packages: '@types/liftoff@4.0.0': resolution: {integrity: sha512-Ny/PJkO6nxWAQnaet8q/oWz15lrfwvdvBpuY4treB0CSsBO1CG0fVuNLngR3m3bepQLd+E4c3Y3DlC2okpUvPw==} + '@types/lunr@2.3.7': + resolution: {integrity: sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2818,8 +2848,8 @@ packages: dompurify@3.1.6: resolution: {integrity: sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==} - domutils@3.0.1: - resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2885,6 +2915,10 @@ packages: resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==} engines: {node: '>=0.12'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + envinfo@7.13.0: resolution: {integrity: sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==} engines: {node: '>=4'} @@ -3571,6 +3605,9 @@ packages: htmlparser2@8.0.1: resolution: {integrity: sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-auth@3.1.3: resolution: {integrity: sha512-Jbx0+ejo2IOx+cRUYAGS1z6RGc6JfYUNkysZM4u4Sfk1uLlGv814F7/PIjQQAuThLdAWxb74JMGd5J8zex1VQg==} engines: {node: '>=4.6.1'} @@ -4259,6 +4296,12 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lunr-languages@1.14.0: + resolution: {integrity: sha512-hWUAb2KqM3L7J5bcrngszzISY4BxrXn/Xhbb9TTCJYEGqlR1nG67/M14sp09+PTIRklobrn57IAxcdcO/ZFyNA==} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.10: resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} @@ -7713,6 +7756,8 @@ snapshots: '@types/fined': 1.1.3 '@types/node': 20.12.12 + '@types/lunr@2.3.7': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.2 @@ -8636,14 +8681,14 @@ snapshots: css-what: 6.1.0 domelementtype: 2.3.0 domhandler: 5.0.3 - domutils: 3.0.1 + domutils: 3.1.0 cheerio@1.0.0-rc.12: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 - domutils: 3.0.1 + domutils: 3.1.0 htmlparser2: 8.0.1 parse5: 7.1.2 parse5-htmlparser2-tree-adapter: 7.0.0 @@ -8964,7 +9009,7 @@ snapshots: boolbase: 1.0.0 css-what: 6.1.0 domhandler: 5.0.3 - domutils: 3.0.1 + domutils: 3.1.0 nth-check: 2.1.1 css-what@6.1.0: {} @@ -9328,7 +9373,7 @@ snapshots: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 - entities: 4.4.0 + entities: 4.5.0 domain-browser@4.23.0: {} @@ -9340,7 +9385,7 @@ snapshots: dompurify@3.1.6: {} - domutils@3.0.1: + domutils@3.1.0: dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 @@ -9406,6 +9451,8 @@ snapshots: entities@4.4.0: {} + entities@4.5.0: {} + envinfo@7.13.0: {} error-ex@1.3.2: @@ -10322,7 +10369,7 @@ snapshots: hast-util-to-html@9.0.3: dependencies: '@types/hast': 3.0.4 - '@types/unist': 3.0.2 + '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 hast-util-whitespace: 3.0.0 @@ -10340,7 +10387,7 @@ snapshots: hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 - '@types/unist': 3.0.2 + '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 @@ -10393,8 +10440,15 @@ snapshots: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 - domutils: 3.0.1 - entities: 4.4.0 + domutils: 3.1.0 + entities: 4.5.0 + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 http-auth@3.1.3: dependencies: @@ -11070,6 +11124,10 @@ snapshots: dependencies: yallist: 4.0.0 + lunr-languages@1.14.0: {} + + lunr@2.3.9: {} + magic-string@0.30.10: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -13579,7 +13637,7 @@ snapshots: unist-util-is@6.0.0: dependencies: - '@types/unist': 3.0.2 + '@types/unist': 3.0.3 unist-util-map@3.1.3: dependencies: @@ -13587,7 +13645,7 @@ snapshots: unist-util-position@5.0.0: dependencies: - '@types/unist': 3.0.2 + '@types/unist': 3.0.3 unist-util-remove-position@5.0.0: dependencies: diff --git a/website/de/book/configuration/book.md b/website/de/book/configuration/book.md index a9cad128..ec3bb6d8 100644 --- a/website/de/book/configuration/book.md +++ b/website/de/book/configuration/book.md @@ -14,6 +14,7 @@ von Optionen, die du definieren kannst. Optionen mit einem "\*" müssen gesetzt | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name\* | Name des Hyperbooks. Wird für den Seitentitel verwendet. | | description | Beschreibung des Hyperbooks. Wird für SEO verwendet. | +| search | Erlaubt es im Hyperbook zu suchen. | | logo | URL eines Logos. Wird für den Seitentitel verwendet. Diese kann relative zum public-Ordner (z.B.: /mein-logo.png) sein oder ein externes Bild referenzieren. | | author.name | Name des Autors des Hyperbooks. Wird in der Fußzeile verwendet. | | author.url | URL zur Homepage des Autors. Wird in der Fußzeile verwendet. | @@ -43,6 +44,8 @@ Hier ist eine Beispielkonfiguration: { "name": "Hyperbook Documentation", "description": "Documentation for Hyperbook created with Hyperbook", + "search": true, + "qrcode": false, "author": { "name": "OpenPatch", "url": "https://openpatch.org" diff --git a/website/de/book/elements/excalidraw.md b/website/de/book/elements/excalidraw.md index 28c9558e..b267d82d 100644 --- a/website/de/book/elements/excalidraw.md +++ b/website/de/book/elements/excalidraw.md @@ -1,6 +1,8 @@ --- name: Excalidraw lang: de +keywords: + - excalidraw --- # Excalidraw diff --git a/website/de/book/elements/math.md b/website/de/book/elements/math.md index f18e27a3..66c98b2f 100644 --- a/website/de/book/elements/math.md +++ b/website/de/book/elements/math.md @@ -1,9 +1,9 @@ --- -name: Mathmatik +name: Mathematik lang: de --- -# Mathemathik +# Mathematik Manchmal wünschst du dir $ \LaTeX $, aber du schreibst ja nur Markdown-Dateien. Kein Problem, Hyperbook unterstützt KaTeX. diff --git a/website/de/hyperbook.json b/website/de/hyperbook.json index ea3da28b..4567cd3b 100644 --- a/website/de/hyperbook.json +++ b/website/de/hyperbook.json @@ -1,6 +1,7 @@ { "name": "Hyperbook Dokumenation", "qrcode": true, + "search": true, "description": "Dokumentation für Hyperbook erstellt mit Hyperbook", "author": { "name": "OpenPatch", diff --git a/website/en/book/configuration/book.md b/website/en/book/configuration/book.md index 1527d1ba..e9e8727f 100644 --- a/website/en/book/configuration/book.md +++ b/website/en/book/configuration/book.md @@ -13,6 +13,7 @@ can and part wise must set (indicated by a \*). | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name\* | Name of your Hyperbook. Used for the page header. | | description | Description of your Hyperbook. Used for SEO. | +| search | Allows searching your hyperbook | | logo | URL to a logo. Used for the page title. Can be relative to the public folder or an absolute URL | | author.name | Author name of your Hyperbook. Used in the footer. | | author.url | Used to link the author name in the footer. | @@ -42,6 +43,8 @@ Here is an example configuration: { "name": "Hyperbook Documentation", "description": "Documentation for Hyperbook created with Hyperbook", + "search": true, + "qrcode": false, "author": { "name": "OpenPatch", "url": "https://openpatch.org" diff --git a/website/en/hyperbook.json b/website/en/hyperbook.json index 56d6738f..4dc9a186 100644 --- a/website/en/hyperbook.json +++ b/website/en/hyperbook.json @@ -1,6 +1,7 @@ { "name": "Hyperbook Documentation", "qrcode": true, + "search": true, "description": "Documentation for Hyperbook created with Hyperbook", "author": { "name": "OpenPatch",