diff --git a/routes/api/baseline/update.ts b/routes/api/baseline/update.ts index 887ba97a..f2029987 100644 --- a/routes/api/baseline/update.ts +++ b/routes/api/baseline/update.ts @@ -6,7 +6,7 @@ import { BackgroundTaskQueue } from "../../../utils/background-task-queue.js"; import { store } from "./lib/store-init.js"; const workerFile = path.join(import.meta.dirname, "update.worker.js"); -const taskQueue = new BackgroundTaskQueue( +const taskQueue = new BackgroundTaskQueue( workerFile, "baseline_update", ); diff --git a/routes/xref/index.ts b/routes/xref/index.ts index 9bc774a2..e605f35e 100644 --- a/routes/xref/index.ts +++ b/routes/xref/index.ts @@ -12,6 +12,7 @@ import searchRouteGet from "./search.get.js"; import searchRoutePost from "./search.post.js"; import headingsRoutePost from "./headings.post.js"; import metaRoute from "./meta.js"; +import termsRoute from "./terms.get.js"; import updateRoute from "./update.js"; import { search, Options, Query } from "./lib/search.js"; @@ -26,6 +27,7 @@ xref .options("/search", cors({ methods: ["POST", "GET"], maxAge: ms("1day") })) .get("/search", cors(), searchRouteGet) .post("/search", express.json({ limit: "2mb" }), cors(), searchRoutePost); +xref.get("/meta/terms/search", cors(), termsRoute); xref.get("/meta{/:field}", cors(), metaRoute); xref .options("/search/headings", cors({ methods: ["POST"], maxAge: ms("1day") })) diff --git a/routes/xref/terms.get.ts b/routes/xref/terms.get.ts new file mode 100644 index 00000000..96859aa3 --- /dev/null +++ b/routes/xref/terms.get.ts @@ -0,0 +1,79 @@ +import { Request, Response } from "express"; + +import { seconds } from "../../utils/misc.js"; +import { store } from "./lib/store-init.js"; + +interface TermEntry { + lower: string; + original: string; +} + +let termsIndex: TermEntry[] = []; +let termsVersion = -1; + +function getTermsIndex(): TermEntry[] { + if (termsVersion < store.version) { + const keys = Object.keys(store.byTerm).filter(k => k !== ""); + termsIndex = keys + .map(k => ({ lower: k.toLowerCase(), original: k })) + .sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0)); + termsVersion = store.version; + } + return termsIndex; +} + +function bisectLeft(arr: TermEntry[], target: string): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid].lower < target) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function searchTerms(query: string, limit: number): string[] { + const index = getTermsIndex(); + const q = query.toLowerCase(); + const results: string[] = []; + const seen = new Set(); + + const start = bisectLeft(index, q); + for (let i = start; i < index.length && results.length < limit; i++) { + if (!index[i].lower.startsWith(q)) break; + seen.add(index[i].lower); + results.push(index[i].original); + } + + if (results.length < limit) { + for (let i = 0; i < index.length && results.length < limit; i++) { + if (seen.has(index[i].lower)) continue; + if (index[i].lower.includes(q)) { + results.push(index[i].original); + } + } + } + + return results; +} + +interface QueryParams { + q?: string | string[]; + limit?: string; +} +type IRequest = Request; + +export default function route(req: IRequest, res: Response) { + const q = Array.isArray(req.query.q) ? req.query.q[0] : req.query.q; + if (!q || q.length < 2) { + res.status(400).json({ error: "query must be at least 2 characters" }); + return; + } + + const limit = Math.min(Math.max(parseInt(req.query.limit || "15", 10) || 15, 1), 50); + const results = searchTerms(q, limit); + + res.set("Cache-Control", `max-age=${seconds("24h")}`); + res.json(results); +} diff --git a/static/xref/script.js b/static/xref/script.js index df8ee2d2..2acee928 100644 --- a/static/xref/script.js +++ b/static/xref/script.js @@ -235,9 +235,9 @@ async function ready() { }; const metaURL = new URL( - `${form.action}/meta?fields=types,specs,terms,version`, + `${form.action}/meta?fields=types,specs,version`, ).href; - const { specs, types, terms, version } = await fetch(metaURL).then(res => + const { specs, types, version } = await fetch(metaURL).then(res => res.json(), ); @@ -260,13 +260,16 @@ async function ready() { const allTypes = [].concat(...Object.values(types)).sort(); updateInput(form.types, allTypes); - const fuse = new Fuse(terms); + const termsBaseURL = new URL(`${form.action}/meta/terms/search`, location.href).href; autocomplete({ input: form.term, + debounceWaitMs: 150, fetch(text, update) { - const searchResults = fuse.search(text).slice(0, 15); - const suggestions = searchResults.map(r => r.item); - update(suggestions); + if (text.length < 2) return update([]); + fetch(`${termsBaseURL}?q=${encodeURIComponent(text)}&limit=15`) + .then(r => r.ok ? r.json() : []) + .then(update) + .catch(() => update([])); }, onSelect(suggestion) { this.input.value = suggestion; @@ -330,7 +333,6 @@ async function ready() { http: new Set(types.http), }, specs, - terms, }; } diff --git a/tests/routes/xref/terms.test.js b/tests/routes/xref/terms.test.js new file mode 100644 index 00000000..4e192411 --- /dev/null +++ b/tests/routes/xref/terms.test.js @@ -0,0 +1,169 @@ +import { mkdtemp, writeFile, mkdir, rm } from "fs/promises"; +import path from "path"; +import { tmpdir } from "os"; + +let tmpDir; +let route; +let origDataDir; + +const FIXTURE_XREF = { + EventTarget: [{ type: "interface", spec: "dom", uri: "#eventtarget" }], + "event target": [{ type: "dfn", spec: "dom", uri: "#concept-event-target" }], + event: [{ type: "dfn", spec: "dom", uri: "#concept-event" }], + "event handler": [{ type: "dfn", spec: "html", uri: "#event-handler" }], + "event loop": [{ type: "dfn", spec: "html", uri: "#event-loop" }], + element: [{ type: "dfn", spec: "dom", uri: "#concept-element" }], + "foreignObject": [{ type: "element", spec: "svg", uri: "#foreignObject" }], + fetch: [{ type: "dfn", spec: "fetch", uri: "#concept-fetch" }], + "fire an event": [{ type: "dfn", spec: "dom", uri: "#concept-event-fire" }], + "live event": [{ type: "dfn", spec: "html", uri: "#concept-live-event" }], + URL: [{ type: "interface", spec: "url", uri: "#url" }], + "url": [{ type: "dfn", spec: "url", uri: "#concept-url" }], + AbortController: [{ type: "interface", spec: "dom", uri: "#abortcontroller" }], + AbortSignal: [{ type: "interface", spec: "dom", uri: "#abortsignal" }], + "abort signal": [{ type: "dfn", spec: "dom", uri: "#concept-abort-signal" }], + Node: [{ type: "interface", spec: "dom", uri: "#node" }], + navigator: [{ type: "attribute", spec: "html", uri: "#navigator" }], +}; + +beforeAll(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "xref-terms-test-")); + const xrefDir = path.join(tmpDir, "xref"); + await mkdir(xrefDir, { recursive: true }); + await writeFile(path.join(xrefDir, "xref.json"), JSON.stringify(FIXTURE_XREF)); + await writeFile(path.join(xrefDir, "specs.json"), "{}"); + await writeFile(path.join(xrefDir, "specmap.json"), '{"current":{},"snapshot":{}}'); + await writeFile(path.join(xrefDir, "headings.json"), "{}"); + + origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; + + const mod = await import("../../../build/routes/xref/terms.get.js"); + route = mod.default; +}); + +afterAll(async () => { + if (origDataDir !== undefined) { + process.env.DATA_DIR = origDataDir; + } + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +function mockReq(query = {}) { + return { query }; +} + +function mockRes() { + const res = { + _status: 200, + _body: undefined, + _headers: {}, + status(code) { res._status = code; return res; }, + json(body) { res._body = body; return res; }, + set(header, value) { res._headers[header] = value; return res; }, + send(body) { res._body = body; return res; }, + }; + return res; +} + +describe("xref/terms - server autocomplete", () => { + it("returns 400 when q is missing", () => { + const res = mockRes(); + route(mockReq(), res); + expect(res._status).toBe(400); + }); + + it("returns 400 when q is too short", () => { + const res = mockRes(); + route(mockReq({ q: "a" }), res); + expect(res._status).toBe(400); + }); + + it("returns prefix matches before infix-only matches", () => { + const res = mockRes(); + route(mockReq({ q: "ev" }), res); + const results = res._body; + const firstInfix = results.findIndex(t => !t.toLowerCase().startsWith("ev")); + const lastPrefix = results.findLastIndex(t => t.toLowerCase().startsWith("ev")); + if (firstInfix !== -1) { + expect(lastPrefix).toBeLessThan(firstInfix); + } + }); + + it("returns prefix matches first", () => { + const res = mockRes(); + route(mockReq({ q: "ev" }), res); + expect(res._status).toBe(200); + expect(Array.isArray(res._body)).toBeTrue(); + expect(res._body.length).toBeGreaterThan(0); + for (const term of res._body) { + expect(term.toLowerCase()).toMatch(/ev/); + } + }); + + it("matches case-insensitively but preserves original case", () => { + const res = mockRes(); + route(mockReq({ q: "eventtarget" }), res); + expect(res._body).toContain("EventTarget"); + }); + + it("returns infix matches when prefix results are insufficient", () => { + const res = mockRes(); + route(mockReq({ q: "signal" }), res); + expect(res._body).toContain("AbortSignal"); + expect(res._body).toContain("abort signal"); + }); + + it("respects the limit parameter", () => { + const res = mockRes(); + route(mockReq({ q: "ev", limit: "3" }), res); + expect(res._body.length).toBeLessThanOrEqual(3); + }); + + it("caps limit at 50", () => { + const res = mockRes(); + route(mockReq({ q: "ev", limit: "100" }), res); + expect(res._body.length).toBeLessThanOrEqual(50); + }); + + it("defaults limit to 15", () => { + const res = mockRes(); + route(mockReq({ q: "ab" }), res); + expect(res._body.length).toBeLessThanOrEqual(15); + }); + + it("sets Cache-Control header", () => { + const res = mockRes(); + route(mockReq({ q: "ev" }), res); + expect(res._headers["Cache-Control"]).toBeDefined(); + expect(res._headers["Cache-Control"]).toMatch(/max-age=/); + }); + + it("returns empty array for no matches", () => { + const res = mockRes(); + route(mockReq({ q: "zzzzzzz" }), res); + expect(res._body).toEqual([]); + }); + + it("handles mixed case query preserving original", () => { + const res = mockRes(); + route(mockReq({ q: "ForeignObj" }), res); + expect(res._body).toContain("foreignObject"); + }); + + it("normalizes array query params to first element", () => { + const res = mockRes(); + route(mockReq({ q: ["event", "ignored"] }), res); + expect(res._status).toBe(200); + expect(Array.isArray(res._body)).toBeTrue(); + expect(res._body.length).toBeGreaterThan(0); + }); + + it("returns 400 when array query param has short first element", () => { + const res = mockRes(); + route(mockReq({ q: ["a", "event"] }), res); + expect(res._status).toBe(400); + }); +});