Skip to content

Commit aaa7dfd

Browse files
TMHSDigitalclaude
andcommitted
search: joined compounds and a few synonyms
"scifi" and "lowpoly" returned nothing because the catalog writes sci-fi and low-poly. Each search field now also indexes hyphenated compounds joined (lowpoly, scifi), and a small synonym map covers sfx/sound effect, bgm/music, model/mesh and tileset/tilemap. Tests in lib.test.mjs. Closes #176 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 90cf92d commit aaa7dfd

2 files changed

Lines changed: 46 additions & 6 deletions

File tree

‎site/lib/lib.test.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,21 @@ eq("no match inside a word (ui/build)", scoreFields(searchFields({ ...kit, name:
827827
eq("no match inside a word (art/earth)", kScore("art"), 0);
828828
eq("plural query finds singular tag", kScore("buttons") > 0, true);
829829
eq("hyphenated tag found by spaced words", kScore("first person") > 0, true);
830+
{
831+
const { joinedCompounds } = await import("./search.mjs");
832+
const pack = { ...kit, name: "Space Pack", tags: ["sci-fi", "low-poly"], subcategories: [], summary: "Ships with sound effects and a tile-set." };
833+
const ps = (q) => scoreFields(searchFields(pack), queryTests(q));
834+
eq("compounds: hyphenated words joined", joinedCompounds("Low-poly sci_fi pack"), "lowpoly scifi");
835+
eq("compounds: scifi finds the sci-fi tag", ps("scifi") > 0, true);
836+
eq("compounds: scifi and sci-fi both match", ps("scifi") > 0 && ps("sci-fi") > 0, true);
837+
eq("compounds: lowpoly finds the low-poly tag", ps("lowpoly") > 0, true);
838+
eq("compounds: joined forms do not match inside a word", ps("cifi"), 0);
839+
eq("synonyms: sfx finds sound effects", ps("sfx") > 0, true);
840+
eq("synonyms: tilemap finds a tile-set", ps("tilemap") > 0, true);
841+
eq("synonyms: bgm finds music", scoreFields(searchFields({ ...pack, summary: "Chiptune music." }), queryTests("bgm")) > 0, true);
842+
eq("synonyms: models finds a mesh", scoreFields(searchFields({ ...pack, summary: "One mesh." }), queryTests("models")) > 0, true);
843+
eq("synonyms: unrelated words stay unmatched", ps("music"), 0);
844+
}
830845
eq("3/4 view answers top down", kScore("top down") > 0, true);
831846
eq("every word must match", kScore("ui zzz"), 0);
832847
eq("name hit outranks summary hit", kScore("kit") > kScore("panels"), true);

‎site/lib/search.mjs‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ export function normalize(text) {
1919
return String(text).toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
2020
}
2121

22+
/**
23+
* Hyphenated compounds written as one word: "low-poly sci-fi" gives
24+
* "lowpoly scifi", so a reader who types the joined form finds them too.
25+
*/
26+
export function joinedCompounds(text) {
27+
const out = [];
28+
for (const m of String(text).toLowerCase().matchAll(/[\p{L}\p{N}]+(?:[-_][\p{L}\p{N}]+)+/gu)) out.push(m[0].replace(/[-_]/g, ""));
29+
return out.join(" ");
30+
}
31+
2232
/**
2333
* An entry's searchable text in three fields. Name, publisher and tags are
2434
* what a reader means by a word, so a hit there ranks above one in the other
@@ -27,24 +37,39 @@ export function normalize(text) {
2737
*/
2838
export const FIELD_WEIGHTS = [3, 2, 1];
2939
export function searchFields(e) {
40+
const field = (text) => normalize(`${text} ${joinedCompounds(text)}`);
3041
return [
31-
normalize([e.name, e.publisher || "", ...(e.tags || [])].join(" ")),
32-
normalize(
33-
[e.license, e.category, PERSPECTIVE_SEARCH[e.camera_perspective] || "", ...(e.formats || []), ...(e.subcategories || [])].join(" ")
34-
),
35-
normalize(e.summary || ""),
42+
field([e.name, e.publisher || "", ...(e.tags || [])].join(" ")),
43+
field([e.license, e.category, PERSPECTIVE_SEARCH[e.camera_perspective] || "", ...(e.formats || []), ...(e.subcategories || [])].join(" ")),
44+
field(e.summary || ""),
3645
];
3746
}
3847

48+
/**
49+
* A few words readers use for the same thing as the catalog's own wording.
50+
* A query word also matches each listed phrase (normalised text, so a phrase
51+
* may hold a space).
52+
*/
53+
export const SYNONYMS = {
54+
sfx: ["sound effect"],
55+
bgm: ["music"],
56+
model: ["mesh"],
57+
mesh: ["model"],
58+
tileset: ["tilemap", "tile set"],
59+
tilemap: ["tileset", "tile map"],
60+
};
61+
3962
/**
4063
* One test per query word. A word matches at the start of a word in the
4164
* text, never inside one: "ui" finds "UI kit" but not "build", "art" finds
4265
* "artwork" but not "earth". A plural query word also matches its singular,
43-
* since tags are stored one way: "buttons" finds the "button" tag.
66+
* since tags are stored one way: "buttons" finds the "button" tag. Each form
67+
* also matches its SYNONYMS.
4468
*/
4569
export function wordTest(word) {
4670
const forms = [word];
4771
if (word.length > 3 && word.endsWith("s")) forms.push(word.slice(0, -1));
72+
for (const f of [...forms]) forms.push(...(SYNONYMS[f] || []));
4873
const alt = forms.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
4974
return new RegExp(`(?:^|[^\\p{L}\\p{N}])(?:${alt})`, "u");
5075
}

0 commit comments

Comments
 (0)