diff --git a/routes/xref/lib/scraper.ts b/routes/xref/lib/scraper.ts index 3e6e280f..499e58d0 100644 --- a/routes/xref/lib/scraper.ts +++ b/routes/xref/lib/scraper.ts @@ -207,9 +207,6 @@ function normalizeTerm(term: string, type: string) { if (type === "method" && !term.endsWith(")")) { return term + "()"; } - if (type === "dfn") { - return term.toLowerCase(); - } return term; } diff --git a/routes/xref/lib/search.ts b/routes/xref/lib/search.ts index 6f03183c..a94f241e 100644 --- a/routes/xref/lib/search.ts +++ b/routes/xref/lib/search.ts @@ -27,6 +27,8 @@ export interface DataEntry { normative: boolean; for?: string[]; htmlProse?: string; + /** The canonical term this entry was indexed under (set on case-insensitive fallback hits). */ + term?: string; } type SpecType = DataEntry["status"] | "draft" | "official"; @@ -120,9 +122,13 @@ function normalizeQuery(query: Query, options: Options) { } function filter(query: Query, store: Store, options: Options) { + const { types = [] } = query; + const isIDL = types.some(t => IDL_TYPES.has(t)); + const allowCaseFallback = !isIDL; + let result: DataEntry[] = []; for (const term of getTermVariations(query)) { - const byTerm = filterByTerm(term, store); + const byTerm = filterByTerm(term, store, allowCaseFallback); const bySpec = filterBySpec(byTerm, query); const byType = filterByType(bySpec, query); const byForContext = filterByForContext(byType, query, options); @@ -155,8 +161,20 @@ function getTermVariations(query: Query) { } } -function filterByTerm(term: Query["term"], store: Store) { - return store.byTerm[term] || []; +function filterByTerm(term: Query["term"], store: Store, allowCaseFallback: boolean) { + if (term == null) return []; + const direct = store.byTerm[term]; + if (direct) return direct; + if (!allowCaseFallback) return []; + // Case-insensitive fallback: tag each entry with its canonical term so + // downstream consumers (e.g. the xref UI) can build correct cite syntax + // instead of using the user's potentially miscased input. + const lower = term.toLowerCase(); + const variants = store.byTermLower.get(lower); + if (!variants) return []; + return variants.flatMap(v => + (store.byTerm[v] || []).map(entry => ({ ...entry, term: v })), + ); } function filterBySpec(data: DataEntry[], query: Query) { diff --git a/routes/xref/lib/store.ts b/routes/xref/lib/store.ts index 7a5f4d25..2cfe5846 100644 --- a/routes/xref/lib/store.ts +++ b/routes/xref/lib/store.ts @@ -17,6 +17,7 @@ export class Store { version = -1; bySpec: { [shortname: string]: DataEntry[] } = {}; byTerm: { [term: string]: DataEntry[] } = {}; + byTermLower: Map = new Map(); specmap: { [group: string]: SpecMapGroup } = {}; /** Headings pre-indexed by spec shortname, then by fragment id. */ headings: HeadingsBySpec = {}; @@ -34,6 +35,7 @@ export class Store { this.specmap = readJson("specmap.json"); this.headings = readJsonOptional("headings.json"); this.specTitleByShortname = buildSpecTitleMap(this.specmap); + this.byTermLower = buildTermLowerIndex(this.byTerm); this.version = Date.now(); } @@ -106,7 +108,6 @@ function readJsonOptional(filename: string) { /** Build a shortname → title map from the specmap for O(1) title lookup. */ function buildSpecTitleMap(specmap: Store["specmap"]): Map { const result = new Map(); - // specmap is { current: { [specid]: entry }, snapshot: { [specid]: entry } } for (const group of Object.values(specmap)) { for (const entry of Object.values(group)) { result.set(entry.shortname, entry.title); @@ -114,3 +115,20 @@ function buildSpecTitleMap(specmap: Store["specmap"]): Map { } return result; } + +/** Build a lowercase → original-case-keys index for case-insensitive lookup. */ +export function buildTermLowerIndex( + byTerm: Store["byTerm"], +): Map { + const index = new Map(); + for (const term of Object.keys(byTerm)) { + const lower = term.toLowerCase(); + const existing = index.get(lower); + if (existing) { + existing.push(term); + } else { + index.set(lower, [term]); + } + } + return index; +} diff --git a/static/xref/script.js b/static/xref/script.js index df8ee2d2..2c2c3bf6 100644 --- a/static/xref/script.js +++ b/static/xref/script.js @@ -84,7 +84,7 @@ const specStatusType = { let metadata; const options = { - fields: ['shortname', 'spec', 'uri', 'type', 'for', 'status'], + fields: ['shortname', 'spec', 'uri', 'type', 'for', 'status', 'term'], spec_type: ['draft', 'snapshot'], all: true, }; @@ -146,17 +146,20 @@ function renderResults(entries, query) { let html = ''; for (const entry of entries) { + // Use the canonical matched term when available (case-insensitive fallback + // hits); otherwise fall back to the user's query term (exact match). + const citeTerm = entry.term || term; const specInfo = metadata.specs[entry.status][entry.spec]; const link = new URL(entry.uri, specInfo.url).href; const title = escapeHTML(specInfo.title); const cite = metadata.types.idl.has(entry.type) - ? howToCiteIDL(term, entry) + ? howToCiteIDL(citeTerm, entry) : metadata.types.markup.has(entry.type) - ? howToCiteMarkup(term, entry) + ? howToCiteMarkup(citeTerm, entry) : metadata.types.css.has(entry.type) || metadata.types.http.has(entry.type) - ? howToCiteAnchor(term, entry) - : howToCiteTerm(term, entry); + ? howToCiteAnchor(citeTerm, entry) + : howToCiteTerm(citeTerm, entry); let row = ` ${title} diff --git a/tests/routes/xref/lib/search.test.js b/tests/routes/xref/lib/search.test.js index 0f6b40ba..5fa511b7 100644 --- a/tests/routes/xref/lib/search.test.js +++ b/tests/routes/xref/lib/search.test.js @@ -2,9 +2,11 @@ import { search as _search, cache, } from "../../../../build/routes/xref/lib/search.js"; +import { buildTermLowerIndex } from "../../../../build/routes/xref/lib/store.js"; import byTerm from "./data-by-term.js"; -const store = { byTerm }; + +const store = { byTerm, byTermLower: buildTermLowerIndex(byTerm) }; /** * @param {import("../../../../routes/xref/lib/search.js").Query} query @@ -137,11 +139,17 @@ describe("xref - search", () => { it("preserves case based on query.types", () => { const baseline = [{ uri: "text.html#TermBaseline" }]; const baselineInterface = [{ uri: "#baseline" }]; + const baselineBoth = [ + { uri: "text.html#TermBaseline" }, + { uri: "#baseline" }, + ]; expect(search({ term: "baseline" })).toEqual(baseline); - expect(search({ term: "baseLine" })).toEqual([]); + // Case-insensitive fallback finds all variants + expect(search({ term: "baseLine" })).toEqual(baselineBoth); expect(search({ term: "baseLine", types: ["dfn"] })).toEqual(baseline); + // IDL is case-sensitive: "baseLine" must not match "Baseline" expect(search({ term: "baseLine", types: ["_IDL_"] })).toEqual([]); expect(search({ term: "Baseline", types: ["dfn"] })).toEqual(baseline); @@ -150,6 +158,27 @@ describe("xref - search", () => { ); }); + it("includes canonical term on case-insensitive fallback hits", () => { + // When the case-insensitive fallback fires, each result entry should + // include the canonical term it was indexed under, so cite syntax can + // use the correct casing instead of the user's input. + const searchWithTerm = query => { + const response = _search([query], store, { fields: ["uri", "term"] }); + return response.result[0][1]; + }; + + // Exact match: no term field (not a fallback hit) + const exact = searchWithTerm({ term: "baseline" }); + expect(exact).toEqual([{ uri: "text.html#TermBaseline", term: undefined }]); + + // Case-insensitive fallback: term field present with canonical casing + const fallback = searchWithTerm({ term: "baseLine" }); + expect(fallback).toEqual([ + { uri: "text.html#TermBaseline", term: "baseline" }, + { uri: "#baseline", term: "Baseline" }, + ]); + }); + it("preserves case for element-type queries", () => { const foreignObject = [ { uri: "embedded.html#elementdef-foreignObject" },