Files
hextra_mirror/assets/js/flexsearch.js
Xin 6823cc1fe8 refactor(css): add prefix to component classes for consistency (#744)
* refactor(navbar): add hextra prefix to navbar and hamburger menu classes

- Updated CSS class names from `nav-container` to `hextra-nav-container` and `hamburger-menu` to `hextra-hamburger-menu` for improved clarity and consistency across the project.
- Adjusted related JavaScript and documentation to reflect the new class names.

* refactor(search): update class names for search components

- Renamed CSS classes from `search-wrapper`, `search-input`, `active`, `no-result`, `prefix`, `excerpt`, and `match` to `hextra-search-wrapper`, `hextra-search-input`, `hextra-search-active`, `hextra-search-no-result`, `hextra-search-prefix`, `hextra-search-excerpt`, and `hextra-search-match` for improved clarity and consistency.
- Updated JavaScript selectors to match the new class names, ensuring functionality remains intact.
- Adjusted HTML structure to reflect the new class naming convention.

* refactor(search): update search component class names for consistency

- Renamed existing search-related CSS classes to include the `hextra` prefix for improved clarity and consistency.
- Added optional nested classes for enhanced customization of the search UI, including titles, active states, and result snippets.
- Removed outdated breadcrumb section as no specific class is available.

* refactor(sidebar): rename sidebar classes for consistency

- Updated CSS and JavaScript to replace `sidebar-container` with `hextra-sidebar-container` and `sidebar-active-item` with `hextra-sidebar-active-item` for improved clarity and consistency across the project.
- Adjusted related documentation to reflect the new class names.

* refactor(language & theme): update class names for consistency

- Renamed language switcher and theme toggle classes to include the `hextra` prefix for improved clarity and consistency across the project.
- Updated related JavaScript selectors and documentation to reflect the new class names.

* refactor(css & html): rename classes for consistency and clarity

- Updated various CSS class names to include the `hextra` prefix, enhancing consistency across the project. This includes renaming classes such as `content` to `hextra-content`, `filename` to `hextra-code-filename`, and `steps` to `hextra-steps`.
- Adjusted related HTML and JavaScript to reflect the new class names, ensuring functionality and styling remain intact.
- Updated documentation to include the new class names for better clarity.

* refactor(language): update class names for language options

- Renamed the `language-options` class to `hextra-language-options` for consistency with the existing `hextra` prefix convention.
- Updated the corresponding HTML to reflect the new class name, ensuring clarity and uniformity across the project.

* refactor(css & html): rename classes for consistency and clarity

- Renamed CSS classes to include the `hextra` prefix, such as changing `subheading-anchor` to `hextra-subheading-anchor` and `footnotes` to `hextra-footnotes`, enhancing consistency across the project.
- Updated related HTML and documentation to reflect the new class names, ensuring clarity and uniformity.

* feat(typography): add styling for horizontal lines

- Introduced new styles for horizontal lines to enhance visual separation in content. The styles apply margin and border color adjustments, ensuring consistency with the overall design.

* feat(blog): add draft release announcement for Hextra v0.10.0

- Created a new markdown file for the draft release announcement of Hextra v0.10.0, including upgrade instructions and author details.
- The announcement is currently marked as a draft and may be updated before the official release.

* refactor(css & html): rename `hextra-content` class to `content` for consistency

- Updated the `hextra-content` class to simply `content` across various HTML files and CSS, enhancing clarity and consistency in the codebase.
- Adjusted the `package.json` script for the development server to include the `-F` flag for better functionality.

* refactor(typography & markdown): enhance table styling and markdown syntax

- Updated CSS for tables to improve styling, including adjustments to margins, borders, and text properties for better readability.
- Revised markdown documentation to standardize table formatting and improve clarity, including consistent syntax for headers and lists.
- Enhanced examples in the documentation to reflect the updated styling and ensure accurate representation of output.

* fix(blog): update text color for improved accessibility

- Modified the text color in the blog single layout to enhance readability in dark mode by adding a dark text color class.
- Ensured consistency in styling for better user experience across different themes.

* docs(blog): update draft release announcement for Hextra v0.10.0

- Revised the "What's New" section to include a TODO placeholder for future updates.
- Added a comprehensive migration guide detailing the CSS class prefix changes to enhance consistency and avoid conflicts.
- Updated the announcement to reflect the new class naming conventions for various components.

* chore: rebuild css
2025-08-13 22:55:38 +08:00

440 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Search functionality using FlexSearch.
// Change shortcut key to cmd+k on Mac, iPad or iPhone.
document.addEventListener("DOMContentLoaded", function () {
if (/iPad|iPhone|Macintosh/.test(navigator.userAgent)) {
// select the kbd element under the .hextra-search-wrapper class
const keys = document.querySelectorAll(".hextra-search-wrapper kbd");
keys.forEach(key => {
key.innerHTML = '<span class="hx:text-xs">⌘</span>K';
});
}
});
// Render the search data as JSON.
// {{ $searchDataFile := printf "%s.search-data.json" .Language.Lang }}
// {{ $searchData := resources.Get "json/search-data.json" | resources.ExecuteAsTemplate $searchDataFile . }}
// {{ if hugo.IsProduction }}
// {{ $searchData := $searchData | minify | fingerprint }}
// {{ end }}
// {{ $noResultsFound := (T "noResultsFound") | default "No results found." }}
(function () {
const searchDataURL = '{{ $searchData.RelPermalink }}';
const inputElements = document.querySelectorAll('.hextra-search-input');
for (const el of inputElements) {
el.addEventListener('focus', init);
el.addEventListener('keyup', search);
el.addEventListener('keydown', handleKeyDown);
el.addEventListener('input', handleInputChange);
}
const shortcutElements = document.querySelectorAll('.hextra-search-wrapper kbd');
function setShortcutElementsOpacity(opacity) {
shortcutElements.forEach(el => {
el.style.opacity = opacity;
});
}
function handleInputChange(e) {
const opacity = e.target.value.length > 0 ? 0 : 100;
setShortcutElementsOpacity(opacity);
}
// Get the search wrapper, input, and results elements.
function getActiveSearchElement() {
const inputs = Array.from(document.querySelectorAll('.hextra-search-wrapper')).filter(el => el.clientHeight > 0);
if (inputs.length === 1) {
return {
wrapper: inputs[0],
inputElement: inputs[0].querySelector('.hextra-search-input'),
resultsElement: inputs[0].querySelector('.hextra-search-results')
};
}
return undefined;
}
const INPUTS = ['input', 'select', 'button', 'textarea']
// Focus the search input when pressing ctrl+k/cmd+k or /.
document.addEventListener('keydown', function (e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const activeElement = document.activeElement;
const tagName = activeElement && activeElement.tagName;
if (
inputElement === activeElement ||
!tagName ||
INPUTS.includes(tagName) ||
(activeElement && activeElement.isContentEditable))
return;
if (
e.key === '/' ||
(e.key === 'k' &&
(e.metaKey /* for Mac */ || /* for non-Mac */ e.ctrlKey))
) {
e.preventDefault();
inputElement.focus();
} else if (e.key === 'Escape' && inputElement.value) {
inputElement.blur();
}
});
// Dismiss the search results when clicking outside the search box.
document.addEventListener('mousedown', function (e) {
const { inputElement, resultsElement } = getActiveSearchElement();
if (!inputElement || !resultsElement) return;
if (
e.target !== inputElement &&
e.target !== resultsElement &&
!resultsElement.contains(e.target)
) {
setShortcutElementsOpacity(100);
hideSearchResults();
}
});
// Get the currently active result and its index.
function getActiveResult() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return { result: undefined, index: -1 };
const result = resultsElement.querySelector('.hextra-search-active');
if (!result) return { result: undefined, index: -1 };
const index = parseInt(result.dataset.index, 10);
return { result, index };
}
// Set the active result by index.
function setActiveResult(index) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
const { result: activeResult } = getActiveResult();
activeResult && activeResult.classList.remove('hextra-search-active');
const result = resultsElement.querySelector(`[data-index="${index}"]`);
if (result) {
result.classList.add('hextra-search-active');
result.focus();
}
}
// Get the number of search results from the DOM.
function getResultsLength() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return 0;
return resultsElement.dataset.count;
}
// Finish the search by hiding the results and clearing the input.
function finishSearch() {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
hideSearchResults();
inputElement.value = '';
inputElement.blur();
}
function hideSearchResults() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
resultsElement.classList.add('hx:hidden');
}
// Handle keyboard events.
function handleKeyDown(e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const resultsLength = getResultsLength();
const { result: activeResult, index: activeIndex } = getActiveResult();
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (activeIndex > 0) setActiveResult(activeIndex - 1);
break;
case 'ArrowDown':
e.preventDefault();
if (activeIndex + 1 < resultsLength) setActiveResult(activeIndex + 1);
break;
case 'Enter':
e.preventDefault();
if (activeResult) {
activeResult.click();
}
finishSearch();
case 'Escape':
e.preventDefault();
hideSearchResults();
// Clear the input when pressing escape
inputElement.value = '';
inputElement.dispatchEvent(new Event('input'));
// Remove focus from the input
inputElement.blur();
break;
}
}
// Initializes the search.
function init(e) {
e.target.removeEventListener('focus', init);
if (!(window.pageIndex && window.sectionIndex)) {
preloadIndex();
}
}
/**
* Preloads the search index by fetching data and adding it to the FlexSearch index.
* @returns {Promise<void>} A promise that resolves when the index is preloaded.
*/
async function preloadIndex() {
const tokenize = '{{- site.Params.search.flexsearch.tokenize | default "forward" -}}';
// https://github.com/TryGhost/Ghost/pull/21148
const regex = new RegExp(
`[\u{4E00}-\u{9FFF}\u{3040}-\u{309F}\u{30A0}-\u{30FF}\u{AC00}-\u{D7A3}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{2CEB0}-\u{2EBEF}\u{30000}-\u{3134F}\u{31350}-\u{323AF}\u{2EBF0}-\u{2EE5F}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]|[0-9A-Za-zа\u00C0-\u017F\u0400-\u04FF\u0600-\u06FF\u0980-\u09FF\u1E00-\u1EFF\u0590-\u05FF]+`,
'mug'
);
const encode = (str) => { return ('' + str).toLowerCase().match(regex) ?? []; }
window.pageIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'crumb'],
index: "content"
}
});
window.sectionIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'content', 'url', 'display', 'crumb'],
index: "content",
tag: [{
field: "pageId"
}]
}
});
const resp = await fetch(searchDataURL);
const data = await resp.json();
let pageId = 0;
for (const route in data) {
let pageContent = '';
++pageId;
const urlParts = route.split('/').filter(x => x != "" && !x.startsWith('#'));
let crumb = '';
let searchUrl = '/';
for (let i = 0; i < urlParts.length; i++) {
const urlPart = urlParts[i];
searchUrl += urlPart + '/'
const crumbData = data[searchUrl];
if (!crumbData) {
console.warn('Excluded page', searchUrl, '- will not be included for search result breadcrumb for', route);
continue;
}
let title = data[searchUrl].title;
if (title == "_index") {
title = urlPart.split("-").map(x => x).join(" ");
}
crumb += title;
if (i < urlParts.length - 1) {
crumb += ' > ';
}
}
for (const heading in data[route].data) {
const [hash, text] = heading.split('#');
const url = route.trimEnd('/') + (hash ? '#' + hash : '');
const title = text || data[route].title;
const content = data[route].data[heading] || '';
const paragraphs = content.split('\n').filter(Boolean);
sectionIndex.add({
id: url,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: title,
...(paragraphs[0] && { display: paragraphs[0] })
});
for (let i = 0; i < paragraphs.length; i++) {
sectionIndex.add({
id: `${url}_${i}`,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: paragraphs[i]
});
}
pageContent += ` ${title} ${content}`;
}
window.pageIndex.add({
id: pageId,
title: data[route].title,
crumb,
content: pageContent
});
}
}
/**
* Performs a search based on the provided query and displays the results.
* @param {Event} e - The event object.
*/
function search(e) {
const query = e.target.value;
if (!e.target.value) {
hideSearchResults();
return;
}
const { resultsElement } = getActiveSearchElement();
while (resultsElement.firstChild) {
resultsElement.removeChild(resultsElement.firstChild);
}
resultsElement.classList.remove('hx:hidden');
const pageResults = window.pageIndex.search(query, 5, { enrich: true, suggest: true })[0]?.result || [];
const results = [];
const pageTitleMatches = {};
for (let i = 0; i < pageResults.length; i++) {
const result = pageResults[i];
pageTitleMatches[i] = 0;
// Show the top 5 results for each page
const sectionResults = window.sectionIndex.search(query, 5, { enrich: true, suggest: true, tag: { 'pageId': `page_${result.id}` } })[0]?.result || [];
let isFirstItemOfPage = true
const occurred = {}
for (let j = 0; j < sectionResults.length; j++) {
const { doc } = sectionResults[j]
const isMatchingTitle = doc.display !== undefined
if (isMatchingTitle) {
pageTitleMatches[i]++
}
const { url, title } = doc
const content = doc.display || doc.content
if (occurred[url + '@' + content]) continue
occurred[url + '@' + content] = true
results.push({
_page_rk: i,
_section_rk: j,
route: url,
prefix: isFirstItemOfPage ? result.doc.crumb : undefined,
children: { title, content }
})
isFirstItemOfPage = false
}
}
const sortedResults = results
.sort((a, b) => {
// Sort by number of matches in the title.
if (a._page_rk === b._page_rk) {
return a._section_rk - b._section_rk
}
if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) {
return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]
}
return a._page_rk - b._page_rk
})
.map(res => ({
id: `${res._page_rk}_${res._section_rk}`,
route: res.route,
prefix: res.prefix,
children: res.children
}));
displayResults(sortedResults, query);
}
/**
* Displays the search results on the page.
*
* @param {Array} results - The array of search results.
* @param {string} query - The search query.
*/
function displayResults(results, query) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
if (!results.length) {
resultsElement.innerHTML = `<span class="hextra-search-no-result">{{ $noResultsFound | safeHTML }}</span>`;
return;
}
// Highlight the query in the result text.
function highlightMatches(text, query) {
const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(escapedQuery, 'gi');
return text.replace(regex, (match) => `<span class="match">${match}</span>`);
}
// Create a DOM element from the HTML string.
function createElement(str) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return div.firstChild;
}
function handleMouseMove(e) {
const target = e.target.closest('a');
if (target) {
const active = resultsElement.querySelector('a.active');
if (active) {
active.classList.remove('active');
}
target.classList.add('active');
}
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.prefix) {
fragment.appendChild(createElement(`
<div class="hextra-search-prefix">${result.prefix}</div>`));
}
let li = createElement(`
<li>
<a data-index="${i}" href="${result.route}" class=${i === 0 ? "hextra-search-active" : ""}>
<div class="hextra-search-title">`+ highlightMatches(result.children.title, query) + `</div>` +
(result.children.content ?
`<div class="hextra-search-excerpt">` + highlightMatches(result.children.content, query) + `</div>` : '') + `
</a>
</li>`);
li.addEventListener('mousemove', handleMouseMove);
li.addEventListener('keydown', handleKeyDown);
li.querySelector('a').addEventListener('click', finishSearch);
fragment.appendChild(li);
}
resultsElement.appendChild(fragment);
resultsElement.dataset.count = results.length;
}
})();