cursedbelt-core 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * What a search box's text MEANS — plain words, a `*` wildcard, or a regular
3
+ * expression — decided once, in one place, for the browser and the server alike.
4
+ *
5
+ * ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
6
+ * *"type * to mean any character in the search. Then if I know two words in a
7
+ * movie but not what is between them I can put the * between them like
8
+ * "the*the" could pull up "The hunt for the red October" … but not … "The mask".
9
+ * Also "hunt*red" would find "The hunt for the red October".*
10
+ *
11
+ * A word index cannot answer that: it knows the title contains `the`, not that it
12
+ * contains it TWICE, and not in which order its words came. So a `*` turns the
13
+ * search into an ordered substring match — `the*the` is "`the`, then anything,
14
+ * then `the`" — and a search without one is untouched, because every app already
15
+ * has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
16
+ * `src/server/library.ts`).
17
+ *
18
+ * ── The semantics, all of them ──────────────────────────────────────────────
19
+ * - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
20
+ * although the title does not START with `hunt`. So leading and trailing stars
21
+ * are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
22
+ * starred forms are WILDCARD searches, which is what lets an app answer `*over*`
23
+ * with *Cloverfield* (a substring inside a word) where its word index could not.
24
+ * - **Literal between the stars.** Spaces included, so `the *the` asks for a
25
+ * `the` followed by a space — a power user's word boundary, and nobody else
26
+ * notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
27
+ * it would demand two separate gaps and fail on titles it plainly describes.
28
+ * - **Case-insensitive, always** — in the matcher and the regex alike. A search
29
+ * box that cares about capitals reads as broken.
30
+ * - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
31
+ * form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
32
+ * in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
33
+ * and a server that ever evaluates one must bound it — a viewer-typed regex on a
34
+ * shared server is a denial-of-service vector.
35
+ *
36
+ * ── How an app uses it ──────────────────────────────────────────────────────
37
+ * {@link parseSearchPattern} is the whole decision; everything else derives from
38
+ * its result. An app that filters in memory calls {@link createSearchMatcher}. An
39
+ * app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
40
+ * keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
41
+ * `cursedbelt/react` parses with the SAME function, so the badge it shows is the
42
+ * syntax the query actually ran in.
43
+ *
44
+ * Isomorphic and dependency-free — it imports nothing.
45
+ */
46
+ /** A syntax an app can switch on beyond plain words. */
47
+ export type SearchSyntax = 'wildcard' | 'regex';
48
+ /** Which syntaxes a search box understands. Absent means the defaults below. */
49
+ export interface SearchSyntaxOptions {
50
+ /** `*` stands for any run of characters (including none). Default **on**. */
51
+ wildcard?: boolean;
52
+ /** `/source/flags` is a regular expression. Default **off**. */
53
+ regex?: boolean;
54
+ }
55
+ export declare const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>>;
56
+ /** The wildcard character. One constant, so the help text can never disagree with the parser. */
57
+ export declare const SEARCH_WILDCARD = "*";
58
+ export type SearchPattern =
59
+ /** Nothing to search for — show everything. A lone `*` is this too. */
60
+ {
61
+ kind: 'empty';
62
+ raw: string;
63
+ }
64
+ /** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
65
+ | {
66
+ kind: 'plain';
67
+ raw: string;
68
+ text: string;
69
+ }
70
+ /** `parts` must appear in this order, anything between them. Never empty. */
71
+ | {
72
+ kind: 'wildcard';
73
+ raw: string;
74
+ text: string;
75
+ parts: string[];
76
+ } | {
77
+ kind: 'regex';
78
+ raw: string;
79
+ source: string;
80
+ flags: string;
81
+ }
82
+ /** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
83
+ | {
84
+ kind: 'invalid';
85
+ raw: string;
86
+ syntax: SearchSyntax;
87
+ message: string;
88
+ };
89
+ /** Decide what a search box's text means under `options`. */
90
+ export declare function parseSearchPattern(raw: string, options?: SearchSyntaxOptions): SearchPattern;
91
+ /**
92
+ * The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
93
+ * nothing (`invalid`). Plain text becomes a case-insensitive contains.
94
+ */
95
+ export declare function searchPatternToRegExp(pattern: SearchPattern): RegExp | null;
96
+ /** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
97
+ export declare function matchesSearchPattern(pattern: SearchPattern, text: string): boolean;
98
+ /**
99
+ * Parse once, compile once, then test many — the shape an in-memory list filter
100
+ * wants. `matcher(title)` for every row costs one regex test, not one parse.
101
+ */
102
+ export declare function createSearchMatcher(raw: string, options?: SearchSyntaxOptions): {
103
+ pattern: SearchPattern;
104
+ test: (text: string) => boolean;
105
+ };
106
+ /**
107
+ * The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
108
+ * say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
109
+ * and `invalid`: none of those has a `LIKE` form, and the caller decides what
110
+ * each means rather than being handed a pattern that silently matches too much.
111
+ *
112
+ * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
113
+ * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
114
+ */
115
+ export declare function searchPatternToLike(pattern: SearchPattern, escape?: string): string | null;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a search box's text MEANS — plain words, a `*` wildcard, or a regular
3
+ * expression — decided once, in one place, for the browser and the server alike.
4
+ *
5
+ * ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
6
+ * *"type * to mean any character in the search. Then if I know two words in a
7
+ * movie but not what is between them I can put the * between them like
8
+ * "the*the" could pull up "The hunt for the red October" … but not … "The mask".
9
+ * Also "hunt*red" would find "The hunt for the red October".*
10
+ *
11
+ * A word index cannot answer that: it knows the title contains `the`, not that it
12
+ * contains it TWICE, and not in which order its words came. So a `*` turns the
13
+ * search into an ordered substring match — `the*the` is "`the`, then anything,
14
+ * then `the`" — and a search without one is untouched, because every app already
15
+ * has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
16
+ * `src/server/library.ts`).
17
+ *
18
+ * ── The semantics, all of them ──────────────────────────────────────────────
19
+ * - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
20
+ * although the title does not START with `hunt`. So leading and trailing stars
21
+ * are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
22
+ * starred forms are WILDCARD searches, which is what lets an app answer `*over*`
23
+ * with *Cloverfield* (a substring inside a word) where its word index could not.
24
+ * - **Literal between the stars.** Spaces included, so `the *the` asks for a
25
+ * `the` followed by a space — a power user's word boundary, and nobody else
26
+ * notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
27
+ * it would demand two separate gaps and fail on titles it plainly describes.
28
+ * - **Case-insensitive, always** — in the matcher and the regex alike. A search
29
+ * box that cares about capitals reads as broken.
30
+ * - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
31
+ * form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
32
+ * in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
33
+ * and a server that ever evaluates one must bound it — a viewer-typed regex on a
34
+ * shared server is a denial-of-service vector.
35
+ *
36
+ * ── How an app uses it ──────────────────────────────────────────────────────
37
+ * {@link parseSearchPattern} is the whole decision; everything else derives from
38
+ * its result. An app that filters in memory calls {@link createSearchMatcher}. An
39
+ * app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
40
+ * keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
41
+ * `cursedbelt/react` parses with the SAME function, so the badge it shows is the
42
+ * syntax the query actually ran in.
43
+ *
44
+ * Isomorphic and dependency-free — it imports nothing.
45
+ */
46
+ export const DEFAULT_SEARCH_SYNTAX = Object.freeze({
47
+ wildcard: true,
48
+ regex: false,
49
+ });
50
+ /** The wildcard character. One constant, so the help text can never disagree with the parser. */
51
+ export const SEARCH_WILDCARD = '*';
52
+ const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
53
+ /** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
54
+ const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
55
+ const collapse = (text) => text.replace(/\s+/g, ' ');
56
+ /** Decide what a search box's text means under `options`. */
57
+ export function parseSearchPattern(raw, options = {}) {
58
+ const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
59
+ const text = collapse(raw.trim());
60
+ if (text.length === 0)
61
+ return { kind: 'empty', raw };
62
+ if (syntax.regex) {
63
+ const form = REGEX_FORM.exec(text);
64
+ if (form) {
65
+ const [, source = '', given = ''] = form;
66
+ const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
67
+ if (unknown) {
68
+ return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
69
+ }
70
+ const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
71
+ try {
72
+ new RegExp(source, flags);
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ return { kind: 'invalid', raw, syntax: 'regex', message };
77
+ }
78
+ return { kind: 'regex', raw, source, flags };
79
+ }
80
+ }
81
+ if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
82
+ const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
83
+ if (parts.length === 0)
84
+ return { kind: 'empty', raw };
85
+ return { kind: 'wildcard', raw, text, parts };
86
+ }
87
+ return { kind: 'plain', raw, text };
88
+ }
89
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
90
+ /**
91
+ * The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
92
+ * nothing (`invalid`). Plain text becomes a case-insensitive contains.
93
+ */
94
+ export function searchPatternToRegExp(pattern) {
95
+ switch (pattern.kind) {
96
+ case 'empty':
97
+ case 'invalid':
98
+ return null;
99
+ case 'plain':
100
+ return new RegExp(escapeRegExp(pattern.text), 'i');
101
+ case 'wildcard':
102
+ // `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
103
+ // multi-line field (a summary, a note) without needing the `s` flag.
104
+ return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
105
+ case 'regex':
106
+ return new RegExp(pattern.source, pattern.flags);
107
+ }
108
+ }
109
+ /** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
110
+ export function matchesSearchPattern(pattern, text) {
111
+ if (pattern.kind === 'empty')
112
+ return true;
113
+ return searchPatternToRegExp(pattern)?.test(text) ?? false;
114
+ }
115
+ /**
116
+ * Parse once, compile once, then test many — the shape an in-memory list filter
117
+ * wants. `matcher(title)` for every row costs one regex test, not one parse.
118
+ */
119
+ export function createSearchMatcher(raw, options = {}) {
120
+ const pattern = parseSearchPattern(raw, options);
121
+ if (pattern.kind === 'empty')
122
+ return { pattern, test: () => true };
123
+ const re = searchPatternToRegExp(pattern);
124
+ return { pattern, test: re ? (text) => re.test(text) : () => false };
125
+ }
126
+ /**
127
+ * The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
128
+ * say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
129
+ * and `invalid`: none of those has a `LIKE` form, and the caller decides what
130
+ * each means rather than being handed a pattern that silently matches too much.
131
+ *
132
+ * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
133
+ * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
134
+ */
135
+ export function searchPatternToLike(pattern, escape = '\\') {
136
+ const literal = (text) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
137
+ const escapeAll = (text) => literal(text.split(escape).join(`${escape}${escape}`));
138
+ switch (pattern.kind) {
139
+ case 'plain':
140
+ return `%${escapeAll(pattern.text)}%`;
141
+ case 'wildcard':
142
+ return `%${pattern.parts.map(escapeAll).join('%')}%`;
143
+ default:
144
+ return null;
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -288,6 +288,12 @@
288
288
  "source": "./src/core/passwordPolicy.ts",
289
289
  "import": "./dist/core/passwordPolicy.js"
290
290
  },
291
+ "./search-pattern": {
292
+ "types": "./dist/core/searchPattern.d.ts",
293
+ "bun": "./src/core/searchPattern.ts",
294
+ "source": "./src/core/searchPattern.ts",
295
+ "import": "./dist/core/searchPattern.js"
296
+ },
291
297
  "./session/claims": {
292
298
  "types": "./dist/core/session/claims.d.ts",
293
299
  "bun": "./src/core/session/claims.ts",
@@ -54,11 +54,13 @@ const { caches: _dropped, ...noCaches } = sw;
54
54
  installShellWorker(noCaches, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
55
55
  `,
56
56
  );
57
- const child = Bun.spawnSync([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
57
+ const child = Bun.spawn([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
58
+ stdin: 'ignore',
58
59
  stdout: 'pipe',
59
60
  stderr: 'pipe',
60
61
  });
61
- return `${child.stdout.toString()}${child.stderr.toString()}`;
62
+ const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
63
+ return `${stdout}${stderr}`;
62
64
  };
63
65
 
64
66
  const output = await typecheck();
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The owner's own examples are the acceptance test — each one is a row below,
3
+ * against the titles he named.
4
+ */
5
+ import { describe, expect, it } from 'bun:test';
6
+ import {
7
+ createSearchMatcher,
8
+ matchesSearchPattern,
9
+ parseSearchPattern,
10
+ searchPatternToLike,
11
+ searchPatternToRegExp,
12
+ } from './searchPattern';
13
+
14
+ const TITLES = [
15
+ 'The Hunt for Red October',
16
+ 'The Mask',
17
+ '10 Cloverfield Lane',
18
+ 'Cloverfield',
19
+ 'The Agony and the Ecstasy',
20
+ 'Other People',
21
+ 'M*A*S*H',
22
+ '100% Wolf',
23
+ 'Snake_Eyes',
24
+ ];
25
+ const find = (raw: string, options = {}) => {
26
+ const { test } = createSearchMatcher(raw, options);
27
+ return TITLES.filter(test);
28
+ };
29
+
30
+ describe('the owner’s examples (flix, 2026-09-28)', () => {
31
+ it('"the*the" finds a title with `the` twice, and not one with it once', () => {
32
+ // The real title is "The Hunt for Red October" — ONE `the` — so it is
33
+ // correctly absent; the request remembered it as "…for the red October".
34
+ expect(find('the*the')).toEqual(['The Agony and the Ecstasy']);
35
+ expect(createSearchMatcher('the*the').test('The hunt for the red October')).toBe(true);
36
+ });
37
+ it('"hunt*red" finds The Hunt for Red October — contains, not starts-with', () => {
38
+ expect(find('hunt*red')).toEqual(['The Hunt for Red October']);
39
+ });
40
+ it('"*clov*" finds 10 Cloverfield Lane, exactly as "clov" does', () => {
41
+ expect(find('*clov*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
42
+ expect(find('clov')).toEqual(find('*clov*'));
43
+ });
44
+ it('order matters: "red*hunt" does not find The Hunt for Red October', () => {
45
+ expect(find('red*hunt')).toEqual([]);
46
+ });
47
+ it('a star reaches INSIDE a word — "*over*" finds Cloverfield', () => {
48
+ expect(find('*over*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
49
+ });
50
+ });
51
+
52
+ describe('parseSearchPattern', () => {
53
+ it('is empty for blank text and for stars alone', () => {
54
+ expect(parseSearchPattern(' ').kind).toBe('empty');
55
+ expect(parseSearchPattern('*').kind).toBe('empty');
56
+ expect(parseSearchPattern(' ** ').kind).toBe('empty');
57
+ });
58
+ it('is plain without a star, trimmed and whitespace-collapsed', () => {
59
+ expect(parseSearchPattern(' the office ')).toEqual({ kind: 'plain', raw: ' the office ', text: 'the office' });
60
+ });
61
+ it('splits on the star and keeps spaces as literal', () => {
62
+ const p = parseSearchPattern('the *the');
63
+ expect(p).toMatchObject({ kind: 'wildcard', parts: ['the ', 'the'] });
64
+ });
65
+ it('drops a whitespace-only part, which would otherwise demand two gaps', () => {
66
+ expect(parseSearchPattern('hunt * * red')).toMatchObject({ kind: 'wildcard', parts: ['hunt ', ' red'] });
67
+ expect(find('hunt * * red')).toEqual(['The Hunt for Red October']);
68
+ });
69
+ it('with wildcard OFF, a star is plain text', () => {
70
+ expect(parseSearchPattern('m*a*s*h', { wildcard: false })).toMatchObject({ kind: 'plain', text: 'm*a*s*h' });
71
+ expect(find('m*a*s*h', { wildcard: false })).toEqual(['M*A*S*H']);
72
+ });
73
+ it('regex is OFF by default — /…/ is plain text', () => {
74
+ expect(parseSearchPattern('/hunt.+red/').kind).toBe('plain');
75
+ expect(parseSearchPattern('/hunt.*red/').kind).toBe('wildcard');
76
+ });
77
+ });
78
+
79
+ describe('regex (opt-in)', () => {
80
+ const on = { regex: true };
81
+ it('compiles /source/flags, always case-insensitive, never stateful', () => {
82
+ expect(parseSearchPattern('/^the (mask|agony)/g', on)).toMatchObject({ kind: 'regex', source: '^the (mask|agony)', flags: 'i' });
83
+ expect(find('/^the (mask|agony)/g', on)).toEqual(['The Mask', 'The Agony and the Ecstasy']);
84
+ });
85
+ it('is invalid — and matches nothing — when it will not compile', () => {
86
+ const p = parseSearchPattern('/(unclosed/', on);
87
+ expect(p).toMatchObject({ kind: 'invalid', syntax: 'regex' });
88
+ expect(matchesSearchPattern(p, 'anything')).toBe(false);
89
+ expect(find('/(unclosed/', on)).toEqual([]);
90
+ });
91
+ it('names an unknown flag', () => {
92
+ expect(parseSearchPattern('/a/q', on)).toMatchObject({ kind: 'invalid', message: 'Unknown regex flag "q"' });
93
+ });
94
+ it('does not mistake a slash inside a title for a regex', () => {
95
+ expect(parseSearchPattern('AC/DC', on).kind).toBe('plain');
96
+ });
97
+ });
98
+
99
+ describe('searchPatternToRegExp', () => {
100
+ it('escapes regex metacharacters in plain and wildcard text', () => {
101
+ expect(find('100%')).toEqual(['100% Wolf']);
102
+ expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('A.B then (C)')).toBe(true);
103
+ expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('axb then (c)')).toBe(false);
104
+ });
105
+ it('lets a gap cross a newline', () => {
106
+ expect(matchesSearchPattern(parseSearchPattern('first*second'), 'first line\nsecond line')).toBe(true);
107
+ });
108
+ it('is null for empty and invalid', () => {
109
+ expect(searchPatternToRegExp(parseSearchPattern(''))).toBeNull();
110
+ expect(searchPatternToRegExp(parseSearchPattern('/(/', { regex: true }))).toBeNull();
111
+ });
112
+ });
113
+
114
+ describe('searchPatternToLike', () => {
115
+ it('is a contains for plain text and an ordered contains for a wildcard', () => {
116
+ expect(searchPatternToLike(parseSearchPattern('the office'))).toBe('%the office%');
117
+ expect(searchPatternToLike(parseSearchPattern('the*the'))).toBe('%the%the%');
118
+ expect(searchPatternToLike(parseSearchPattern('*clov*'))).toBe('%clov%');
119
+ });
120
+ it('escapes LIKE’s own wildcards and the escape character itself', () => {
121
+ expect(searchPatternToLike(parseSearchPattern('100%*snake_eyes'))).toBe('%100\\%%snake\\_eyes%');
122
+ expect(searchPatternToLike(parseSearchPattern('a\\b'))).toBe('%a\\\\b%');
123
+ expect(searchPatternToLike(parseSearchPattern('50%'), '!')).toBe('%50!%%');
124
+ });
125
+ it('has no form for empty, regex or invalid — the caller decides', () => {
126
+ expect(searchPatternToLike(parseSearchPattern(''))).toBeNull();
127
+ expect(searchPatternToLike(parseSearchPattern('/a/', { regex: true }))).toBeNull();
128
+ expect(searchPatternToLike(parseSearchPattern('/(/', { regex: true }))).toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * What a search box's text MEANS — plain words, a `*` wildcard, or a regular
3
+ * expression — decided once, in one place, for the browser and the server alike.
4
+ *
5
+ * ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
6
+ * *"type * to mean any character in the search. Then if I know two words in a
7
+ * movie but not what is between them I can put the * between them like
8
+ * "the*the" could pull up "The hunt for the red October" … but not … "The mask".
9
+ * Also "hunt*red" would find "The hunt for the red October".*
10
+ *
11
+ * A word index cannot answer that: it knows the title contains `the`, not that it
12
+ * contains it TWICE, and not in which order its words came. So a `*` turns the
13
+ * search into an ordered substring match — `the*the` is "`the`, then anything,
14
+ * then `the`" — and a search without one is untouched, because every app already
15
+ * has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
16
+ * `src/server/library.ts`).
17
+ *
18
+ * ── The semantics, all of them ──────────────────────────────────────────────
19
+ * - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
20
+ * although the title does not START with `hunt`. So leading and trailing stars
21
+ * are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
22
+ * starred forms are WILDCARD searches, which is what lets an app answer `*over*`
23
+ * with *Cloverfield* (a substring inside a word) where its word index could not.
24
+ * - **Literal between the stars.** Spaces included, so `the *the` asks for a
25
+ * `the` followed by a space — a power user's word boundary, and nobody else
26
+ * notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
27
+ * it would demand two separate gaps and fail on titles it plainly describes.
28
+ * - **Case-insensitive, always** — in the matcher and the regex alike. A search
29
+ * box that cares about capitals reads as broken.
30
+ * - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
31
+ * form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
32
+ * in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
33
+ * and a server that ever evaluates one must bound it — a viewer-typed regex on a
34
+ * shared server is a denial-of-service vector.
35
+ *
36
+ * ── How an app uses it ──────────────────────────────────────────────────────
37
+ * {@link parseSearchPattern} is the whole decision; everything else derives from
38
+ * its result. An app that filters in memory calls {@link createSearchMatcher}. An
39
+ * app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
40
+ * keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
41
+ * `cursedbelt/react` parses with the SAME function, so the badge it shows is the
42
+ * syntax the query actually ran in.
43
+ *
44
+ * Isomorphic and dependency-free — it imports nothing.
45
+ */
46
+
47
+ /** A syntax an app can switch on beyond plain words. */
48
+ export type SearchSyntax = 'wildcard' | 'regex';
49
+
50
+ /** Which syntaxes a search box understands. Absent means the defaults below. */
51
+ export interface SearchSyntaxOptions {
52
+ /** `*` stands for any run of characters (including none). Default **on**. */
53
+ wildcard?: boolean;
54
+ /** `/source/flags` is a regular expression. Default **off**. */
55
+ regex?: boolean;
56
+ }
57
+
58
+ export const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>> = Object.freeze({
59
+ wildcard: true,
60
+ regex: false,
61
+ });
62
+
63
+ /** The wildcard character. One constant, so the help text can never disagree with the parser. */
64
+ export const SEARCH_WILDCARD = '*';
65
+
66
+ export type SearchPattern =
67
+ /** Nothing to search for — show everything. A lone `*` is this too. */
68
+ | { kind: 'empty'; raw: string }
69
+ /** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
70
+ | { kind: 'plain'; raw: string; text: string }
71
+ /** `parts` must appear in this order, anything between them. Never empty. */
72
+ | { kind: 'wildcard'; raw: string; text: string; parts: string[] }
73
+ | { kind: 'regex'; raw: string; source: string; flags: string }
74
+ /** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
75
+ | { kind: 'invalid'; raw: string; syntax: SearchSyntax; message: string };
76
+
77
+ const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
78
+ /** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
79
+ const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
80
+
81
+ const collapse = (text: string): string => text.replace(/\s+/g, ' ');
82
+
83
+ /** Decide what a search box's text means under `options`. */
84
+ export function parseSearchPattern(raw: string, options: SearchSyntaxOptions = {}): SearchPattern {
85
+ const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
86
+ const text = collapse(raw.trim());
87
+ if (text.length === 0) return { kind: 'empty', raw };
88
+
89
+ if (syntax.regex) {
90
+ const form = REGEX_FORM.exec(text);
91
+ if (form) {
92
+ const [, source = '', given = ''] = form;
93
+ const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
94
+ if (unknown) {
95
+ return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
96
+ }
97
+ const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
98
+ try {
99
+ new RegExp(source, flags);
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ return { kind: 'invalid', raw, syntax: 'regex', message };
103
+ }
104
+ return { kind: 'regex', raw, source, flags };
105
+ }
106
+ }
107
+
108
+ if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
109
+ const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
110
+ if (parts.length === 0) return { kind: 'empty', raw };
111
+ return { kind: 'wildcard', raw, text, parts };
112
+ }
113
+
114
+ return { kind: 'plain', raw, text };
115
+ }
116
+
117
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
118
+
119
+ /**
120
+ * The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
121
+ * nothing (`invalid`). Plain text becomes a case-insensitive contains.
122
+ */
123
+ export function searchPatternToRegExp(pattern: SearchPattern): RegExp | null {
124
+ switch (pattern.kind) {
125
+ case 'empty':
126
+ case 'invalid':
127
+ return null;
128
+ case 'plain':
129
+ return new RegExp(escapeRegExp(pattern.text), 'i');
130
+ case 'wildcard':
131
+ // `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
132
+ // multi-line field (a summary, a note) without needing the `s` flag.
133
+ return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
134
+ case 'regex':
135
+ return new RegExp(pattern.source, pattern.flags);
136
+ }
137
+ }
138
+
139
+ /** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
140
+ export function matchesSearchPattern(pattern: SearchPattern, text: string): boolean {
141
+ if (pattern.kind === 'empty') return true;
142
+ return searchPatternToRegExp(pattern)?.test(text) ?? false;
143
+ }
144
+
145
+ /**
146
+ * Parse once, compile once, then test many — the shape an in-memory list filter
147
+ * wants. `matcher(title)` for every row costs one regex test, not one parse.
148
+ */
149
+ export function createSearchMatcher(
150
+ raw: string,
151
+ options: SearchSyntaxOptions = {},
152
+ ): { pattern: SearchPattern; test: (text: string) => boolean } {
153
+ const pattern = parseSearchPattern(raw, options);
154
+ if (pattern.kind === 'empty') return { pattern, test: () => true };
155
+ const re = searchPatternToRegExp(pattern);
156
+ return { pattern, test: re ? (text) => re.test(text) : () => false };
157
+ }
158
+
159
+ /**
160
+ * The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
161
+ * say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
162
+ * and `invalid`: none of those has a `LIKE` form, and the caller decides what
163
+ * each means rather than being handed a pattern that silently matches too much.
164
+ *
165
+ * Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
166
+ * a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
167
+ */
168
+ export function searchPatternToLike(pattern: SearchPattern, escape = '\\'): string | null {
169
+ const literal = (text: string) => text.replace(/[%_]/g, (c) => `${escape}${c}`);
170
+ const escapeAll = (text: string) => literal(text.split(escape).join(`${escape}${escape}`));
171
+ switch (pattern.kind) {
172
+ case 'plain':
173
+ return `%${escapeAll(pattern.text)}%`;
174
+ case 'wildcard':
175
+ return `%${pattern.parts.map(escapeAll).join('%')}%`;
176
+ default:
177
+ return null;
178
+ }
179
+ }
@@ -250,16 +250,20 @@ console.log(JSON.stringify(out));
250
250
  `,
251
251
  );
252
252
 
253
- const child = Bun.spawnSync(['bun', 'run', 'entry.ts'], {
253
+ const child = Bun.spawn(['bun', 'run', 'entry.ts'], {
254
254
  cwd: root,
255
+ stdin: 'ignore',
255
256
  stdout: 'pipe',
256
257
  stderr: 'pipe',
257
258
  });
258
- const stdout = child.stdout.toString().trim();
259
- if (child.exitCode !== 0 || stdout === '') {
260
- throw new Error(
261
- `the fixture could not run at all (exit ${child.exitCode}) — that is a broken check, not a passing one:\n${child.stderr.toString()}`,
262
- );
259
+ const [stdoutRaw, stderr, exitCode] = await Promise.all([
260
+ new Response(child.stdout).text(),
261
+ new Response(child.stderr).text(),
262
+ child.exited,
263
+ ]);
264
+ const stdout = stdoutRaw.trim();
265
+ if (exitCode !== 0 || stdout === '') {
266
+ throw new Error(`the fixture could not run at all (exit ${exitCode}) — that is a broken check, not a passing one:\n${stderr}`);
263
267
  }
264
268
  return JSON.parse(stdout) as Record<string, Outcome>;
265
269
  };
@@ -23,7 +23,6 @@
23
23
  * to skip it. Ignored-vs-not is the property that actually regresses.
24
24
  */
25
25
  import { describe, expect, test } from 'bun:test'
26
- import { spawnSync } from 'node:child_process'
27
26
  import { existsSync } from 'node:fs'
28
27
  import { fileURLToPath } from 'node:url'
29
28
 
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
37
36
  ).toBe(true)
38
37
  })
39
38
 
40
- test('git is not told to ignore it', () => {
41
- const git = spawnSync('git', ['check-ignore', '-q', 'bun.lock'], { cwd: repoRoot })
42
- if (git.error) return // no git on this host: nothing to assert about ignore rules
39
+ test('git is not told to ignore it', async () => {
40
+ let status: number
41
+ try {
42
+ const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
43
+ status = await child.exited
44
+ } catch {
45
+ return // no git on this host: nothing to assert about ignore rules
46
+ }
43
47
 
44
48
  // exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
45
49
  expect(
46
- git.status,
50
+ status,
47
51
  'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
48
52
  ).not.toBe(0)
49
53
  })
@@ -25,7 +25,6 @@
25
25
  * when `git` cannot answer — a check that silently degrades to green is worse than none.
26
26
  */
27
27
  import { describe, expect, test } from 'bun:test';
28
- import { spawnSync } from 'node:child_process';
29
28
  import { existsSync } from 'node:fs';
30
29
  import { fileURLToPath } from 'node:url';
31
30
  import pkg from '../package.json';
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
36
35
  const shippedDirs = (): string[] =>
37
36
  ((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
38
37
 
39
- const git = (...args: string[]): { ok: boolean; out: string } => {
40
- const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
41
- return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
38
+ const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
39
+ const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
40
+ const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
41
+ return { ok: code === 0, out: out.trim() };
42
42
  };
43
43
 
44
44
  describe('every file inside the published surface is tracked', () => {
45
- test('git can answer at all — a degraded check must refuse, not pass', () => {
46
- expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
45
+ test('git can answer at all — a degraded check must refuse, not pass', async () => {
46
+ expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
47
47
  });
48
48
 
49
49
  test('package.json names a shippable directory to scan', () => {
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
52
52
  expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
53
53
  });
54
54
 
55
- test('no untracked file sits inside a directory `files` ships', () => {
55
+ test('no untracked file sits inside a directory `files` ships', async () => {
56
56
  // --others = untracked; --exclude-standard honours .gitignore, so deliberately
57
57
  // ignored paths (.agent.noindex, node_modules) are not offences.
58
- const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
58
+ const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
59
59
  expect(ok, 'git ls-files failed').toBe(true);
60
60
  const untracked = out === '' ? [] : out.split('\n');
61
61
  expect(