diff --git a/404-suggest.js b/404-suggest.js new file mode 100644 index 0000000000..8d8c6bdd5e --- /dev/null +++ b/404-suggest.js @@ -0,0 +1,739 @@ +/** + * 404-suggest.js + * + * Adds a "Did you mean …?" line and a search/ask escape hatch to Mintlify's + * built-in 404 page. + * + * Why this exists: + * - Mintlify's native 404 page lists AI-suggested pages chosen from page + * *content* semantics, so for a near-miss slug they can be badly wrong: + * /models1 (one character off /models) yields three /inference/* links and + * never offers /models at all. + * - A dead URL one edit away from a real page is a spelling problem, not a + * semantic one, and lexical matching on the path solves it deterministically. + * So we add our answer *above* Mintlify's recommendations rather than + * replacing them — semantic renames still need their list. + * - 404s attract heavy bot traffic and every AI-assistant message costs + * credits, so this never auto-fires the assistant. The primary escape hatch + * is a prefilled search (free); asking the AI is an explicit click. + * + * How it works: + * - Detects the 404 page from Mintlify's documented 404 DOM hooks, with a + * document.title / body-text probe as a fallback in case those change. + * - Fetches /sitemap.xml (auto-generated by Mintlify on every site) for the + * real page list, so nothing is hardcoded and the list cannot drift out of + * sync. Pages absent from docs.json's navigation (~300 of them) are covered + * too. The fetch happens only on a 404, so normal pageviews pay nothing for + * it; a small built-in landing-page list is used if it fails. + * - Ranks pages with a blended Dice-bigram + Levenshtein score on the last + * path segment, plus full-path similarity, segment overlap and a + * nearest-existing-ancestor bonus. Junk suffixes ("models1"), separators + * (_ + %20), case, plurals and wrong depth are normalized away. + * - Injects one panel above Mintlify's recommendations. Idempotent, and + * re-checked on client-side navigation. + * + * Environment: + * - Loaded globally by Mintlify for every docs page (any .js in the content + * dir is included). + * - Wrapped in an IIFE to avoid polluting the global scope. + * - No dependencies. The Kapa.ai widget (see kapa-widget.js) is used only if + * it happens to be loaded; its absence degrades to search-only. + */ + +(function () { + /** Minimum score before we are willing to say "Did you mean X?". */ + var MIN_PRIMARY_SCORE = 0.55; + /** Minimum score for the smaller "or:" alternates. */ + var MIN_ALT_SCORE = 0.45; + /** Marker attribute so we never inject the panel twice. */ + var MARKER = 'data-wandb-404-suggest'; + /** Locale prefixes this site serves; used to normalize and to filter sitemap URLs. */ + var LOCALES = { en: 1, ko: 1, ja: 1, fr: 1 }; + /** Mintlify's documented hook for the 404 view's outer wrapper. */ + var HOOK_CONTAINER = 'not-found-container'; + /** Mintlify's documented hook for its own list of recommended pages. */ + var HOOK_RECS = 'not-found-recommended-pages-list'; + /** Any one of these documented hooks is enough to identify the 404 view. */ + var HOOKS_404 = [HOOK_CONTAINER, HOOK_RECS, 'not-found-title']; + /** + * Landing pages used only if /sitemap.xml cannot be fetched. Deliberately + * tiny — enough to rescue the common "typo in a top-level slug" case. + */ + var FALLBACK_PAGES = [ + 'index', 'models', 'weave', 'inference', 'sandboxes', 'serverless-training', + 'support', 'pricing', 'release-notes', 'get-started', 'hivemind', 'security', + 'models/quickstart', 'models/track', 'models/sweeps', 'models/registry', + 'models/artifacts', 'models/automations', 'models/integrations', 'models/reports', + 'models/app', 'models/ref', 'weave/quickstart', 'weave/guides', 'weave/reference', + 'inference/models', 'platform/hosting', 'platform/mcp-server', 'aria/overview' + ]; + + // ------------------------------------------------------------ normalization + + /** + * Normalize a URL path into comparable lowercase segments. + * Strips leading/trailing slashes, file extensions, the legacy `_print` + * prefix, a locale prefix and a trailing `index`; folds `_`, `+` and `%20` + * to `-`; collapses repeated hyphens. + * @param {string} p + * @returns {string[]} + */ + function normalizePath(p) { + var s = String(p || ''); + try { s = decodeURIComponent(s); } catch (e) { /* keep raw on bad escapes */ } + s = s.split('#')[0].split('?')[0].toLowerCase(); + s = s.replace(/\.(html?|mdx?|php|aspx?)$/, ''); + s = s.replace(/^\/+|\/+$/g, ''); + + var segs = s.split('/'); + var kept = []; + for (var i = 0; i < segs.length; i++) if (segs[i]) kept.push(segs[i]); + if (kept.length && kept[0] === '_print') kept.shift(); + if (kept.length > 1 && LOCALES[kept[0]]) kept.shift(); + if (kept.length > 1 && kept[kept.length - 1] === 'index') kept.pop(); + + var out = []; + for (var j = 0; j < kept.length; j++) { + var seg = kept[j] + .replace(/[_+]|%20/g, '-') + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + if (seg) out.push(seg); + } + return out; + } + + /** + * Strip junk that typos add around a slug: a trailing digit run ("models1"), + * a trailing hyphen, or a leading digit run. Returns the input unchanged if + * stripping would empty it. + * @param {string} seg + * @returns {string} + */ + function dejunk(seg) { + var s = seg.replace(/-+$/, ''); + s = s.replace(/([a-z]{3,})[0-9]+$/, '$1'); + s = s.replace(/^[0-9]+([a-z]{3,})/, '$1'); + return s || seg; + } + + /** + * Crude singular/plural fold so model<->models and sweep<->sweeps collapse. + * @param {string} seg + * @returns {string} + */ + function stem(seg) { + if (/ies$/.test(seg)) return seg.slice(0, -3) + 'y'; + if (/[^s]s$/.test(seg)) return seg.slice(0, -1); + if (/es$/.test(seg) && seg.length > 4) return seg.slice(0, -2); + return seg; + } + + // -------------------------------------------------------------- similarity + + /** + * Set of character bigrams present in a string, as a lookup object. + * @param {string} s + * @returns {Object} + */ + function bigrams(s) { + var o = Object.create(null); + for (var i = 0; i < s.length - 1; i++) o[s.slice(i, i + 2)] = 1; + return o; + } + + /** + * Sorensen-Dice coefficient over character bigrams. Rewards shared + * substrings and tolerates transpositions. + * @param {string} a + * @param {string} b + * @returns {number} 0..1 + */ + function dice(a, b) { + if (a === b) return 1; + if (a.length < 2 || b.length < 2) return 0; + var counts = Object.create(null), i, g, total = 0, hits = 0; + for (i = 0; i < a.length - 1; i++) { g = a.slice(i, i + 2); counts[g] = (counts[g] || 0) + 1; } + for (i = 0; i < b.length - 1; i++) { + g = b.slice(i, i + 2); + if (counts[g] > 0) { counts[g]--; hits++; } + total++; + } + return (2 * hits) / (a.length - 1 + total); + } + + /** + * Levenshtein edit distance, two-row buffer. + * @param {string} a + * @param {string} b + * @returns {number} + */ + function lev(a, b) { + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + var prev = [], cur = [], i, j; + for (j = 0; j <= b.length; j++) prev[j] = j; + for (i = 1; i <= a.length; i++) { + cur[0] = i; + for (j = 1; j <= b.length; j++) { + cur[j] = Math.min( + prev[j] + 1, + cur[j - 1] + 1, + prev[j - 1] + (a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1) + ); + } + for (j = 0; j <= b.length; j++) prev[j] = cur[j]; + } + return prev[b.length]; + } + + /** + * Blended similarity: Dice for shared substrings, normalized Levenshtein + * for edit cost. Neither alone ranks doc slugs well. + * @param {string} a + * @param {string} b + * @returns {number} 0..1 + */ + function strSim(a, b) { + var m = Math.max(a.length, b.length); + var levSim = m === 0 ? 1 : 1 - lev(a, b) / m; + return 0.5 * dice(a, b) + 0.5 * levSim; + } + + /** + * Jaccard overlap of two segment lists — rewards shared path vocabulary + * regardless of order or depth, so /guides/models still matches /models. + * @param {string[]} a + * @param {string[]} b + * @returns {number} 0..1 + */ + function segJaccard(a, b) { + var A = Object.create(null), n = 0, inter = 0, i; + for (i = 0; i < a.length; i++) if (!A[a[i]]) { A[a[i]] = 1; n++; } + var seen = Object.create(null), m = 0; + for (i = 0; i < b.length; i++) { + if (!seen[b[i]]) { seen[b[i]] = 1; m++; if (A[b[i]]) inter++; } + } + var union = n + m - inter; + return union === 0 ? 0 : inter / union; + } + + // ----------------------------------------------------------------- ranking + + /** + * Derive everything about the dead path once, so the per-candidate loop does + * no repeated regex or stemming work. + * @param {string} deadPath + * @returns {Object|null} + */ + function prepareQuery(deadPath) { + var segs = normalizePath(deadPath); + if (!segs.length) return null; + var last = segs[segs.length - 1]; + var dj = dejunk(last); + var segSet = Object.create(null), i, g; + for (i = 0; i < segs.length; i++) segSet[segs[i]] = 1; + var bi = bigrams(last), djBi = bigrams(dj); + for (g in djBi) bi[g] = 1; + return { + segs: segs, joined: segs.join('/'), last: last, + dejunked: dj, stemmed: stem(dj), segSet: segSet, bi: bi + }; + } + + /** + * Pre-normalize a page list into scorable candidates. + * @param {string[]} pages + * @returns {Object[]} + */ + function prepareCandidates(pages) { + var out = []; + for (var i = 0; i < pages.length; i++) { + var segs = normalizePath(pages[i]); + if (!segs.length) continue; + var last = segs[segs.length - 1]; + var segSet = Object.create(null); + for (var j = 0; j < segs.length; j++) segSet[segs[j]] = 1; + out.push({ + // `path` must stay the REAL path, because it becomes the href. About 80 + // pages on this site have underscores in their slug (e.g. + // models/models_quickstart); linking to the normalized, hyphenated form + // would itself 404. Normalized data is used only for scoring. + path: String(pages[i]).replace(/^\/+|\/+$/g, ''), + segs: segs, joined: segs.join('/'), last: last, + stemmedLast: stem(last), lastBi: bigrams(last), segSet: segSet + }); + } + return out; + } + + /** + * Cheap gate ahead of the O(n*m) edit-distance work: only score candidates + * that share a bigram in the last segment or share a whole path segment. + * Also stops junk/bot paths (/wp-admin, /.env) from producing suggestions. + * @param {Object} q + * @param {Object} cand + * @returns {boolean} + */ + function passesPrefilter(q, cand) { + var g; + for (g in q.bi) if (cand.lastBi[g]) return true; + for (g in q.segSet) if (cand.segSet[g]) return true; + if (q.last.length < 3 || cand.last.length < 3) { + return Math.abs(q.last.length - cand.last.length) <= 2; + } + return false; + } + + /** + * Score one candidate against the dead path. Weighted toward the last + * segment, which is what a user actually mistypes or half-remembers. + * @param {Object} q + * @param {Object} cand + * @returns {number} + */ + function scoreCandidate(q, cand) { + var cSegs = cand.segs, qSegs = q.segs, i; + + // Best of raw / de-junked / stemmed comparison on the last segment. + var last = Math.max( + strSim(q.last, cand.last), + strSim(q.dejunked, cand.last) - 0.01, + strSim(q.stemmed, cand.stemmedLast) - 0.02 + ); + if (q.last === cand.last) last = 1; + else if (q.dejunked === cand.last || q.stemmed === cand.stemmedLast) { + last = Math.max(last, 0.97); + } + + var full = strSim(q.joined, cand.joined); + var overlap = segJaccard(qSegs, cSegs); + + // Does the mistyped slug match ANY segment of the candidate? Catches both + // "extra segment" (/guides/models -> models) and "missing segment" cases. + var anywhere = 0; + for (i = 0; i < cSegs.length; i++) { + var s = strSim(q.dejunked, cSegs[i]); + if (s > anywhere) anywhere = s; + } + + var score = 0.52 * last + 0.20 * full + 0.16 * overlap + 0.12 * anywhere; + + // With an exact last-segment match, prefer the shallowest page: /models1 + // should land on /models, not /models/track/log/log-models. + if (last >= 0.97) score += 0.06 / cSegs.length; + + // Typo'd paths keep their depth, so a large depth gap is weak evidence. + score -= 0.012 * Math.abs(qSegs.length - cSegs.length); + + // Agreeing leading segments are a strong structural hint. + var common = 0; + while (common < qSegs.length && common < cSegs.length && qSegs[common] === cSegs[common]) common++; + score += 0.05 * (common / Math.max(qSegs.length, cSegs.length)); + + // Nearest existing ancestor: the user asked for something under a section + // that does exist, so that section page is always defensible. Kept modest + // so a good sibling match still wins. + if (common === cSegs.length && cSegs.length < qSegs.length) { + score += 0.16 * (cSegs.length / qSegs.length); + } + + return score; + } + + /** + * Rank pages against a dead path, best first. + * @param {string} deadPath + * @param {Object[]} candidates - output of prepareCandidates() + * @param {number} limit + * @returns {{path: string, score: number}[]} + */ + function rank(deadPath, candidates, limit) { + var q = prepareQuery(deadPath); + if (!q) return []; + var out = []; + for (var i = 0; i < candidates.length; i++) { + if (!passesPrefilter(q, candidates[i])) continue; + var s = scoreCandidate(q, candidates[i]); + if (s > 0.3) out.push({ path: candidates[i].path, score: s }); + } + out.sort(function (a, b) { + return b.score - a.score || a.path.length - b.path.length; + }); + return out.slice(0, limit || 4); + } + + // ------------------------------------------------------------- page list + + var pageListPromise = null; + + /** + * Fetch and parse the site's page list from /sitemap.xml, which Mintlify + * generates automatically. Cached for the lifetime of the document. + * Resolves to the fallback list on any failure. + * @returns {Promise} + */ + function getPageList() { + if (pageListPromise) return pageListPromise; + + if (typeof fetch !== 'function') { + pageListPromise = Promise.resolve(FALLBACK_PAGES); + return pageListPromise; + } + + pageListPromise = fetch('/sitemap.xml', { credentials: 'same-origin' }) + .then(function (res) { + if (!res.ok) throw new Error('sitemap ' + res.status); + return res.text(); + }) + .then(function (xml) { + var paths = [], seen = Object.create(null); + // Parse values without a DOM parser dependency. + var re = /\s*([^<\s]+)\s*<\/loc>/g, m; + while ((m = re.exec(xml)) !== null) { + var p = m[1].replace(/^https?:\/\/[^/]+/, ''); + var segs = p.replace(/^\/+|\/+$/g, '').split('/'); + // Keep only the default (en) locale; ko/ja/fr are separate trees and + // would otherwise triple the candidate set with near-duplicates. + if (segs.length && LOCALES[segs[0]] && segs[0] !== 'en') continue; + // Keep the REAL path for the href; use the normalized form only as a + // dedupe key (underscores in real slugs must survive — see + // prepareCandidates). + var real = p.replace(/^\/+|\/+$/g, ''); + var key = normalizePath(p).join('/'); + if (!key || seen[key]) continue; + seen[key] = 1; + paths.push(real || 'index'); + } + return paths.length > 20 ? paths : FALLBACK_PAGES; + }) + .catch(function () { return FALLBACK_PAGES; }); + + return pageListPromise; + } + + // ------------------------------------------------------------- DOM hooks + + /** + * Find an element by one of Mintlify's documented DOM hook names, trying + * every form the hook could actually take in the rendered markup: class + * (`.name`), bare element (`name`), then id (`#name`). + * + * Why all three: Mintlify documents the 404 hooks under "Element selectors", + * which it defines as targeted "with no `#` or `.` prefix" — read literally, + * `not-found-container` would be a tag name. But names from that same + * documented list demonstrably render as *classes*: this repo's own + * production CSS matches `textarea.chat-assistant-input` and + * `button.chat-assistant-send-button`, written from live-DOM inspection. The + * `not-found-*` hooks have never been observed in a live DOM either way, and + * guessing wrong on the insertion anchor would silently downgrade the panel's + * placement, so cover every form instead. An element name that matches no + * real tag is harmless to `querySelector`. + * + * Each form gets its own query rather than one comma-separated list, because + * `querySelector` returns the first match in DOCUMENT order, not selector + * order — a page containing more than one form could otherwise resolve to the + * less likely of the two. + * + * @param {ParentNode} root element (or document) to search within + * @param {string[]} names documented hook names, most preferred first + * @returns {Element|null} + */ + function queryHook(root, names) { + if (!root || typeof root.querySelector !== 'function') return null; + var forms = ['.', '', '#']; + for (var f = 0; f < forms.length; f++) { + for (var n = 0; n < names.length; n++) { + var el = root.querySelector(forms[f] + names[n]); + if (el) return el; + } + } + return null; + } + + /** + * The element the panel is injected into: Mintlify's 404 container when it + * can be found, else the page's main content region. Shared by render() and + * run() so the "already injected?" check always looks at the same node the + * panel would be added to. + * @returns {Element|null} + */ + function findHost() { + return queryHook(document, [HOOK_CONTAINER]) || + document.querySelector('#content-area') || + document.querySelector('main'); + } + + // ------------------------------------------------------------- detection + + /** + * Whether the current page is Mintlify's 404 page. + * Primary signal: Mintlify's documented 404 DOM hooks. Those are described + * as "subject to change", so a title / visible-text probe backs them up. + * @returns {boolean} + */ + function isNotFoundPage() { + // Preferred signal: Mintlify's documented 404 hooks, in any form. + if (queryHook(document, HOOKS_404)) return true; + + // Fallback, for if those hook names change. It must not fire on real docs + // pages that merely *discuss* 404s — this site ships + // support/inference/articles/api-error-code-404-model-not-found, whose + // title contains "404". Every real content page renders an `#page-title` + //

; the 404 view does not. So require that to be absent before + // trusting any text heuristic. + if (document.querySelector('#page-title')) return false; + + if (/^\s*(404|page not found)\b/i.test(document.title || '')) return true; + + var main = document.querySelector('#content-area, #body-content, main'); + if (main) { + var txt = (main.innerText || '').slice(0, 400); + if (/page not found|couldn'?t find that page/i.test(txt)) return true; + } + return false; + } + + // -------------------------------------------------------------- escape hatches + + /** + * Open Mintlify's search modal and prefill it. Search is free, so this is + * the primary escape hatch. Clicks the documented `#search-bar-entry` + * trigger, then polls briefly for `#search-input` to appear before typing + * into it (the modal mounts asynchronously). + * @param {string} query + */ + function openSearch(query) { + var entry = document.querySelector('#search-bar-entry') || + document.querySelector('#search-bar-entry-mobile'); + if (entry) entry.click(); + + var tries = 0; + var timer = setInterval(function () { + var input = document.querySelector('#search-input'); + if (input) { + clearInterval(timer); + input.focus(); + // Use the native setter so React's controlled input sees the change. + var proto = Object.getPrototypeOf(input); + var desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) desc.set.call(input, query); + else input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } else if (++tries > 25) { + clearInterval(timer); + } + }, 80); + } + + /** + * Open the Kapa.ai assistant with the query prefilled but NOT submitted. + * Assistant messages cost credits and 404s get heavy bot traffic, so this + * only ever runs from a real click, and never auto-sends. + * @param {string} query + * @returns {boolean} whether the widget was available + */ + function openAsk(query) { + if (!window.Kapa || typeof window.Kapa.open !== 'function') return false; + try { + window.Kapa.open({ query: query, submit: false }); + return true; + } catch (e) { + return false; + } + } + + /** @returns {boolean} whether the Kapa widget looks available. */ + function hasAsk() { + return !!(window.Kapa && typeof window.Kapa.open === 'function'); + } + + // ------------------------------------------------------------------ render + + /** + * Human-readable label for a page path, for the button/link text. + * @param {string} path + * @returns {string} + */ + function pretty(path) { + return '/' + path; + } + + /** + * Search query derived from the dead slug: the last segment, de-junked, with + * hyphens turned back into spaces. + * @param {string} deadPath + * @returns {string} + */ + function queryFromPath(deadPath) { + var segs = normalizePath(deadPath); + if (!segs.length) return ''; + return dejunk(segs[segs.length - 1]).replace(/-/g, ' '); + } + + /** + * Build and insert the suggestion panel. + * @param {{path: string, score: number}[]} results + * @param {string} deadPath + */ + function render(results, deadPath) { + var host = findHost(); + if (!host || host.querySelector('[' + MARKER + ']')) return; + + var query = queryFromPath(deadPath); + var primary = results[0] && results[0].score >= MIN_PRIMARY_SCORE ? results[0] : null; + var alts = []; + for (var i = 1; i < results.length && alts.length < 2; i++) { + if (results[i].score >= MIN_ALT_SCORE) alts.push(results[i]); + } + + var panel = document.createElement('div'); + panel.setAttribute(MARKER, '1'); + panel.style.cssText = [ + 'margin:1.25rem 0', 'padding:1rem 1.15rem', 'border-radius:0.75rem', + 'border:1px solid rgba(252,188,50,0.45)', 'background:rgba(252,188,50,0.08)', + 'font-size:0.95rem', 'line-height:1.5' + ].join(';'); + + if (primary) { + var lead = document.createElement('div'); + lead.style.cssText = 'margin-bottom:0.5rem'; + lead.appendChild(document.createTextNode('Did you mean ')); + + var link = document.createElement('a'); + link.href = '/' + primary.path; + link.textContent = pretty(primary.path); + link.style.cssText = 'font-weight:600;text-decoration:underline'; + lead.appendChild(link); + lead.appendChild(document.createTextNode('?')); + panel.appendChild(lead); + + if (alts.length) { + var altLine = document.createElement('div'); + altLine.style.cssText = 'margin-bottom:0.65rem;opacity:0.75;font-size:0.875rem'; + altLine.appendChild(document.createTextNode('Or: ')); + for (var j = 0; j < alts.length; j++) { + if (j) altLine.appendChild(document.createTextNode(' · ')); + var a = document.createElement('a'); + a.href = '/' + alts[j].path; + a.textContent = pretty(alts[j].path); + a.style.cssText = 'text-decoration:underline'; + altLine.appendChild(a); + } + panel.appendChild(altLine); + } + } else { + var none = document.createElement('div'); + none.style.cssText = 'margin-bottom:0.65rem'; + none.textContent = 'No close match for this URL.'; + panel.appendChild(none); + } + + // Escape hatches. Search first and styled as the primary action, because + // it is free; asking the assistant is a secondary, explicit choice. + var actions = document.createElement('div'); + actions.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center'; + + if (query) { + var searchBtn = document.createElement('button'); + searchBtn.type = 'button'; + searchBtn.textContent = 'Search docs for “' + query + '”'; + searchBtn.style.cssText = [ + 'cursor:pointer', 'padding:0.4rem 0.75rem', 'border-radius:0.5rem', + 'border:1px solid currentColor', 'background:transparent', + 'font:inherit', 'font-size:0.875rem' + ].join(';'); + searchBtn.addEventListener('click', function () { openSearch(query); }); + actions.appendChild(searchBtn); + + if (hasAsk()) { + var askBtn = document.createElement('button'); + askBtn.type = 'button'; + askBtn.textContent = 'Ask AI instead'; + askBtn.style.cssText = [ + 'cursor:pointer', 'padding:0.4rem 0.75rem', 'border-radius:0.5rem', + 'border:1px solid transparent', 'background:transparent', + 'font:inherit', 'font-size:0.875rem', 'opacity:0.75', + 'text-decoration:underline' + ].join(';'); + askBtn.addEventListener('click', function () { + if (!openAsk('Where can I find documentation about ' + query + '?')) openSearch(query); + }); + actions.appendChild(askBtn); + } + } + if (actions.childNodes.length) panel.appendChild(actions); + + // Sit directly above Mintlify's own recommendation list when we can find + // it, so our answer is read first. If no form of that hook resolves, lead + // the host instead of trailing it — first child, never appended — so the + // panel still comes before Mintlify's list on the page. + var recs = queryHook(host, [HOOK_RECS]); + var anchor = recs ? (recs.closest('div') || recs) : null; + if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(panel, anchor); + else host.insertBefore(panel, host.firstChild); + + // The repo already loads GA4/GTM, so record that the panel rendered and + // what it proposed. Makes it possible to tell whether this helps at all. + if (window.dataLayer && typeof window.dataLayer.push === 'function') { + window.dataLayer.push({ + event: 'docs_404_suggestion_shown', + dead_path: deadPath, + suggested_path: primary ? '/' + primary.path : null, + suggestion_score: primary ? Math.round(primary.score * 100) / 100 : null + }); + } + } + + // -------------------------------------------------------------------- boot + + var lastHandledPath = null; + + /** Detect a 404, resolve the page list, rank, and render. */ + function run() { + var deadPath = window.location.pathname; + if (deadPath === lastHandledPath) return; + if (!isNotFoundPage()) return; + var host = findHost(); + if (host && host.querySelector('[' + MARKER + ']')) return; + + lastHandledPath = deadPath; + getPageList().then(function (pages) { + // Bail out if client-side navigation moved on while the sitemap loaded. + if (window.location.pathname !== deadPath || !isNotFoundPage()) return; + var results = rank(deadPath, prepareCandidates(pages), 4); + render(results, deadPath); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } + + // The 404 view mounts after hydration, and Mintlify's AI recommendations + // stream in later still, so re-check on a short schedule and on DOM changes. + [300, 800, 1500, 3000].forEach(function (ms) { setTimeout(run, ms); }); + + function startObserving() { + if (!document.body) return; + var scheduled = null; + var observer = new MutationObserver(function () { + if (scheduled) return; + scheduled = setTimeout(function () { scheduled = null; run(); }, 150); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startObserving); + } else { + startObserving(); + } + + // Client-side navigation to another dead URL should re-run the check. + window.addEventListener('popstate', function () { lastHandledPath = null; run(); }); + window.addEventListener('pageshow', function () { run(); }); +})();