impl/typescript.ts
2,795 bytes · the TypeScript implementation · view raw
/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */
type SearchableRecord = Readonly<Record<string, unknown>>;
/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */
const SAFE_INTEGER = 9007199254740991;
/**
* Fold A-Z to a-z and leave every other character alone.
*
* Deliberately not toLowerCase(): full Unicode case mapping differs between
* JavaScript, Python and Rust (Turkish dotted I, final sigma, sharp s), and a
* search that finds a row in one service but not in another is worse than one
* that consistently ignores accents.
*/
export function foldAscii(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) as number;
out += code >= 0x41 && code <= 0x5a ? String.fromCharCode(code + 32) : ch;
}
return out;
}
/**
* The searchable text of a value, or null if there is nothing to search.
*
* Floats are skipped rather than rendered: no decimal form of them is
* identical in all three languages, so matching on one could not be pinned.
*/
function searchableText(value: unknown): string | null {
if (typeof value === "string") return value;
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isInteger(value) || Math.abs(value) > SAFE_INTEGER) return null;
return String(value);
}
return null;
}
/**
* The records whose text in any of `fields` contains `query`, in input order.
*
* An empty query returns everything, because "nothing typed yet" means "show
* me everything" in every search box ever built.
*/
export function searchText(
records: readonly SearchableRecord[],
fields: readonly string[],
query: string,
): readonly SearchableRecord[] {
if (!Array.isArray(records)) {
throw new TypeError("searchText needs a list of records");
}
if (!Array.isArray(fields) || fields.some((f) => typeof f !== "string")) {
throw new TypeError("searchText needs a list of field names");
}
// An empty field list that quietly matches nothing is the classic bug that
// reaches users as "search is broken" with no other symptom.
if (fields.length === 0) {
throw new RangeError("searchText needs at least one field to search");
}
if (typeof query !== "string") {
throw new TypeError(`query must be a string, received ${query}`);
}
const needle = foldAscii(query);
if (needle.length === 0) return records.slice();
return records.filter((record) => {
if (record === null || record === undefined) return false;
for (const field of fields) {
const text = searchableText(record[field]);
if (text !== null && foldAscii(text).includes(needle)) return true;
}
return false;
});
}