mirror of
https://github.com/php/web-php.git
synced 2026-03-23 23:02:13 +01:00
Co-authored-by: Gina Peter Banyard <girgias@php.net> Co-authored-by: Sergey Panteleev <sergey@php.net>
472 lines
15 KiB
JavaScript
472 lines
15 KiB
JavaScript
/**
|
|
* Initialize the PHP search functionality with a given language.
|
|
* Loads the search index, sets up FuzzySearch, and returns a search function.
|
|
*
|
|
* @param {string} language The language for which the search index should be
|
|
* loaded.
|
|
* @returns {Promise<(query: string) => Array>} A function that takes a query
|
|
* and performs a search using the loaded index.
|
|
*/
|
|
const initPHPSearch = async (language) => {
|
|
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
const CACHE_DAYS = 14;
|
|
|
|
/**
|
|
* Converts the structure from search-index.php into an array of objects,
|
|
* mapping the index entries to their respective types.
|
|
*
|
|
* @param {object} index
|
|
* @returns {Array}
|
|
*/
|
|
const processIndex = (index) => {
|
|
return Object.entries(index)
|
|
.map(([id, [name, description, tag]]) => {
|
|
if (!name) return null;
|
|
|
|
let type = "General";
|
|
switch (tag) {
|
|
case "phpdoc:varentry":
|
|
type = "Variable";
|
|
break;
|
|
|
|
case "refentry":
|
|
type = "Function";
|
|
break;
|
|
|
|
case "phpdoc:exceptionref":
|
|
type = "Exception";
|
|
break;
|
|
|
|
case "phpdoc:classref":
|
|
type = "Class";
|
|
break;
|
|
|
|
case "set":
|
|
case "book":
|
|
case "reference":
|
|
type = "Extension";
|
|
break;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
description,
|
|
tag,
|
|
type,
|
|
methodName: name.split("::").pop(),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
};
|
|
|
|
/**
|
|
* Looks up the search index cached in localStorage.
|
|
*
|
|
* @returns {Array|null}
|
|
*/
|
|
const lookupIndexCache = () => {
|
|
const key = `search-${language}`;
|
|
const cache = window.localStorage.getItem(key);
|
|
|
|
if (!cache) {
|
|
return null;
|
|
}
|
|
|
|
const { data, time: cachedDate } = JSON.parse(cache);
|
|
|
|
// Invalidate old search cache format (previously an object)
|
|
// TODO: Remove this check once the new search index (a single array)
|
|
// has been in use for a while.
|
|
if (!Array.isArray(data)) {
|
|
console.log("Invalidating old search cache format");
|
|
return null;
|
|
}
|
|
|
|
const expireDate = cachedDate + CACHE_DAYS * MILLISECONDS_PER_DAY;
|
|
|
|
if (Date.now() > expireDate) {
|
|
return null;
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
/**
|
|
* Fetch the search index.
|
|
*
|
|
* @returns {Promise<Array>} The search index.
|
|
*/
|
|
const fetchIndex = async () => {
|
|
const key = `search-${language}`;
|
|
const response = await fetch(`/js/search-index.php?lang=${language}`);
|
|
const data = await response.json();
|
|
const items = processIndex(data);
|
|
|
|
try {
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
data: items,
|
|
time: Date.now(),
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
// Local storage might be full, or other error.
|
|
// Just continue without caching.
|
|
console.error("Failed to cache search index", e);
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
/**
|
|
* Loads the search index, using cache if available.
|
|
*
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
const loadIndex = async () => {
|
|
const cached = lookupIndexCache();
|
|
return cached || fetchIndex();
|
|
};
|
|
|
|
/**
|
|
* Load the language index, falling back to English on error.
|
|
*
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
const loadIndexWithFallback = async () => {
|
|
try {
|
|
const searchItems = await loadIndex();
|
|
return searchItems;
|
|
} catch (error) {
|
|
if (language !== "en") {
|
|
return loadIndexWithFallback("en");
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Perform a search using the given query and a FuzzySearch instance.
|
|
*
|
|
* @param {string} query The search query.
|
|
* @param {object} fuzzyhound The FuzzySearch instance to use for searching.
|
|
* @returns {Array} An array of search results.
|
|
*/
|
|
const search = (query, fuzzyhound) => {
|
|
return fuzzyhound
|
|
.search(query)
|
|
.map((result) => {
|
|
// Boost Language Reference matches.
|
|
if (result.item.id.startsWith("language")) {
|
|
result.score += 10;
|
|
}
|
|
return result;
|
|
})
|
|
.sort((a, b) => b.score - a.score);
|
|
};
|
|
|
|
const searchIndex = await loadIndexWithFallback();
|
|
if (!searchIndex) {
|
|
throw new Error("Failed to load search index");
|
|
}
|
|
|
|
fuzzyhound = new FuzzySearch({
|
|
source: searchIndex,
|
|
token_sep: " \t.,-_",
|
|
score_test_fused: true,
|
|
keys: ["name", "methodName", "description"],
|
|
thresh_include: 5.0,
|
|
thresh_relative_to_best: 0.7,
|
|
bonus_match_start: 0.7,
|
|
bonus_token_order: 1.0,
|
|
bonus_position_decay: 0.3,
|
|
token_query_min_length: 1,
|
|
token_field_min_length: 2,
|
|
output_map: "root",
|
|
});
|
|
|
|
return (query) => search(query, fuzzyhound);
|
|
};
|
|
|
|
/**
|
|
* Initialize the search modal, handling focus trap and modal transitions.
|
|
*/
|
|
const initSearchModal = () => {
|
|
const backdropElement = document.getElementById("search-modal__backdrop");
|
|
const modalElement = document.getElementById("search-modal");
|
|
const resultsElement = document.getElementById("search-modal__results");
|
|
const inputElement = document.getElementById("search-modal__input");
|
|
|
|
const focusTrapHandler = (event) => {
|
|
if (event.key != "Tab") {
|
|
return;
|
|
}
|
|
|
|
const selectable = modalElement.querySelectorAll("input, button, a");
|
|
const lastElement = selectable[selectable.length - 1];
|
|
|
|
if (event.shiftKey) {
|
|
if (document.activeElement === inputElement) {
|
|
event.preventDefault();
|
|
lastElement.focus();
|
|
}
|
|
} else if (document.activeElement === lastElement) {
|
|
event.preventDefault();
|
|
inputElement.focus();
|
|
}
|
|
};
|
|
|
|
const onModalTransitionEnd = (handler) => {
|
|
backdropElement.addEventListener("transitionend", handler, {
|
|
once: true,
|
|
});
|
|
};
|
|
|
|
const documentWidth = document.documentElement.clientWidth;
|
|
const scrollbarWidth = Math.abs(window.innerWidth - documentWidth);
|
|
|
|
const show = function () {
|
|
if (
|
|
backdropElement.classList.contains("show") ||
|
|
backdropElement.classList.contains("showing")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
document.body.style.overflow = "hidden";
|
|
document.documentElement.style.overflow = "hidden";
|
|
resultsElement.innerHTML = "";
|
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
|
|
backdropElement.setAttribute("aria-modal", "true");
|
|
backdropElement.setAttribute("role", "dialog");
|
|
backdropElement.classList.add("showing");
|
|
inputElement.focus();
|
|
inputElement.value = "";
|
|
document.addEventListener("keydown", focusTrapHandler);
|
|
|
|
onModalTransitionEnd(() => {
|
|
backdropElement.classList.remove("showing");
|
|
backdropElement.classList.add("show");
|
|
});
|
|
};
|
|
|
|
const hide = function () {
|
|
if (!backdropElement.classList.contains("show")) {
|
|
return;
|
|
}
|
|
|
|
backdropElement.classList.add("hiding");
|
|
backdropElement.classList.remove("show");
|
|
backdropElement.removeAttribute("aria-modal");
|
|
backdropElement.removeAttribute("role");
|
|
onModalTransitionEnd(() => {
|
|
document.body.style.overflow = "auto";
|
|
document.documentElement.style.overflow = "auto";
|
|
document.body.style.paddingRight = "0px";
|
|
backdropElement.classList.remove("hiding");
|
|
document.removeEventListener("keydown", focusTrapHandler);
|
|
});
|
|
};
|
|
|
|
const searchLink = document.getElementById("navbar__search-link");
|
|
const searchButtonMobile = document.getElementById(
|
|
"navbar__search-button-mobile",
|
|
);
|
|
const searchButton = document.getElementById("navbar__search-button");
|
|
|
|
// Enhance mobile search
|
|
searchLink.setAttribute("hidden", "true");
|
|
searchButtonMobile.removeAttribute("hidden");
|
|
|
|
// Enhance desktop search
|
|
document
|
|
.querySelector(".navbar__search-form")
|
|
.setAttribute("hidden", "true");
|
|
searchButton.removeAttribute("hidden");
|
|
|
|
// Open when the search button is clicked
|
|
[searchButton, searchButtonMobile].forEach((button) =>
|
|
button.addEventListener("click", show),
|
|
);
|
|
|
|
// Open when / is pressed
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "/") {
|
|
show();
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Close when the close button is clicked
|
|
document
|
|
.querySelector(".search-modal__close")
|
|
.addEventListener("click", hide);
|
|
|
|
// Close when the escape key is pressed
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
hide();
|
|
}
|
|
});
|
|
|
|
// Close when the user clicks outside of it
|
|
backdropElement.addEventListener("click", (event) => {
|
|
if (event.target === backdropElement) {
|
|
hide();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize the search modal UI, setting up search result rendering and
|
|
* input handling.
|
|
*
|
|
* @param {object} options An object containing the search callback, language,
|
|
* and result limit.
|
|
*/
|
|
const initSearchUI = ({ searchCallback, language, limit = 30 }) => {
|
|
const DEBOUNCE_DELAY = 200;
|
|
// https://pictogrammers.com/library/mdi/icon/code-braces/
|
|
const BRACES_ICON =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z" /></svg>';
|
|
// https://pictogrammers.com/library/mdi/icon/file-document-outline/
|
|
const DOCUMENT_ICON =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z" /></svg>';
|
|
|
|
const resultsElement = document.getElementById("search-modal__results");
|
|
const inputElement = document.getElementById("search-modal__input");
|
|
let selectedIndex = -1;
|
|
|
|
/**
|
|
* Update the selected result in the results container.
|
|
*/
|
|
const updateSelectedResult = () => {
|
|
const results = resultsElement.querySelectorAll(
|
|
".search-modal__result",
|
|
);
|
|
results.forEach((result, index) => {
|
|
const isSelected = index === selectedIndex;
|
|
result.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
if (!isSelected) {
|
|
result.classList.remove("selected");
|
|
return;
|
|
}
|
|
result.classList.add("selected");
|
|
result.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "nearest",
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Render the search results.
|
|
*
|
|
* @param {Array} results The search results.
|
|
*/
|
|
const renderResults = (results) => {
|
|
const escape = (html) => {
|
|
const div = document.createElement("div");
|
|
const node = document.createTextNode(html);
|
|
div.appendChild(node);
|
|
return div.innerHTML;
|
|
};
|
|
|
|
let resultsHtml = "";
|
|
results.forEach(({ item }, i) => {
|
|
const icon = ["General", "Extension"].includes(item.type)
|
|
? DOCUMENT_ICON
|
|
: BRACES_ICON;
|
|
const link = `/manual/${encodeURIComponent(language)}/${encodeURIComponent(item.id)}.php`;
|
|
|
|
const description =
|
|
item.type !== "General"
|
|
? `${item.type} • ${item.description}`
|
|
: item.description;
|
|
|
|
resultsHtml += `
|
|
<a
|
|
href="${link}"
|
|
class="search-modal__result"
|
|
role="option"
|
|
aria-labelledby="search-modal__result-name-${i}"
|
|
aria-describedby="search-modal__result-description-${i}"
|
|
aria-selected="false"
|
|
>
|
|
<div class="search-modal__result-icon">${icon}</div>
|
|
<div class="search-modal__result-content">
|
|
<div
|
|
id="search-modal__result-name-${i}"
|
|
class="search-modal__result-name"
|
|
>
|
|
${escape(item.name)}
|
|
</div>
|
|
<div
|
|
id="search-modal__result-description-${i}"
|
|
class="search-modal__result-description"
|
|
>
|
|
${escape(description)}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
});
|
|
|
|
resultsElement.innerHTML = resultsHtml;
|
|
};
|
|
|
|
const debounce = (func, delay) => {
|
|
let timeoutId;
|
|
return (...args) => {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => func(...args), delay);
|
|
};
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
const resultsElements = resultsElement.querySelectorAll(
|
|
".search-modal__result",
|
|
);
|
|
|
|
switch (event.key) {
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
selectedIndex = Math.min(
|
|
selectedIndex + 1,
|
|
resultsElements.length - 1,
|
|
);
|
|
updateSelectedResult();
|
|
break;
|
|
case "ArrowUp":
|
|
event.preventDefault();
|
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
|
updateSelectedResult();
|
|
break;
|
|
case "Enter":
|
|
if (selectedIndex !== -1) {
|
|
event.preventDefault();
|
|
resultsElements[selectedIndex].click();
|
|
} else {
|
|
window.location.href = `/search.php?lang=${language}&q=${encodeURIComponent(inputElement.value)}`;
|
|
}
|
|
break;
|
|
case "Escape":
|
|
selectedIndex = -1;
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleInput = (event) => {
|
|
const results = searchCallback(event.target.value);
|
|
renderResults(results.slice(0, limit), language, resultsElement);
|
|
selectedIndex = -1;
|
|
};
|
|
const debouncedHandleInput = debounce(handleInput, DEBOUNCE_DELAY);
|
|
|
|
inputElement.addEventListener("input", debouncedHandleInput);
|
|
inputElement.addEventListener("keydown", handleKeyDown);
|
|
};
|