Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion functions/_middleware.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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);
Expand Down
131 changes: 114 additions & 17 deletions themes/mieweb-docs/assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `
<div class="flex flex-col gap-1 rounded-lg px-3 py-3 animate-pulse">
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-muted flex-shrink-0"></div>
<div class="h-4 rounded bg-muted" style="width: ${60 + Math.random() * 30}%"></div>
</div>
<div class="h-3 rounded bg-muted/60 mt-1" style="width: ${70 + Math.random() * 25}%"></div>
</div>`;
}
searchResults.innerHTML = html;
}

function openSearchModal() {
searchModal?.classList.remove("hidden");
Expand All @@ -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);
Expand Down Expand Up @@ -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 = `
<div class="px-2 py-8 text-center text-sm text-muted-foreground">
<p>Start typing to search...</p>
</div>
`;
// Perform search (async — shows skeleton while index loads)
async function performSearch(query) {
if (!query || !query.trim()) {
if (searchResults) {
searchResults.innerHTML = `
<div class="px-2 py-8 text-center text-sm text-muted-foreground">
<p>Start typing to search...</p>
</div>
`;
}
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;

Expand Down Expand Up @@ -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
// ============================================
Expand Down
12 changes: 12 additions & 0 deletions themes/mieweb-docs/layouts/_default/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading