Skip to content

Commit 44e9c08

Browse files
fix(xref): don't let a for-scoped lowercase term shadow a canonical concept (#533)
#508 preserved original dfn term case, splitting "URL" and "url" into separate keys in the store. A bare [=URL=] lowercases to "url", which matched only the for-scoped basic URL parser variable and got filtered out by the no-for context check, never falling back to the canonical URL concept — breaking the link on every spec. filter() now tries the exact-case bucket first, then falls back to the case-insensitive variants when it yields nothing after filtering.
1 parent 2e1af5e commit 44e9c08

3 files changed

Lines changed: 92 additions & 39 deletions

File tree

routes/xref/lib/search.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,18 +126,22 @@ function filter(query: Query, store: Store, options: Options) {
126126
const isIDL = types.some(t => IDL_TYPES.has(t));
127127
const allowCaseFallback = !isIDL;
128128

129-
let result: DataEntry[] = [];
130129
for (const term of getTermVariations(query)) {
131-
const byTerm = filterByTerm(term, store, allowCaseFallback);
132-
const bySpec = filterBySpec(byTerm, query);
133-
const byType = filterByType(bySpec, query);
134-
const byForContext = filterByForContext(byType, query, options);
135-
if (byForContext.length) {
136-
result = byForContext;
137-
break;
130+
// Try the exact-case bucket first (so `[=baseline=]` and `{{Baseline}}`
131+
// stay distinct), then a case-insensitive fallback. The fallback is
132+
// essential when an exact-case bucket exists but every entry is filtered
133+
// out downstream — e.g. term "url" matches only the for-scoped basic URL
134+
// parser variable, while the canonical URL concept lives under "URL".
135+
for (const byTerm of termCandidates(term, store, allowCaseFallback)) {
136+
const bySpec = filterBySpec(byTerm, query);
137+
const byType = filterByType(bySpec, query);
138+
const byForContext = filterByForContext(byType, query, options);
139+
if (byForContext.length) {
140+
return byForContext;
141+
}
138142
}
139143
}
140-
return result;
144+
return [];
141145
}
142146

143147
function getTermVariations(query: Query) {
@@ -161,20 +165,31 @@ function getTermVariations(query: Query) {
161165
}
162166
}
163167

164-
function filterByTerm(term: Query["term"], store: Store, allowCaseFallback: boolean) {
165-
if (term == null) return [];
168+
/**
169+
* Yields candidate entry sets for a term in priority order: the exact-case
170+
* bucket, then (non-IDL only) the case-insensitive fallback union, each
171+
* fallback entry tagged with its canonical term. Two separate sets so the
172+
* caller can prefer an exact match that survives filtering and reach for the
173+
* fallback only when it doesn't.
174+
*/
175+
function* termCandidates(
176+
term: Query["term"],
177+
store: Store,
178+
allowCaseFallback: boolean,
179+
) {
180+
if (term == null) return;
166181
const direct = store.byTerm[term];
167-
if (direct) return direct;
168-
if (!allowCaseFallback) return [];
169-
// Case-insensitive fallback: tag each entry with its canonical term so
170-
// downstream consumers (e.g. the xref UI) can build correct cite syntax
171-
// instead of using the user's potentially miscased input.
182+
if (direct) yield direct;
183+
if (!allowCaseFallback) return;
172184
const lower = term.toLowerCase();
173185
const variants = store.byTermLower.get(lower);
174-
if (!variants) return [];
175-
return variants.flatMap(v =>
176-
(store.byTerm[v] || []).map(entry => ({ ...entry, term: v })),
177-
);
186+
if (!variants) return;
187+
const fallback = variants
188+
.filter(v => v !== term) // exclude the exact bucket already yielded above
189+
.flatMap(v =>
190+
(store.byTerm[v] || []).map(entry => ({ ...entry, term: v })),
191+
);
192+
if (fallback.length) yield fallback;
178193
}
179194

180195
function filterBySpec(data: DataEntry[], query: Query) {

tests/routes/xref/lib/data-by-term.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,29 @@ export default {
290290
normative: false,
291291
},
292292
],
293+
// The canonical URL concept is indexed under "URL" (case preserved), while a
294+
// distinct for-scoped "url" (the basic URL parser's local variable) exists
295+
// under the lowercase key. A lowercased query ("url") must still resolve the
296+
// canonical concept and not be shadowed by the for-scoped lowercase entry.
297+
URL: [
298+
{
299+
type: "dfn",
300+
spec: "url",
301+
shortname: "url",
302+
status: "current",
303+
uri: "#concept-url",
304+
normative: true,
305+
},
306+
],
307+
url: [
308+
{
309+
type: "dfn",
310+
spec: "url",
311+
shortname: "url",
312+
status: "current",
313+
uri: "#basic-url-parser-url",
314+
normative: true,
315+
for: ["basic URL parser"],
316+
},
317+
],
293318
};

tests/routes/xref/lib/search.test.js

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ describe("xref - search", () => {
2121
beforeEach(() => cache.clear());
2222

2323
describe("options", () => {
24-
2524
describe("query", () => {
2625
it("adds query back to response if requested", () => {
2726
expect(_search([], store, { query: true })).toEqual({
@@ -83,15 +82,15 @@ describe("xref - search", () => {
8382
});
8483

8584
it("uses filter@for if options.all is set and for is provided", () => {
86-
expect(
87-
search({ term: "event", for: "Window" }, { all: true }),
88-
).toEqual([resultsAll[1]]);
85+
expect(search({ term: "event", for: "Window" }, { all: true })).toEqual(
86+
[resultsAll[1]],
87+
);
8988
});
9089

9190
it("uses filter@for if options.all is not set", () => {
92-
expect(
93-
search({ term: "event", for: "Window" }, { all: true }),
94-
).toEqual([resultsAll[1]]);
91+
expect(search({ term: "event", for: "Window" })).toEqual([
92+
resultsAll[1],
93+
]);
9594
});
9695
});
9796
});
@@ -169,7 +168,9 @@ describe("xref - search", () => {
169168

170169
// Exact match: no term field (not a fallback hit)
171170
const exact = searchWithTerm({ term: "baseline" });
172-
expect(exact).toEqual([{ uri: "text.html#TermBaseline", term: undefined }]);
171+
expect(exact).toEqual([
172+
{ uri: "text.html#TermBaseline", term: undefined },
173+
]);
173174

174175
// Case-insensitive fallback: term field present with canonical casing
175176
const fallback = searchWithTerm({ term: "baseLine" });
@@ -180,15 +181,13 @@ describe("xref - search", () => {
180181
});
181182

182183
it("preserves case for element-type queries", () => {
183-
const foreignObject = [
184-
{ uri: "embedded.html#elementdef-foreignObject" },
185-
];
186-
expect(
187-
search({ term: "foreignObject", types: ["element"] }),
188-
).toEqual(foreignObject);
189-
expect(
190-
search({ term: "foreignObject", types: ["_CONCEPT_"] }),
191-
).toEqual(foreignObject);
184+
const foreignObject = [{ uri: "embedded.html#elementdef-foreignObject" }];
185+
expect(search({ term: "foreignObject", types: ["element"] })).toEqual(
186+
foreignObject,
187+
);
188+
expect(search({ term: "foreignObject", types: ["_CONCEPT_"] })).toEqual(
189+
foreignObject,
190+
);
192191
expect(
193192
search({ term: "foreignObject", types: ["element", "dfn"] }),
194193
).toEqual(foreignObject);
@@ -200,9 +199,23 @@ describe("xref - search", () => {
200199
expect(search({ term: "clipPath", types: ["_CONCEPT_"] })).toEqual(
201200
clipPath,
202201
);
202+
expect(search({ term: "clipPath", types: ["element", "dfn"] })).toEqual(
203+
clipPath,
204+
);
205+
});
206+
207+
it("resolves a canonical concept shadowed by a for-scoped lowercase term", () => {
208+
// Regression: [=URL=] sends term "url" (concepts are lowercased). The
209+
// canonical URL concept is indexed under "URL"; a distinct for-scoped
210+
// "url" (basic URL parser local variable) exists under the lowercase key.
211+
// The lowercase direct hit must not shadow the canonical concept...
212+
expect(search({ term: "url", types: ["_CONCEPT_"] })).toEqual([
213+
{ uri: "#concept-url" },
214+
]);
215+
// ...but the for-scoped entry still wins when its for is given.
203216
expect(
204-
search({ term: "clipPath", types: ["element", "dfn"] }),
205-
).toEqual(clipPath);
217+
search({ term: "url", types: ["_CONCEPT_"], for: "basic URL parser" }),
218+
).toEqual([{ uri: "#basic-url-parser-url" }]);
206219
});
207220
});
208221

0 commit comments

Comments
 (0)