Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion routes/api/baseline/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./update.worker")>(
const taskQueue = new BackgroundTaskQueue<typeof import("./update.worker.ts")>(
workerFile,
"baseline_update",
);
Expand Down
2 changes: 2 additions & 0 deletions routes/xref/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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") }))
Expand Down
79 changes: 79 additions & 0 deletions routes/xref/terms.get.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<never, any, never, QueryParams>;

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) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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);
}
16 changes: 9 additions & 7 deletions static/xref/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Comment thread
marcoscaceres marked this conversation as resolved.
res.json(),
);

Expand All @@ -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;
Expand Down Expand Up @@ -330,7 +333,6 @@ async function ready() {
http: new Set(types.http),
},
specs,
terms,
};
}

Expand Down
169 changes: 169 additions & 0 deletions tests/routes/xref/terms.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading