diff --git a/functions/_middleware.ts b/functions/_middleware.ts index ccc6774cc..04c220bcb 100644 --- a/functions/_middleware.ts +++ b/functions/_middleware.ts @@ -1,11 +1,29 @@ // Cloudflare Pages Middleware -// Strips .html extensions from URLs with a 301 permanent redirect. +// - Strips .html extensions from URLs with a 301 permanent redirect. +// - Redirects /videos/ paths to the external video site based on domain. +// // e.g. /features/system-administration/data-migration/order-list-import-tool.html // -> /features/system-administration/data-migration/order-list-import-tool +// e.g. docs.enterprisehealth.com/videos/foo -> https://videos.enterprise.health/videos/foo +// e.g. docs.webchartnow.com/videos/bar -> https://videos.webch.art/videos/bar + +const VIDEO_HOSTS: Record = { + "docs.enterprisehealth.com": "https://videos.enterprise.health", + "docs.webchartnow.com": "https://videos.webch.art", +}; export const onRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); + // Redirect /videos/* to the external video site based on hostname + if (url.pathname.startsWith("/videos/") || url.pathname === "/videos") { + const host = VIDEO_HOSTS[url.hostname]; + if (host) { + const videoPath = url.pathname === "/videos" ? "/videos/" : url.pathname; + return Response.redirect(`${host}${videoPath}${url.search}`, 301); + } + } + if (url.pathname.endsWith(".html")) { // Remove .html, preserve query string and hash url.pathname = url.pathname.slice(0, -5); diff --git a/themes/mieweb-docs/assets/js/main.js b/themes/mieweb-docs/assets/js/main.js index 2e38a03e3..25096bfae 100644 --- a/themes/mieweb-docs/assets/js/main.js +++ b/themes/mieweb-docs/assets/js/main.js @@ -494,6 +494,24 @@ const searchResultTemplate = document.getElementById("search-result-template"); let searchIndex = null; let searchDocs = []; let selectedIndex = -1; +let searchIndexLoading = false; + +function showSearchSkeleton() { + if (!searchResults) return; + const skeletonCount = 5; + let html = ""; + for (let i = 0; i < skeletonCount; i++) { + html += ` +
+
+
+
+
+
+
`; + } + searchResults.innerHTML = html; +} function openSearchModal() { searchModal?.classList.remove("hidden"); @@ -517,6 +535,13 @@ function closeSearchModal() { `; } selectedIndex = -1; + + // Clean up ?q= from URL when modal is closed + const url = new URL(window.location.href); + if (url.searchParams.has("q")) { + url.searchParams.delete("q"); + window.history.replaceState({}, "", url.toString()); + } } searchTrigger?.addEventListener("click", openSearchModal); @@ -570,39 +595,71 @@ function updateSelectedResult(results) { }); } -// Load search index +// Load search index (non-blocking: yields to UI between chunks) async function loadSearchIndex() { if (searchIndex) return; + if (searchIndexLoading) { + // Already loading — wait for it to finish + return new Promise((resolve) => { + const check = setInterval(() => { + if (searchIndex || !searchIndexLoading) { + clearInterval(check); + resolve(); + } + }, 50); + }); + } + searchIndexLoading = true; try { const response = await fetch(`${window.BaseURL}search.json`); searchDocs = await response.json(); - searchIndex = lunr(function () { - this.ref("href"); - this.field("title", { boost: 10 }); - this.field("content"); + // Build lunr index in chunks to avoid blocking the UI thread + const builder = new lunr.Builder(); + builder.ref("href"); + builder.field("title", { boost: 10 }); + builder.field("content"); + + const CHUNK_SIZE = 200; + for (let i = 0; i < searchDocs.length; i += CHUNK_SIZE) { + const chunk = searchDocs.slice(i, i + CHUNK_SIZE); + chunk.forEach((doc) => builder.add(doc)); + // Yield to the browser so the UI stays responsive + if (i + CHUNK_SIZE < searchDocs.length) { + await new Promise((r) => setTimeout(r, 0)); + } + } - searchDocs.forEach((doc) => { - this.add(doc); - }); - }); + searchIndex = builder.build(); } catch (error) { console.error("Failed to load search index:", error); + } finally { + searchIndexLoading = false; } } -// Perform search -function performSearch(query) { - if (!searchIndex || !query.trim()) { - searchResults.innerHTML = ` -
-

Start typing to search...

-
- `; +// Perform search (async — shows skeleton while index loads) +async function performSearch(query) { + if (!query || !query.trim()) { + if (searchResults) { + searchResults.innerHTML = ` +
+

Start typing to search...

+
+ `; + } return; } + // Show skeleton immediately if index isn't ready yet + if (!searchIndex) { + showSearchSkeleton(); + await loadSearchIndex(); + } + + if (!searchIndex) return; // Still failed + const results = searchIndex.search(query + "*"); selectedIndex = -1; @@ -649,6 +706,46 @@ searchInput?.addEventListener("input", (e) => { searchTrigger?.addEventListener("click", loadSearchIndex); searchTriggerDesktop?.addEventListener("click", loadSearchIndex); +// ============================================ +// URL-driven Search (?q= and #search=) +// ============================================ +(function initUrlSearch() { + // Redirect #search=... to ?q=... for SEO-friendly URLs + const hash = window.location.hash; + if (hash.startsWith("#search=")) { + const query = decodeURIComponent(hash.substring("#search=".length)); + const url = new URL(window.location.href); + url.hash = ""; + url.searchParams.set("q", query); + window.location.replace(url.toString()); + return; // Stop — page will reload with ?q= + } + + // Open search modal with ?q= query on page load + const urlParams = new URLSearchParams(window.location.search); + const queryParam = urlParams.get("q"); + if (queryParam && searchInput) { + const runSearch = async () => { + // Show modal + query text + skeleton instantly (no waiting) + openSearchModal(); + searchInput.value = queryParam; + showSearchSkeleton(); + // Now load index in background and run search + await loadSearchIndex(); + // Only search if the user hasn't changed the input meanwhile + if (searchInput.value === queryParam) { + performSearch(queryParam); + } + }; + // lunr.js is loaded with defer, so it may not be ready yet + if (typeof lunr !== "undefined") { + runSearch(); + } else { + window.addEventListener("load", runSearch); + } + } +})(); + // ============================================ // Image Lightbox // ============================================ diff --git a/themes/mieweb-docs/layouts/_default/baseof.html b/themes/mieweb-docs/layouts/_default/baseof.html index f56b04f93..e583e9eb0 100644 --- a/themes/mieweb-docs/layouts/_default/baseof.html +++ b/themes/mieweb-docs/layouts/_default/baseof.html @@ -166,6 +166,18 @@ window.BaseURL = "{{ .Site.BaseURL }}"; window.BrandCode = "{{ .Site.Params.Brand.code }}"; + // Early redirect: #search=... → ?q=... (before page renders) + (function() { + var hash = window.location.hash; + if (hash.indexOf('#search=') === 0) { + var query = decodeURIComponent(hash.substring(8)); + var url = new URL(window.location.href); + url.hash = ''; + url.searchParams.set('q', query); + window.location.replace(url.toString()); + } + })(); + // Theme detection if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.setAttribute('data-theme', 'dark');