From 9d2724e92a5a09043c34b84a9106a776122d7134 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 18:47:32 +0000 Subject: [PATCH 1/2] Add client-side "Did you mean" suggestion to the 404 page Mintlify's native 404 page suggests pages using content semantics, which handles renames well but misses simple typos: /models1 currently suggests three /inference/* pages and never /models. This adds 404-suggest.js, which lexically matches the dead path against the site's own /sitemap.xml (fetched only after a 404 is detected) and inserts a "Did you mean /models?" line above Mintlify's suggestion list. The two mechanisms are complementary, so Mintlify's list is kept rather than replaced. The panel also offers a prefilled docs search as the primary action (search is free) and a click-only "Ask AI" affordance that opens the existing Kapa widget with submit:false, so 404 bot traffic can never auto-fire a paid assistant message. No build step, no dependencies, no hardcoded page list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Md238DeoXErHLL2jq96xU6 --- 404-suggest.js | 683 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 404-suggest.js diff --git a/404-suggest.js b/404-suggest.js new file mode 100644 index 0000000000..af2afa9e82 --- /dev/null +++ b/404-suggest.js @@ -0,0 +1,683 @@ +/** + * 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 }; + /** + * 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; + } + + // ------------------------------------------------------------- detection + + /** + * Whether the current page is Mintlify's 404 page. + * Primary signal: Mintlify's documented 404 class 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 class hooks. + if (document.querySelector('.not-found-container, .not-found-recommended-pages-list, .not-found-title')) { + return true; + } + + // Fallback, for if those class 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 = document.querySelector('.not-found-container') || + document.querySelector('#content-area') || + document.querySelector('main'); + 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; otherwise lead the container. + var recs = host.querySelector('.not-found-recommended-pages-list'); + 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 = document.querySelector('.not-found-container, #content-area, main'); + 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(); }); +})(); From 69c61635183a73ab729540fa9e8492a8ddafaba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:03:51 +0000 Subject: [PATCH 2/2] Make the 404 DOM hook lookups form-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mintlify documents the 404 hooks (not-found-container, not-found-recommended-pages-list, not-found-title) under "Element selectors", defined as targeted "with no # or . prefix" — read literally, they are tag names. But names from that same documented list demonstrably render as classes: this repo's own scripts/css-minify/buttons.css matches textarea.chat-assistant-input and button.chat-assistant-send-button, with comments written from live-DOM inspection. Which form the 404 view uses has not been observed, and cannot be from here. Detection tolerated the ambiguity already (the #page-title-absent gate plus a title/text probe). The insertion anchor did not: a single host.querySelector('.not-found-recommended-pages-list') with no fallback. If the class form is wrong, the panel is still injected — into #content-area — but no longer sits above Mintlify's recommendation list, so the PR's headline behaviour ("our answer is read first") is lost silently while everything still looks fine. So look every documented hook up in all three forms via one queryHook() helper, class first, then bare element, then id. Separate queries rather than one comma-separated list, because querySelector returns the first match in DOCUMENT order, not selector order, and could otherwise pick the less likely form. render() and run() now share findHost(), so the "already injected?" check and the injection target cannot diverge. The no-anchor path still leads the host (insertBefore firstChild) rather than appending, so placement degrades to "above the list" rather than "below it". Behaviour is unchanged if the class form was right all along. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Md238DeoXErHLL2jq96xU6 --- 404-suggest.js | 80 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/404-suggest.js b/404-suggest.js index af2afa9e82..8d8c6bdd5e 100644 --- a/404-suggest.js +++ b/404-suggest.js @@ -49,6 +49,12 @@ 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. @@ -408,21 +414,71 @@ 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 class hooks. Those are described + * 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 class hooks. - if (document.querySelector('.not-found-container, .not-found-recommended-pages-list, .not-found-title')) { - return true; - } + // Preferred signal: Mintlify's documented 404 hooks, in any form. + if (queryHook(document, HOOKS_404)) return true; - // Fallback, for if those class names change. It must not fire on real docs + // 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` @@ -523,9 +579,7 @@ * @param {string} deadPath */ function render(results, deadPath) { - var host = document.querySelector('.not-found-container') || - document.querySelector('#content-area') || - document.querySelector('main'); + var host = findHost(); if (!host || host.querySelector('[' + MARKER + ']')) return; var query = queryFromPath(deadPath); @@ -613,8 +667,10 @@ 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; otherwise lead the container. - var recs = host.querySelector('.not-found-recommended-pages-list'); + // 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); @@ -640,7 +696,7 @@ var deadPath = window.location.pathname; if (deadPath === lastHandledPath) return; if (!isNotFoundPage()) return; - var host = document.querySelector('.not-found-container, #content-area, main'); + var host = findHost(); if (host && host.querySelector('[' + MARKER + ']')) return; lastHandledPath = deadPath;