From 42c26d442b347260cd6d627e7b55121a0d670257 Mon Sep 17 00:00:00 2001 From: William Reiske Date: Thu, 12 Feb 2026 10:05:50 -0700 Subject: [PATCH 1/3] feat: add URL-driven search with skeleton loader - Support #search=... hash URLs by redirecting to ?q=... (SEO-friendly) - Early redirect in prevents flash of content - Auto-open search modal with pre-filled query on ?q= page load - Add animated skeleton loader while search index loads - Build lunr index in chunks (non-blocking) to keep UI responsive - Clean up ?q= param from URL when search modal is closed - Aligns with existing SearchAction structured data in baseof.html --- themes/mieweb-docs/assets/js/main.js | 131 +++++++++++++++--- .../mieweb-docs/layouts/_default/baseof.html | 12 ++ 2 files changed, 126 insertions(+), 17 deletions(-) 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'); From 5ab8cc6e47ccc497230ce1627d4db099540e0dff Mon Sep 17 00:00:00 2001 From: William Reiske Date: Thu, 12 Feb 2026 10:10:00 -0700 Subject: [PATCH 2/3] feat: redirect /videos/ paths to external video sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /eh/videos/* → https://videos.enterprise.health/videos/* - /wc/videos/* → https://videos.webch.art/videos/* - 301 permanent redirect preserving path and query string --- functions/_middleware.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/functions/_middleware.ts b/functions/_middleware.ts index ccc6774cc..11ac42632 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 for each brand. +// // e.g. /features/system-administration/data-migration/order-list-import-tool.html // -> /features/system-administration/data-migration/order-list-import-tool +// e.g. /eh/videos/foo -> https://videos.enterprise.health/videos/foo +// e.g. /wc/videos/bar -> https://videos.webch.art/videos/bar + +const VIDEO_HOSTS: Record = { + eh: "https://videos.enterprise.health", + wc: "https://videos.webch.art", +}; export const onRequest: PagesFunction = async (context) => { const url = new URL(context.request.url); + // Redirect //videos/* to the external video site + const videosMatch = url.pathname.match(/^\/(eh|wc)(\/videos\/.*)$/); + if (videosMatch) { + const brand = videosMatch[1]; + const videoPath = videosMatch[2]; // e.g. /videos/foo + const host = VIDEO_HOSTS[brand]; + 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); From d89b9ba4c68b4d7f08e06b5e641629d176a4017b Mon Sep 17 00:00:00 2001 From: William Reiske Date: Thu, 12 Feb 2026 10:10:42 -0700 Subject: [PATCH 3/3] fix: detect brand by hostname instead of URL path prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs.enterprisehealth.com/videos/* → https://videos.enterprise.health/videos/* - docs.webchartnow.com/videos/* → https://videos.webch.art/videos/* --- functions/_middleware.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/functions/_middleware.ts b/functions/_middleware.ts index 11ac42632..04c220bcb 100644 --- a/functions/_middleware.ts +++ b/functions/_middleware.ts @@ -1,27 +1,27 @@ // Cloudflare Pages Middleware // - Strips .html extensions from URLs with a 301 permanent redirect. -// - Redirects /videos/ paths to the external video site for each brand. +// - 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. /eh/videos/foo -> https://videos.enterprise.health/videos/foo -// e.g. /wc/videos/bar -> https://videos.webch.art/videos/bar +// 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 = { - eh: "https://videos.enterprise.health", - wc: "https://videos.webch.art", + "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 - const videosMatch = url.pathname.match(/^\/(eh|wc)(\/videos\/.*)$/); - if (videosMatch) { - const brand = videosMatch[1]; - const videoPath = videosMatch[2]; // e.g. /videos/foo - const host = VIDEO_HOSTS[brand]; - return Response.redirect(`${host}${videoPath}${url.search}`, 301); + // 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")) {