diff --git a/build/scripts/script.js b/build/scripts/script.js index ed41d49..bbf22a5 100644 --- a/build/scripts/script.js +++ b/build/scripts/script.js @@ -439,6 +439,7 @@ function initializeSearchEngine() { anchorH3: elt.anchorH3, }); pageIndex.push({ + file: res[i].file, h1: elt.h1, h2: elt.h2, h3: elt.h3, @@ -478,6 +479,11 @@ function initializeSearchEngine() { return searchState.promise; } +// Symbol are guaranteed to be unique. +// So there will be no overlap between this symbol or any string +// when it's used as a key to access an object property. +const __DEFAULT_SYMBOL__ = Symbol("__DEFAULT__"); + /** * Update search result in the search result HTMLElement according to the given * value. @@ -519,84 +525,154 @@ function updateSearchResults(value) { '
' + "No result for that search." + "
"; return; } + + // only consider the first 30 results + const searchResultsSliced = searchResults.slice(0, 29); + const searchResultSorted = reorderSearchResults(searchResultsSliced); searchResultsElt.innerHTML = ""; - for (let resIdx = 0; resIdx < searchResults.length && resIdx < 30; resIdx++) { - const res = searchResults[resIdx]; - const links = searchIndexLinks[+res.refIndex]; - const contentDiv = document.createElement("div"); - contentDiv.className = "search-result-item"; - - const locationDiv = document.createElement("div"); - locationDiv.className = "search-result-location"; - - let needSeparator = false; - if (res.item.h1 !== undefined && res.item.h1 !== "") { - let linkH1; - if (links.anchorH1 !== undefined) { - const href = rootUrl + "/" + links.file + "#" + links.anchorH1; - linkH1 = document.createElement("a"); - linkH1.href = href; - } else { - linkH1 = document.createElement("span"); - } - linkH1.className = "h1"; - linkH1.textContent = res.item.h1; - locationDiv.appendChild(linkH1); - needSeparator = true; + let previousItem = null; + for (const searchResult of searchResultSorted) { + let displayFromLevel = + previousItem === null + ? 0 + : getCommonAncestorLevel(searchResult, previousItem); + previousItem = searchResult; + const elems = createHeadingElements(searchResult, displayFromLevel); + elems.forEach((elem) => searchResultsElt.appendChild(elem)); + } +} + +/** + * Compares two search result items to determine the deepest common ancestor level. + * + * @param {Object} itemA - The first search result item. + * @param {Object} itemB - The second search result item. + * @returns {number} - Returns 0 if items does not share same file, 1 if h1 is different, etc... + */ +function getCommonAncestorLevel(searchResultItemA, searchResultItemB) { + if (searchResultItemA.file !== searchResultItemB.file) { + return 0; + } + + if (searchResultItemA.h1 !== searchResultItemB.h1) { + return 1; + } + + if (searchResultItemA.h2 !== searchResultItemB.h2) { + return 2; + } + + return 3; +} + +/** + * Re-orders search results by grouping them according + * to their shared section headings (h1, h2, h3). + * @param {array} searchResults The array of search results to be re-ordered. + * @returns An array re-ordered based on section headings. + * + * @example + */ +function reorderSearchResults(searchResults) { + const groupedSearchResult = groupItems(searchResults); + return flattenGroupedItems(groupedSearchResult); +} +/** + * Groups items from the provided `results` array by their file, h1, h2, and h3 properties. + * If a property is missing at any level, it defaults to using the `__DEFAULT__` symbol. + * Items are nested based on their structure, creating keys for each + * unique combination of `file`, `h1`, `h2`, and `h3`. + * + * @param {array} array The array to group. + * @returns An object containing items grouped. + */ +function groupItems(results) { + const groupedByFileAndHeader = {}; + + results.forEach((res) => { + let item = res.item; + + if (item.file === undefined || item.h1 === undefined) { + // item.file and item.h1 is required, if it's missing let's skip the search result. + return; + } + if (!groupedByFileAndHeader[item.file]) { + groupedByFileAndHeader[item.file] = {}; + } + + if (!groupedByFileAndHeader[item.file][item.h1]) { + groupedByFileAndHeader[item.file][item.h1] = {}; } - if (res.item.h2 !== undefined && res.item.h2 !== "") { - if (needSeparator) { - const separatorSpan = document.createElement("span"); - separatorSpan.textContent = " > "; - locationDiv.appendChild(separatorSpan); - needSeparator = false; + // Create the h2 key, if exists + if (item.h2) { + if (!groupedByFileAndHeader[item.file][item.h1][item.h2]) { + groupedByFileAndHeader[item.file][item.h1][item.h2] = {}; } - let linkH2; - if (links.anchorH2 !== undefined) { - const href = rootUrl + "/" + links.file + "#" + links.anchorH2; - linkH2 = document.createElement("a"); - linkH2.href = href; + + // Create the h3 key, if exists + if (item.h3) { + if (!groupedByFileAndHeader[item.file][item.h1][item.h2][item.h3]) { + groupedByFileAndHeader[item.file][item.h1][item.h2][item.h3] = []; + } + groupedByFileAndHeader[item.file][item.h1][item.h2][item.h3].push(item); } else { - linkH2 = document.createElement("span"); + // If no h3, use the default symbol + if ( + !groupedByFileAndHeader[item.file][item.h1][item.h2][ + __DEFAULT_SYMBOL__ + ] + ) { + groupedByFileAndHeader[item.file][item.h1][item.h2][ + __DEFAULT_SYMBOL__ + ] = []; + } + groupedByFileAndHeader[item.file][item.h1][item.h2][ + __DEFAULT_SYMBOL__ + ].push(item); } - linkH2.className = "h2"; - linkH2.textContent = res.item.h2; - locationDiv.appendChild(linkH2); - needSeparator = true; + } else { + // If no h2, use the default symbol under h1 + if (!groupedByFileAndHeader[item.file][item.h1][__DEFAULT_SYMBOL__]) { + groupedByFileAndHeader[item.file][item.h1][__DEFAULT_SYMBOL__] = []; + } + groupedByFileAndHeader[item.file][item.h1][__DEFAULT_SYMBOL__].push(item); } - if (res.item.h3 !== undefined && res.item.h3 !== "") { - if (needSeparator) { - const separatorSpan = document.createElement("span"); - separatorSpan.textContent = " > "; - locationDiv.appendChild(separatorSpan); - needSeparator = false; + }); + return groupedByFileAndHeader; +} +/** + * From a nested object contain search results returns an array with all + * the search items. + */ +function flattenGroupedItems(groupedByFileAndHeader) { + const flattenedItems = []; + + // Helper function to recursively traverse the nested structure + function traverse(group) { + // If the group is an array, it means there is no deeper level. + if (Array.isArray(group)) { + flattenedItems.push(...group); + } else if (group !== null && typeof group === "object") { + if (group[__DEFAULT_SYMBOL__] !== undefined) { + traverse(group[__DEFAULT_SYMBOL__]); } - let linkH3; - if (links.anchorH3 !== undefined) { - const href = rootUrl + "/" + links.file + "#" + links.anchorH3; - linkH3 = document.createElement("a"); - linkH3.href = href; - } else { - linkH3 = document.createElement("span"); + + // Object.keys() does not iterate through Symbol, so the __DEFAULT_SYMBOL__ + // key will not be handled here. + for (const key of Object.keys(group)) { + traverse(group[key]); } - linkH3.className = "h3"; - linkH3.textContent = res.item.h3; - locationDiv.appendChild(linkH3); } - const bodyDiv = document.createElement("div"); - bodyDiv.className = "search-result-body"; - let body = res.item.body ?? ""; - if (body.length > 300) { - body = body.substring(0, 300) + "..."; + } + // Start traversing from the top-level grouping + for (const file in groupedByFileAndHeader) { + for (const h1 in groupedByFileAndHeader[file]) { + traverse(groupedByFileAndHeader[file][h1]); } - bodyDiv.textContent = body; - - contentDiv.appendChild(locationDiv); - contentDiv.appendChild(bodyDiv); - searchResultsElt.appendChild(contentDiv); } + return flattenedItems; } /** @@ -1031,3 +1107,87 @@ function getSearchIconElements() { function getSearchWrapperElement() { return document.getElementById("search-wrapper"); } +/** + * Creates an array of rendered elements based on the specified display level and item data. + * + * @param {Object} item - The item containing heading properties (h1, h2, h3). + * @param {number} displayFromLevel - The level from which to start rendering headings (1 to 3). + * @returns {Array} An array of elements created for the relevant heading levels. + */ +function createHeadingElements(item, displayFromLevel) { + const headingElements = []; + const headingLevelToRender = []; + + if (displayFromLevel <= 1 && item.h1) { + headingLevelToRender.push("h1"); + } + + if (displayFromLevel <= 2 && item.h2) { + headingLevelToRender.push("h2"); + } + + if (displayFromLevel <= 3 && item.h3) { + headingLevelToRender.push("h3"); + } + + for (const headingLevel of headingLevelToRender) { + const element = createResultElement(item, headingLevel); + headingElements.push(element); + } + return headingElements; +} + +function createResultElement(item, itemLevel) { + const links = searchIndexLinks[+item.id]; + const contentDiv = document.createElement("div"); + contentDiv.className = "search-result-item"; + const locationDiv = document.createElement("div"); + locationDiv.className = "search-result-location"; + + let href; + let textContent; + if (itemLevel === "h3") { + contentDiv.classList.add("search-result-item-is-h3"); + if (links.anchorH3 !== undefined) { + href = rootUrl + "/" + links.file + "#" + links.anchorH3; + } + textContent = item.h3; + } else if (itemLevel === "h2") { + contentDiv.classList.add("search-result-item-is-h2"); + if (links.anchorH2 !== undefined) { + href = rootUrl + "/" + links.file + "#" + links.anchorH2; + } + textContent = item.h2; + } else if (itemLevel === "h1") { + contentDiv.classList.add("search-result-item-is-h1"); + if (links.anchorH1 !== undefined) { + href = rootUrl + "/" + links.file + "#" + links.anchorH1; + } + textContent = item.h1; + } + + let anchorElement; + if (href) { + anchorElement = document.createElement("a"); + anchorElement.href = href; + } else { + anchorElement = document.createElement("span"); + } + + anchorElement.textContent = textContent; + anchorElement.className = itemLevel; + locationDiv.appendChild(anchorElement); + + const bodyDiv = document.createElement("div"); + bodyDiv.className = "search-result-body"; + let body = item.body ?? ""; + if (body.length > 300) { + body = body.substring(0, 300) + "..."; + } + bodyDiv.textContent = body; + + contentDiv.appendChild(locationDiv); + contentDiv.appendChild(bodyDiv); + + return contentDiv; +} diff --git a/build/styles/style.css b/build/styles/style.css index cfc5b41..b626bc4 100644 --- a/build/styles/style.css +++ b/build/styles/style.css @@ -222,6 +222,25 @@ table tr td { margin: 17px 0px; } +.search-result-item-is-h2 { + padding-left: 40px; +} + +.search-result-item-is-h3 { + padding-left: 80px; +} + +.search-result-item-is-h1 .search-result-location::before { + content: "📄"; + padding-right: 8px; +} + +.search-result-item-is-h2 .search-result-location::before, +.search-result-item-is-h3 .search-result-location::before { + content: "#️"; + padding-right: 8px; +} + #search-results { margin-top: 20px; padding-bottom: 20px; diff --git a/src/create_documentation_page.ts b/src/create_documentation_page.ts index 316380b..4542a7d 100644 --- a/src/create_documentation_page.ts +++ b/src/create_documentation_page.ts @@ -120,7 +120,7 @@ export default async function createDocumentationPage({ tocMd, nbTocElements, } = await parseMD(data, inputDir, outputDir, baseOutDir, linkTranslator); - const searchData = getSearchDataForContent(resHtml); + const searchData = getSearchDataForContent(resHtml, outputUrlFromRoot); searchIndex.push({ file: outputUrlFromRoot, index: searchData, diff --git a/src/get_search_data_for_content.ts b/src/get_search_data_for_content.ts index 035042b..bcd9f26 100644 --- a/src/get_search_data_for_content.ts +++ b/src/get_search_data_for_content.ts @@ -2,6 +2,7 @@ import type { AnyNode } from "cheerio"; import { load } from "cheerio"; export interface FileSearchIndex { + fileURL: string; h1: string | undefined; h2?: string | undefined; h3?: string | undefined; @@ -18,6 +19,7 @@ export interface FileSearchIndex { */ export default function getSearchDataForContent( contentHtml: string, + fileURL: string, ): FileSearchIndex[] { const indexForFile: FileSearchIndex[] = []; const $ = load(contentHtml); @@ -87,6 +89,7 @@ export default function getSearchDataForContent( if (currentLevel === "h3") { const body = currentBody.length > 0 ? currentBody.join(" ") : ""; indexForFile.push({ + fileURL, h1: currentH1, h2: currentH2, h3: currentH3, @@ -98,6 +101,7 @@ export default function getSearchDataForContent( } else if (currentLevel === "h2") { const body = currentBody.length > 0 ? currentBody.join(" ") : ""; indexForFile.push({ + fileURL, h1: currentH1, h2: currentH2, body, @@ -107,6 +111,7 @@ export default function getSearchDataForContent( } else if (currentLevel === "h1") { const body = currentBody.length > 0 ? currentBody.join(" ") : ""; indexForFile.push({ + fileURL, h1: currentH1, body, anchorH1: currentH1Anchor,