Skip to content
3 changes: 0 additions & 3 deletions routes/xref/lib/scraper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
24 changes: 21 additions & 3 deletions routes/xref/lib/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 19 additions & 1 deletion routes/xref/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class Store {
version = -1;
bySpec: { [shortname: string]: DataEntry[] } = {};
byTerm: { [term: string]: DataEntry[] } = {};
byTermLower: Map<string, string[]> = new Map();
specmap: { [group: string]: SpecMapGroup } = {};
/** Headings pre-indexed by spec shortname, then by fragment id. */
headings: HeadingsBySpec = {};
Expand All @@ -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();
}

Expand Down Expand Up @@ -106,11 +108,27 @@ function readJsonOptional(filename: string) {
/** Build a shortname → title map from the specmap for O(1) title lookup. */
function buildSpecTitleMap(specmap: Store["specmap"]): Map<string, string> {
const result = new Map<string, string>();
// 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);
}
}
return result;
}

/** Build a lowercase → original-case-keys index for case-insensitive lookup. */
export function buildTermLowerIndex(
byTerm: Store["byTerm"],
): Map<string, string[]> {
const index = new Map<string, string[]>();
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;
}
13 changes: 8 additions & 5 deletions static/xref/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 = `
<tr>
<td><a href="${link}">${title}</a></td>
Expand Down
33 changes: 31 additions & 2 deletions tests/routes/xref/lib/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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" },
Expand Down