The search overlay's sections (files, commits, reviews, wiki, and repos on the index and owner pages) split a query on whitespace only, while code search took "a quoted phrase" as one literal string. Now all read a query with query.ts: a quoted phrase is one term that must appear as written, ignoring case; unquoted words are terms of their own; every term must match. An unclosed quote runs to the end as a phrase, so a phrase narrows as it is typed; empty quotes are no term; a quote inside a word is a character. Results mark a phrase as a whole. Scoring, marking and snippets move into query.ts beside the parser, untouched otherwise, so that test/search-phrases.ts can run them in Node on a built search.json. Pages load search.js as a module to import it. The search-phrases scenario records the parser over a table of queries (phrase against words, unclosed and empty quotes, mixed), what commits, reviews and repos match and how they are marked, and that the shipped search.js imports query.js by its hashed name.
- commit
- f72b3f833fcb3a5010a02d5331e6557ed33d156c
- parent
- 1e513e382442f82210b53622d27fbd21477cc8ee
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 144s, signed by cat
- kind
- fix risk score 1.7 of 3, Jev's estimate
- review
- #3 Match quoted phrases in every search section
ci attestation
forge-attestation 1 type ci commit f72b3f833fcb3a5010a02d5331e6557ed33d156c tree b1498a9dc03ac4ef1df16bd6fce423acfddc59f1 script .forge/ci e6f038ae810b87f90a4be115a762a9c91dd76a01 result pass exit 0 started 2026-10-18T22:48:48Z duration 144s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:6a5d5510307d2046f36e04c48dd2574ec47e11cfa14e485e3c242a47f4eb11f1 240 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |778d59f running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |778d59f pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |236e1a8 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |236e1a8 fail in 1s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |552ec37 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |552ec37 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 22a2ce4 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |0c0b42e running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |0c0b42e pass in 0s, signed |pushed main |261de5e running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |261de5e pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed aeaf8bf -> /sites/dami/forge/guide/ |(node:19423) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:21600) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:23748) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:26386) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 5s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:31901) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | README.md | 20 | +++++- |
| M | site/ | 2 | +- |
| M | site/ | 67 | ++++++++++++++++++-- |
| M | site/ | 100 | ++++++++++-------------------- |
| A | test/ | 94 | ++++++++++++++++++++++++++++ |
| A | test/ | 57 | +++++++++++++++++ |
@@ -555,6 +555,19 @@ first time the search opens, and matching runs in the browser. Commit bodies
555555 are cut to 240 characters and wiki pages to 2000, to keep the index small.556556 `t` still finds files by name on the current branch.557557 558+Every section reads a query the same way (`site/assets/query.ts`): terms559+split on whitespace, in any order, and a quoted phrase (`"fix the search"`)560+is one term, spaces and all, that must appear as written. Every term must561+match, ignoring case, and results mark each match, a phrase as a whole. A562+quote opens a phrase only at the start of a term (in `it's"` it is just a563+character), an unclosed quote runs to the end of the query as a phrase, so a564+phrase narrows as you type it, and empty quotes are no term. So565+`fix search` finds "Search fix for the box" and `"fix the search"` doesn't.566+`node test/search-phrases.ts` runs the same code in Node, for567+`test/scenarios/search-phrases.sh`. Pages load `search.ts` as a module; it568+and `codesearch.ts` import `./query.ts`, which a build rewrites to that569+file's content-hashed name.570+558571 #### Code search559572 560573 File contents are in a second index per repo, `/<owner>/<repo>/code.db`,@@ -569,9 +582,10 @@ pages mirror on R2 through the edge, whose fallback passes the header on.
569582 Once the query has a word of 3 characters (a trigram can't match less), it570583 goes to a worker, and a Code section lists each line that has every word,571584 ignoring case, as `path:line`, linked to that line of the file's page (the572-source page for markdown). Words split on whitespace, as in the other573-sections, in any order, and a quoted phrase (`"import fs from"`) is one574-literal string, spaces and all. Words of 3 characters or more are looked up585+source page for markdown). Its words are the terms the other sections read,586+so a quoted phrase (`"import fs from"`) is one literal string, spaces and587+all; a word inside another goes, since a line with the longer one has it.588+Words of 3 characters or more are looked up575589 in the index; shorter ones only filter the lines it finds. It loads nothing576590 until then: the first code query starts the worker, which loads577591 `sqlite3.wasm` and sqlite-wasm's script, both content-hashed assets. Results@@ -21,7 +21,7 @@
2121 <script src="{{ assets.ssexi }}" defer></script>2222 <script src="{{ assets.live }}" defer></script>2323 <script src="{{ assets.keys }}" defer></script>24-<script src="{{ assets.search }}" defer></script>24+<script type="module" src="{{ assets.search }}"></script>2525 <script src="{{ assets.ui }}" data-mermaid="{{ assets.mermaid }}" defer></script>2626 {% block scripts %}{% endblock %}2727 {% block head %}{{ m.meta(p.title if p and p.title else forge.config.name, r.description if r else '') }}{% endblock %}@@ -1,9 +1,12 @@
1-// A search query's terms, as codesearch.ts reads them for the lines of2-// files: split on whitespace, except that a quoted phrase ("like this") is3-// one term, spaces and all, which must appear as written. A quote opens a4-// phrase only at the start of a term (in `it's"` or `a"b` it is just a5-// character), an unclosed one runs to the end of the query, and empty quotes6-// are no term. Every term must match, ignoring case.1+// A search query, as every section of the search reads it: search.ts for2+// commits, reviews, wiki pages, file paths and repos, and codesearch.ts for3+// the lines of files. A query is terms split on whitespace, except that a4+// quoted phrase ("like this") is one term, spaces and all, which must appear5+// as written. A quote opens a phrase only at the start of a term (in6+// `it's"` or `a"b` it is just a character), an unclosed one runs to the end7+// of the query, and empty quotes are no term. Every term must match, ignoring8+// case. Nothing here touches the page, so test/search-phrases.ts checks it9+// in Node as the browser runs it.710 811 // A query's terms, lower case, each once.912 export function terms(q: string): string[] {@@ -14,3 +17,55 @@ export function terms(q: string): string[] {
1417 // The terms a search within one line needs: a term inside another goes,1518 // since every line with the longer one has it.1619 export const narrow = (ts: string[]): string[] => ts.filter((t, i) => !ts.some((o, j) => j !== i && o.length > t.length && o.includes(t)));20+21+// Whether a text has every term, ignoring case.22+export function hasAll(text: string, ts: string[]): boolean {23+ const low = text.toLowerCase();24+ return ts.every((t) => low.includes(t));25+}26+27+const wordStart = (s: string, i: number): boolean => i === 0 || /[^\p{L}\p{N}]/u.test(s[i - 1]);28+29+// How well a record matches, from its title, meta line and text (lower30+// case), and a key that matches by prefix (a commit's full sha); null unless31+// every term is in one of them. Matches in titles rank above matches in32+// text, and those at the start of a word above those inside one.33+export function score(low: [string, string, string], key: string, ts: string[]): { s: number; inText: boolean } | null {34+ let s = 0;35+ let inText = false;36+ for (const t of ts) {37+ const i = low[0].indexOf(t);38+ if (i >= 0) s += wordStart(low[0], i) ? 6 : 4;39+ else if (t.length >= 4 && key.startsWith(t)) s += 5;40+ else if (low[1].includes(t)) s += 2;41+ else if (low[2].includes(t)) {42+ s += 1;43+ inText = true;44+ } else return null;45+ }46+ return { s, inText };47+}48+49+// A text cut into runs, each marked if it is part of a term's match: a50+// phrase is marked whole, and overlapping matches make one run. Text whose51+// lower case is another length (a few letters) stays one plain run.52+export function marks(s: string, ts: string[]): [string, boolean][] {53+ const low = s.toLowerCase();54+ if (low.length !== s.length) return [[s, false]];55+ const hit = new Uint8Array(s.length);56+ for (const t of ts) for (let i = low.indexOf(t); i >= 0; i = low.indexOf(t, i + 1)) hit.fill(1, i, i + t.length);57+ const out: [string, boolean][] = [];58+ for (let i = 0, j = 0; i < s.length; i = j) {59+ while (j < s.length && hit[j] === hit[i]) j++;60+ out.push([s.slice(i, j), !!hit[i]]);61+ }62+ return out;63+}64+65+// The part of a long text around its first match.66+export function snippet(text: string, ts: string[]): string {67+ const low = text.toLowerCase();68+ const at = Math.min(...ts.map((t) => low.indexOf(t)).filter((i) => i >= 0));69+ const start = Math.max(0, at - 30);70+ return (start ? '…' : '') + text.slice(start, start + 120) + (start + 120 < text.length ? '…' : '');71+}@@ -1,14 +1,18 @@
11 // Search, on "/" or the header button. In a repo it covers commits, reviews,22 // wiki pages and file paths on the default branch; on the index and owner33 // pages, repo names and descriptions. The index is a static JSON file (see4-// lib/search.ts) fetched the first time the search opens. Every term must5-// match somewhere; matches in titles rank above matches in text.4+// lib/search.ts) fetched the first time the search opens. A query is terms5+// split on whitespace, "a phrase" in quotes being one, read the same way in6+// every section (query.ts). Every term must match somewhere; matches in7+// titles rank above matches in text.68 //79 // With a word of 3 characters it also searches file contents on the default8-// branch, for lines with every word ("a phrase" in quotes is one): the9-// query goes to a worker (codesearch.ts) that queries the repo's code index10-// with sqlite-wasm over range requests. The worker and sqlite-wasm load the11-// first time someone searches code.10+// branch, for lines with every term: the query goes to a worker11+// (codesearch.ts) that queries the repo's code index with sqlite-wasm over12+// range requests. The worker and sqlite-wasm load the first time someone13+// searches code.14+import { hasAll, marks, narrow, score as match, snippet, terms } from './query.ts';15+1216 (() => {1317 const button = document.querySelector<HTMLButtonElement>('button[data-search]');1418 if (!button) return;@@ -60,20 +64,13 @@
6064 error?: string;6165 }6266 // As in codesearch.ts: trigrams need 3 characters; 12 chunks for a first63- // look, 100 when asked for more. A query's words split on whitespace, but64- // a quoted phrase is one, spaces and all; a word inside another goes.67+ // look, 100 when asked for more. A line has every term, so a term inside68+ // another goes.6569 const CODE_MIN = 3;6670 const CODE_FIRST = 12;6771 const CODE_MORE = 100;68- const codeWords = (q: string): string[] => {69- const all = [...q.toLowerCase().matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[1] ?? m[2]).filter((w) => w.trim());70- return [...new Set(all)].filter((w, i, ws) => !ws.some((o, j) => j !== i && o.length > w.length && o.includes(w)));71- };72+ const codeWords = (q: string): string[] => narrow(terms(q));7273 const codeable = (q: string): boolean => codeWords(q).some((w) => [...w].length >= CODE_MIN);73- const hasAll = (text: string, ws: string[]): boolean => {74- const low = text.toLowerCase();75- return ws.every((w) => low.includes(w));76- };7774 let codeUrl: string | null = null;7875 let blobBase = '';7976 let worker: Worker | null = null;@@ -129,50 +126,21 @@
129126 return list;130127 }131128 132- const wordStart = (s: string, i: number): boolean => i === 0 || /[^\p{L}\p{N}]/u.test(s[i - 1]);133-134- function score(r: Rec, terms: string[]): Hit | null {135- let s = 0;136- let inText = false;137- for (const t of terms) {138- const i = r.low[0].indexOf(t);139- if (i >= 0) s += wordStart(r.low[0], i) ? 6 : 4;140- else if (t.length >= 4 && r.key.startsWith(t)) s += 5;141- else if (r.low[1].includes(t)) s += 2;142- else if (r.low[2].includes(t)) {143- s += 1;144- inText = true;145- } else return null;146- }147- return { r, s, inText };148- }149-150- // Text with every occurrence of a term in <b>, built as nodes (never HTML).151- function marked(s: string, terms: string[]): Node[] {152- const low = s.toLowerCase();153- if (low.length !== s.length) return [document.createTextNode(s)];154- const hit = new Uint8Array(s.length);155- for (const t of terms) for (let i = low.indexOf(t); i >= 0; i = low.indexOf(t, i + 1)) hit.fill(1, i, i + t.length);156- const out: Node[] = [];157- for (let i = 0, j = 0; i < s.length; i = j) {158- while (j < s.length && hit[j] === hit[i]) j++;159- const text = document.createTextNode(s.slice(i, j));160- if (!hit[i]) out.push(text);161- else {162- const b = document.createElement('b');163- b.append(text);164- out.push(b);165- }166- }167- return out;129+ function score(r: Rec, ts: string[]): Hit | null {130+ const m = match(r.low, r.key, ts);131+ return m && { r, ...m };168132 }169133 170- // The part of a long text around its first match.171- function snippet(text: string, terms: string[]): string {172- const low = text.toLowerCase();173- const at = Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0));174- const start = Math.max(0, at - 30);175- return (start ? '…' : '') + text.slice(start, start + 120) + (start + 120 < text.length ? '…' : '');134+ // Text with every match of a term (a phrase whole) in <b>, built as nodes135+ // (never HTML).136+ function marked(s: string, ts: string[]): Node[] {137+ return marks(s, ts).map(([text, hit]) => {138+ const t = document.createTextNode(text);139+ if (!hit) return t;140+ const b = document.createElement('b');141+ b.append(t);142+ return b;143+ });176144 }177145 178146 function open(): void {@@ -304,7 +272,7 @@
304272 };305273 306274 const draw = (): void => {307- const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean);275+ const ts = terms(input.value);308276 const q = input.value.trim();309277 ol.replaceChildren();310278 rows = [];@@ -312,21 +280,21 @@
312280 hint.textContent = failed ? 'The search index could not be loaded.' : 'Loading the search index';313281 return;314282 }315- if (!terms.length) {316- hint.textContent = repos ? 'Repository names and descriptions.' : codeUrl317- ? 'Searches commit messages, reviews, wiki pages, file paths and, for words of 3 characters or more, the lines of the files themselves ("a phrase" in quotes), on the default branch. Press t to find a file on this branch.'318- : 'Searches commit messages, reviews, wiki pages and file paths on the default branch. Press t to find a file on this branch.';283+ if (!ts.length) {284+ hint.textContent = repos ? 'Repository names and descriptions; "a phrase" in quotes matches as written.' : codeUrl285+ ? 'Searches commit messages, reviews, wiki pages, file paths and, for words of 3 characters or more, the lines of the files themselves, on the default branch; "a phrase" in quotes matches as written. Press t to find a file on this branch.'286+ : 'Searches commit messages, reviews, wiki pages and file paths on the default branch; "a phrase" in quotes matches as written. Press t to find a file on this branch.';319287 return;320288 }321- const hits = all.map((r) => score(r, terms)).filter((h): h is Hit => !!h);289+ const hits = all.map((r) => score(r, ts)).filter((h): h is Hit => !!h);322290 for (const kind of KINDS) {323291 const list = hits.filter((h) => h.r.kind === kind).sort((a, b) => b.s - a.s);324292 if (!list.length) continue;325293 heading(kind, String(list.length));326294 const shown = expanded.has(kind) ? list : list.slice(0, PER_KIND);327295 for (const h of shown) {328- const extra = h.inText ? snippet(h.r.text, terms) : h.r.meta || h.r.text.slice(0, 120);329- link(h.r.href, marked(h.r.title, terms), h.r.mono, extra ? marked(extra, terms) : null);296+ const extra = h.inText ? snippet(h.r.text, ts) : h.r.meta || h.r.text.slice(0, 120);297+ link(h.r.href, marked(h.r.title, ts), h.r.mono, extra ? marked(extra, ts) : null);330298 }331299 if (shown.length < list.length) moreRow(`${list.length - shown.length} more`, () => expanded.add(kind));332300 }@@ -0,0 +1,94 @@
1+# The search reads a query the same way in every section: terms split on2+# whitespace, a quoted phrase is one term that must appear as written3+# (ignoring case), and every term must match. Commits, reviews, wiki pages4+# and repos match through site/assets/query.ts, as the browser does, and5+# code search's words come out the same.6+. "$(dirname "$0")/../lib.sh"7+user dami8+repo dami/greet "say hello world"9+repo dami/other "hello there, world"10+clone dami dami/greet "$T/w/dami"11+at "$T/w/dami" commit "Fix the search box"12+at "$T/w/dami" commit "Search fix for the box"13+tick14+at "$T/w/dami" git commit -q --allow-empty -m "Tidy the box" -m "This is to fix the search for good."15+at "$T/w/dami" commit "box search fix"16+push_as dami "$T/w/dami" origin main17+at "$T/w/dami" git switch -q -c dami/wide18+at "$T/w/dami" commit "Make the search box wider"19+push_as dami "$T/w/dami" origin dami/wide20+build21+ix=$FORGE_WWW/dami/greet/search.json22+exists "$ix"23+terms() { node "$T_HOME/test/search-phrases.ts" terms "$@"; }24+match() { node "$T_HOME/test/search-phrases.ts" match "$@"; }25+26+# The parser. A phrase is one term, spaces and all; words are terms of their27+# own; an unclosed quote runs to the end, as a phrase; empty quotes (or only28+# spaces in them) are no term; a quote inside a word is a character. Code29+# search reads the same terms, less any inside another (a line with the30+# longer one has it).31+ok terms 'fix search'32+eq "$(output)" 'fix search => [fix] [search] | code [fix] [search]'33+ok terms '"Fix the Search"'34+eq "$(output)" '"Fix the Search" => [fix the search] | code [fix the search]'35+ok terms '"the search'36+eq "$(output)" '"the search => [the search] | code [the search]'37+ok terms '""' '" "' '"'38+eq "$(output)" '"" => - | code -39+" " => - | code -40+" => - | code -'41+ok terms '"search box" fix' 'fix "search box"'42+eq "$(output)" '"search box" fix => [search box] [fix] | code [search box] [fix]43+fix "search box" => [fix] [search box] | code [fix] [search box]'44+ok terms 'search "search box"' 'Fix FIX'45+eq "$(output)" 'search "search box" => [search] [search box] | code [search box]46+Fix FIX => [fix] | code [fix]'47+ok terms "it's\"" 'a"b c' '"fix"box'48+eq "$(output)" "it's\" => [it's\"] | code [it's\"]49+a\"b c => [a\"b] [c] | code [a\"b] [c]50+\"fix\"box => [fix] [box] | code [fix] [box]"51+52+# Words: every commit with both, in any order, in the subject or the body.53+ok match "$ix" 'fix search'54+eq "$(output | grep -c '^Commits: ')" 455+# A phrase: only the subject and the body that have it as written, ignoring56+# case, and it is marked whole.57+ok match "$ix" '"fix the search"'58+eq "$(output | grep '^Commits: ' | sed 's/ | .*//')" 'Commits: [Fix the search] box59+Commits: Tidy the box'60+has "$T_LOG" '^Commits: Tidy the box \| This is to \[fix the search\] for good\.$'61+# Unclosed, it is the same phrase.62+ok match "$ix" '"FIX THE SEARCH'63+eq "$(output | grep '^Commits: ' | sed 's/ | .*//')" 'Commits: [Fix the search] box64+Commits: Tidy the box'65+# Mixed: the phrase and the word must both match.66+ok match "$ix" '"search box" fix'67+eq "$(output)" "$(match "$ix" 'fix "search box"')"68+eq "$(output | sed 's/ | .*//')" 'Commits: [Fix] the [search box]'69+# Reviews too (and the review's commit, which the index has as well).70+ok match "$ix" '"search box"'71+eq "$(output | sed 's/ | .*//')" 'Reviews: #1 Make the [search box] wider72+Commits: Make the [search box] wider73+Commits: Fix the [search box]'74+# Nothing to look for: empty quotes match nothing, as an empty query does.75+ok match "$ix" '""'76+eq "$(output)" ''77+ok match "$ix" '"box search fix"'78+eq "$(output | sed 's/ | .*//')" 'Commits: [box search fix]'79+ok match "$ix" '"fix the box"'80+eq "$(output)" ''81+82+# Repos on the index page: names and descriptions, the same way.83+ok match "$FORGE_WWW/repos.json" 'hello world'84+eq "$(output)" 'Repositories: dami/greet | say [hello] [world]85+Repositories: dami/other | [hello] there, [world]'86+ok match "$FORGE_WWW/repos.json" '"hello world"'87+eq "$(output)" 'Repositories: dami/greet | say [hello world]'88+89+# Pages load search.js as a module, which imports query.js by its shipped name.90+has "$FORGE_WWW/dami/greet/index.html" '<script type="module" src="/assets/search\.[0-9A-Za-z_-]{10}\.js"></script>'91+has "$FORGE_WWW/index.html" '<script type="module" src="/assets/search\.[0-9A-Za-z_-]{10}\.js"></script>'92+s=$(ls "$FORGE_WWW"/assets/search.*.js)93+has "$s" "^import \{ hasAll, marks, narrow, score as match, snippet, terms \} from '\./query\.[0-9A-Za-z_-]{10}\.js';$"94+exists "$FORGE_WWW/assets/$(sed -n "s|^import .* from '\./\(query\.[^']*\)';\$|\1|p" "$s")"@@ -0,0 +1,57 @@
1+// How the search reads a query and what it matches, run in Node with the2+// browser's own code: site/assets/query.ts, which search.ts matches every3+// section with, and codesearch.ts's words for file contents.4+//5+// node test/search-phrases.ts terms <query>... each query's terms, then code search's6+// node test/search-phrases.ts match <search.json> <query> what the sections list7+//8+// `terms` prints a line per query: `<query> => [term] [term] | code [word]`,9+// with - for none.10+// `match` builds the records from a built search.json (or repos.json) the11+// way search.ts's load() does, less the file list, and prints a line per12+// hit in rank order within its kind: `<kind>: <title> | <second line>`,13+// with each marked run in [brackets].14+import fs from 'node:fs';15+import { marks, score, snippet, terms } from '../site/assets/query.ts';16+import { words } from '../site/assets/codesearch.ts';17+18+const [mode, ...args] = process.argv.slice(2);19+const show = (ts: string[]): string => ts.map((t) => `[${t}]`).join(' ') || '-';20+21+interface Rec {22+ kind: string;23+ title: string;24+ meta: string;25+ text: string;26+ key: string;27+}28+29+if (mode === 'terms') {30+ for (const q of args) console.log(`${q} => ${show(terms(q))} | code ${show(words(q))}`);31+} else if (mode === 'match') {32+ const [file, q] = args;33+ type Row = (string | number)[];34+ const ix = JSON.parse(fs.readFileSync(file, 'utf8')) as { commits?: Row[]; reviews?: Row[]; wiki?: Row[]; repos?: Row[] };35+ const recs: Rec[] = [];36+ const rec = (kind: string, title: string, meta = '', text = '', key = ''): number => recs.push({ kind, title, meta, text, key });37+ for (const [slug, desc] of ix.repos ?? []) rec('Repositories', String(slug), '', String(desc));38+ for (const [id, title, branch, author, status] of ix.reviews ?? []) rec('Reviews', `#${id} ${title}`, `${branch}, ${author ? author + ', ' : ''}${status}`);39+ for (const [sha, subject, author, date, body] of ix.commits ?? []) rec('Commits', String(subject), `${String(sha).slice(0, 7)} ${author}, ${date}`, String(body), String(sha));40+ for (const [, title, text] of ix.wiki ?? []) rec('Wiki', String(title), '', String(text));41+ const ts = terms(q);42+ const marked = (s: string): string => marks(s, ts).map(([t, hit]) => (hit ? `[${t}]` : t)).join('');43+ for (const kind of ['Repositories', 'Reviews', 'Commits', 'Wiki']) {44+ const hits = recs45+ .filter((r) => r.kind === kind && ts.length)46+ .map((r) => ({ r, m: score([r.title.toLowerCase(), r.meta.toLowerCase(), r.text.toLowerCase()], r.key, ts) }))47+ .filter((h) => h.m)48+ .sort((a, b) => b.m!.s - a.m!.s);49+ for (const { r, m } of hits) {50+ const extra = m!.inText ? snippet(r.text, ts) : r.meta || r.text.slice(0, 120);51+ console.log(`${kind}: ${marked(r.title)}${extra ? ` | ${marked(extra)}` : ''}`);52+ }53+ }54+} else {55+ console.error('usage: node test/search-phrases.ts terms <query>... | match <search.json> <query>');56+ process.exit(2);57+}