rich-input 1.2.2 → 1.4.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.
@@ -3,14 +3,187 @@
3
3
  * Handles keyword:value tokenization, caret context inspection, and suggestion application.
4
4
  */
5
5
 
6
+ export const DEFAULT_OPERATORS = ['-'];
7
+ export const DEFAULT_COMBINATORS = [];
8
+
9
+ /**
10
+ * Normalizes an operators configuration (string or array) into an array of non-empty operator strings.
11
+ * @param {string|string[]|null|undefined} operators
12
+ * @returns {string[]}
13
+ */
14
+ export function normalizeOperators(operators) {
15
+ if (operators === undefined) {
16
+ return [...DEFAULT_OPERATORS];
17
+ }
18
+ if (operators === null || operators === '') {
19
+ return [];
20
+ }
21
+ const rawList = Array.isArray(operators)
22
+ ? operators
23
+ : typeof operators === 'string'
24
+ ? operators.split(/\s+/)
25
+ : [];
26
+
27
+ const result = [];
28
+ const seen = new Set();
29
+ for (const item of rawList) {
30
+ if (typeof item !== 'string') continue;
31
+ const parts = item.trim().split(/\s+/).filter(Boolean);
32
+ for (let part of parts) {
33
+ part = part.replace(/^(.)\1+$/, '$1');
34
+ if (!seen.has(part)) {
35
+ seen.add(part);
36
+ result.push(part);
37
+ }
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Normalizes a combinators configuration (string or array) into an array of non-empty combinator strings.
45
+ * @param {string|string[]|null|undefined} combinators
46
+ * @returns {string[]}
47
+ */
48
+ export function normalizeCombinators(combinators) {
49
+ if (combinators === undefined) {
50
+ return [...DEFAULT_COMBINATORS];
51
+ }
52
+ if (combinators === null || combinators === '') {
53
+ return [];
54
+ }
55
+ const rawList = Array.isArray(combinators)
56
+ ? combinators
57
+ : typeof combinators === 'string'
58
+ ? combinators.split(/\s+/)
59
+ : [];
60
+
61
+ const result = [];
62
+ const seen = new Set();
63
+ for (const item of rawList) {
64
+ if (typeof item !== 'string') continue;
65
+ const parts = item.trim().split(/\s+/).filter(Boolean);
66
+ for (const part of parts) {
67
+ if (!seen.has(part)) {
68
+ seen.add(part);
69
+ result.push(part);
70
+ }
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Matches if a string starts with any of the configured operators (longest match first).
78
+ * @param {string} str
79
+ * @param {string[]} normalizedOperators
80
+ * @returns {string|null}
81
+ */
82
+ function matchOperator(str, normalizedOperators) {
83
+ if (!str || !normalizedOperators || normalizedOperators.length === 0) {
84
+ return null;
85
+ }
86
+ const sorted = [...normalizedOperators].sort((a, b) => b.length - a.length);
87
+ for (const op of sorted) {
88
+ if (str.startsWith(op)) {
89
+ return op;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export const DEFAULT_DELIMITERS = ['()'];
96
+
97
+ /**
98
+ * Normalizes delimiters input into a deduplicated array of delimiter pair strings (e.g. ['()', '{}', '[]']).
99
+ * @param {string|string[]|null|undefined} delimiters
100
+ * @returns {string[]}
101
+ */
102
+ export function normalizeDelimiters(delimiters) {
103
+ if (delimiters === undefined) {
104
+ return [...DEFAULT_DELIMITERS];
105
+ }
106
+ if (delimiters === null || delimiters === '') {
107
+ return [];
108
+ }
109
+ const rawList = Array.isArray(delimiters)
110
+ ? delimiters
111
+ : typeof delimiters === 'string'
112
+ ? delimiters.split(/\s+/)
113
+ : [];
114
+
115
+ const flatTokens = [];
116
+ for (const item of rawList) {
117
+ if (typeof item !== 'string') continue;
118
+ const parts = item.trim().split(/\s+/).filter(Boolean);
119
+ flatTokens.push(...parts);
120
+ }
121
+
122
+ const result = [];
123
+ const seen = new Set();
124
+
125
+ for (let i = 0; i < flatTokens.length; i++) {
126
+ let token = flatTokens[i];
127
+ if (token.length === 1 && i + 1 < flatTokens.length && flatTokens[i + 1].length === 1) {
128
+ token = token + flatTokens[i + 1];
129
+ i++;
130
+ } else if (token.length > 2) {
131
+ token = token.replace(/(.)\1+/g, '$1');
132
+ }
133
+ if (token.length >= 2 && !seen.has(token)) {
134
+ seen.add(token);
135
+ result.push(token);
136
+ }
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ function buildDelimiterEntries(normalizedDelims) {
143
+ const entries = [];
144
+ if (!normalizedDelims || normalizedDelims.length === 0) return entries;
145
+ for (const pair of normalizedDelims) {
146
+ if (typeof pair !== 'string' || pair.length < 2) continue;
147
+ const open = pair[0];
148
+ const close = pair[pair.length - 1];
149
+ entries.push({ delimiter: open, role: 'open', pair });
150
+ if (close !== open) {
151
+ entries.push({ delimiter: close, role: 'close', pair });
152
+ }
153
+ }
154
+ return entries;
155
+ }
156
+
157
+ function matchDelimiter(str, delimEntries) {
158
+ if (!str || !delimEntries || delimEntries.length === 0) return null;
159
+ for (const entry of delimEntries) {
160
+ if (str.startsWith(entry.delimiter)) {
161
+ return entry;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
6
167
  /**
7
168
  * Parses raw search input into token objects.
8
169
  * @param {string} inputStr
170
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
171
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
172
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
9
173
  * @returns {Array<Object>}
10
174
  */
11
- export function parseSearchTokens(inputStr) {
175
+ export function parseSearchTokens(
176
+ inputStr,
177
+ operators = DEFAULT_OPERATORS,
178
+ combinators = DEFAULT_COMBINATORS,
179
+ delimiters = DEFAULT_DELIMITERS
180
+ ) {
12
181
  if (typeof inputStr !== 'string') return [];
13
182
 
183
+ const normalizedOps = normalizeOperators(operators);
184
+ const normalizedCombs = normalizeCombinators(combinators);
185
+ const normalizedDelims = normalizeDelimiters(delimiters);
186
+ const delimEntries = buildDelimiterEntries(normalizedDelims);
14
187
  const tokens = [];
15
188
  let i = 0;
16
189
  const len = inputStr.length;
@@ -31,17 +204,49 @@ export function parseSearchTokens(inputStr) {
31
204
  continue;
32
205
  }
33
206
 
207
+ // 1b. Delimiter
208
+ const matchedDelim = matchDelimiter(inputStr.slice(i), delimEntries);
209
+ if (matchedDelim) {
210
+ tokens.push({
211
+ type: 'delimiter',
212
+ delimiter: matchedDelim.delimiter,
213
+ role: matchedDelim.role,
214
+ pair: matchedDelim.pair,
215
+ raw: matchedDelim.delimiter,
216
+ start: i,
217
+ end: i + matchedDelim.delimiter.length,
218
+ });
219
+ i += matchedDelim.delimiter.length;
220
+ continue;
221
+ }
222
+
34
223
  const tokenStart = i;
35
224
 
36
- // 2. Check for keyword pattern: [a-zA-Z0-9_-]+:
225
+ // 2. Check for optional operator followed by keyword pattern: [a-zA-Z0-9][a-zA-Z0-9_-]*:
37
226
  const remaining = inputStr.slice(i);
38
- const colonMatch = remaining.match(/^([a-zA-Z0-9_-]+):/);
227
+ const matchedOp = matchOperator(remaining, normalizedOps);
228
+ let colonMatch = null;
229
+ let opLen = 0;
230
+
231
+ if (matchedOp) {
232
+ const afterOp = remaining.slice(matchedOp.length);
233
+ const afterOpMatch = afterOp.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
234
+ if (afterOpMatch) {
235
+ colonMatch = afterOpMatch;
236
+ opLen = matchedOp.length;
237
+ }
238
+ } else {
239
+ colonMatch = remaining.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
240
+ }
39
241
 
40
242
  if (colonMatch) {
41
243
  const keyword = colonMatch[1];
42
- const keywordStart = i;
43
- const colonIndex = i + keyword.length;
44
- i += colonMatch[0].length; // Move index past ':'
244
+ const operator = matchedOp || null;
245
+ const operatorStart = operator ? tokenStart : null;
246
+ const operatorEnd = operator ? tokenStart + opLen : null;
247
+ const keywordStart = tokenStart + opLen;
248
+ const colonIndex = keywordStart + keyword.length;
249
+ i = colonIndex + 1; // Move index past ':'
45
250
 
46
251
  const valueStart = i;
47
252
  let quoted = false;
@@ -67,9 +272,13 @@ export function parseSearchTokens(inputStr) {
67
272
  i++; // skip closing quote
68
273
  }
69
274
  } else {
70
- // Unquoted value: until next whitespace or end
275
+ // Unquoted value: until next whitespace, delimiter, or end
71
276
  innerStart = i;
72
- while (i < len && !/\s/.test(inputStr[i])) {
277
+ while (
278
+ i < len &&
279
+ !/\s/.test(inputStr[i]) &&
280
+ !matchDelimiter(inputStr.slice(i), delimEntries)
281
+ ) {
73
282
  i++;
74
283
  }
75
284
  innerEnd = i;
@@ -84,6 +293,9 @@ export function parseSearchTokens(inputStr) {
84
293
  raw: inputStr.slice(tokenStart, i),
85
294
  start: tokenStart,
86
295
  end: i,
296
+ operator,
297
+ operatorStart,
298
+ operatorEnd,
87
299
  keyword,
88
300
  keywordLower: keyword.toLowerCase(),
89
301
  keywordStart,
@@ -100,16 +312,38 @@ export function parseSearchTokens(inputStr) {
100
312
  isClosed,
101
313
  });
102
314
  } else {
103
- // 3. Plain text token (single word or unfinished keyword)
104
- while (i < len && !/\s/.test(inputStr[i])) {
315
+ // 3. Standalone word token (combinator or plain text)
316
+ while (
317
+ i < len &&
318
+ !/\s/.test(inputStr[i]) &&
319
+ !matchDelimiter(inputStr.slice(i), delimEntries)
320
+ ) {
105
321
  i++;
106
322
  }
107
- tokens.push({
108
- type: 'text',
109
- raw: inputStr.slice(tokenStart, i),
110
- start: tokenStart,
111
- end: i,
112
- });
323
+ const raw = inputStr.slice(tokenStart, i);
324
+ if (normalizedCombs.includes(raw)) {
325
+ tokens.push({
326
+ type: 'combinator',
327
+ combinator: raw,
328
+ raw,
329
+ start: tokenStart,
330
+ end: i,
331
+ });
332
+ } else {
333
+ const textOp = matchOperator(raw, normalizedOps);
334
+ const textToken = {
335
+ type: 'text',
336
+ raw,
337
+ start: tokenStart,
338
+ end: i,
339
+ };
340
+ if (textOp) {
341
+ textToken.operator = textOp;
342
+ textToken.operatorStart = tokenStart;
343
+ textToken.operatorEnd = tokenStart + textOp.length;
344
+ }
345
+ tokens.push(textToken);
346
+ }
113
347
  }
114
348
  }
115
349
 
@@ -117,31 +351,23 @@ export function parseSearchTokens(inputStr) {
117
351
  }
118
352
 
119
353
  /**
120
- * Parses search query into a structured object with keywords and free text.
354
+ * Parses search query into a structured object with the raw string and lexical tokens.
121
355
  * @param {string} inputStr
122
- * @returns {{ raw: string, text: string, keywords: Record<string, string[]>, tokens: Array<Object> }}
356
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
357
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
358
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
359
+ * @returns {{ raw: string, tokens: Array<Object> }}
123
360
  */
124
- export function parseSearchQuery(inputStr) {
125
- const tokens = parseSearchTokens(inputStr);
126
- const keywords = {};
127
- const textWords = [];
128
-
129
- for (const token of tokens) {
130
- if (token.type === 'keyword') {
131
- const kw = token.keywordLower;
132
- if (!keywords[kw]) {
133
- keywords[kw] = [];
134
- }
135
- keywords[kw].push(token.innerValue);
136
- } else if (token.type === 'text') {
137
- textWords.push(token.raw);
138
- }
139
- }
361
+ export function parseSearchQuery(
362
+ inputStr,
363
+ operators = DEFAULT_OPERATORS,
364
+ combinators = DEFAULT_COMBINATORS,
365
+ delimiters = DEFAULT_DELIMITERS
366
+ ) {
367
+ const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
140
368
 
141
369
  return {
142
370
  raw: inputStr,
143
- text: textWords.join(' '),
144
- keywords,
145
371
  tokens,
146
372
  };
147
373
  }
@@ -151,28 +377,41 @@ export function parseSearchQuery(inputStr) {
151
377
  * @param {string} inputStr
152
378
  * @param {number} caretPos
153
379
  * @param {Map<string, Object>|Object} configuredKeywords
380
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
381
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
382
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
154
383
  * @returns {Object}
155
384
  */
156
- export function getCaretContext(inputStr, caretPos, configuredKeywords) {
385
+ export function getCaretContext(
386
+ inputStr,
387
+ caretPos,
388
+ configuredKeywords,
389
+ operators = DEFAULT_OPERATORS,
390
+ combinators = DEFAULT_COMBINATORS,
391
+ delimiters = DEFAULT_DELIMITERS
392
+ ) {
157
393
  if (typeof inputStr !== 'string') inputStr = '';
158
394
  caretPos = Math.max(0, Math.min(caretPos || 0, inputStr.length));
159
395
 
160
- const tokens = parseSearchTokens(inputStr);
396
+ const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
161
397
 
162
- // Find token at caret
398
+ // Find token at caret (skip whitespace and delimiter boundary tokens)
163
399
  let activeToken = null;
164
400
  for (const token of tokens) {
401
+ if (token.type === 'whitespace' || token.type === 'delimiter') continue;
165
402
  if (caretPos >= token.start && caretPos <= token.end) {
166
403
  activeToken = token;
167
404
  break;
168
405
  }
169
406
  }
170
407
 
171
- // If caret is in whitespace or between tokens
172
- if (!activeToken || activeToken.type === 'whitespace') {
173
- // Check if the previous token was a colon without value or if we're in open space
408
+ // If caret is in whitespace, delimiter, or empty input (at the start of a new token)
409
+ if (!activeToken || activeToken.type === 'whitespace' || activeToken.type === 'delimiter') {
174
410
  return {
175
- mode: 'none',
411
+ mode: 'keyword',
412
+ query: '',
413
+ replaceStart: caretPos,
414
+ replaceEnd: caretPos,
176
415
  caretPos,
177
416
  tokens,
178
417
  };
@@ -183,21 +422,25 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
183
422
  if (caretPos <= activeToken.colonIndex) {
184
423
  // User is editing the keyword name (filter based on full keyword name, not caret position)
185
424
  const query = activeToken.keyword;
186
- return {
425
+ const context = {
187
426
  mode: 'keyword',
188
427
  query,
189
- replaceStart: activeToken.start,
428
+ replaceStart: activeToken.keywordStart,
190
429
  replaceEnd: activeToken.colonIndex + 1,
191
430
  caretPos,
192
431
  token: activeToken,
193
432
  tokens,
194
433
  };
434
+ if (activeToken.operator) {
435
+ context.operator = activeToken.operator;
436
+ }
437
+ return context;
195
438
  } else {
196
439
  // User is editing the keyword value (filter based on full value string, not caret position)
197
440
  const isQuoted = activeToken.quoted;
198
441
  const value = activeToken.innerValue;
199
442
 
200
- return {
443
+ const context = {
201
444
  mode: 'value',
202
445
  keyword: activeToken.keyword,
203
446
  keywordLower: activeToken.keywordLower,
@@ -212,11 +455,41 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
212
455
  token: activeToken,
213
456
  tokens,
214
457
  };
458
+ if (activeToken.operator) {
459
+ context.operator = activeToken.operator;
460
+ }
461
+ return context;
215
462
  }
216
463
  }
217
464
 
218
- // 2. Caret is within a plain text token (filter based on full word, not caret position)
465
+ // 2. Caret is within a combinator token
466
+ if (activeToken.type === 'combinator') {
467
+ return {
468
+ mode: 'keyword',
469
+ query: activeToken.raw,
470
+ replaceStart: activeToken.start,
471
+ replaceEnd: activeToken.end,
472
+ caretPos,
473
+ token: activeToken,
474
+ tokens,
475
+ };
476
+ }
477
+
478
+ // 3. Caret is within a plain text token (filter based on full word, not caret position)
219
479
  if (activeToken.type === 'text') {
480
+ if (activeToken.operator) {
481
+ const query = activeToken.raw.slice(activeToken.operator.length);
482
+ return {
483
+ mode: 'keyword',
484
+ operator: activeToken.operator,
485
+ query,
486
+ replaceStart: activeToken.operatorEnd,
487
+ replaceEnd: activeToken.end,
488
+ caretPos,
489
+ token: activeToken,
490
+ tokens,
491
+ };
492
+ }
220
493
  const query = activeToken.raw;
221
494
  return {
222
495
  mode: 'keyword',
@@ -240,9 +513,10 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
240
513
  * Generates suggestions for the given caret context.
241
514
  * @param {Object} context
242
515
  * @param {Map<string, Object>} configuredKeywords
516
+ * @param {string|string[]} [configuredCombinators=DEFAULT_COMBINATORS]
243
517
  * @returns {Array<Object>}
244
518
  */
245
- export function getSuggestions(context, configuredKeywords) {
519
+ export function getSuggestions(context, configuredKeywords, configuredCombinators = DEFAULT_COMBINATORS) {
246
520
  if (!context || context.mode === 'none') return [];
247
521
 
248
522
  const suggestions = [];
@@ -252,7 +526,7 @@ export function getSuggestions(context, configuredKeywords) {
252
526
  for (const [id, kw] of configuredKeywords.entries()) {
253
527
  const idLower = id.toLowerCase();
254
528
  const labelLower = (kw.label || '').toLowerCase();
255
- const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q) || idLower.includes(q);
529
+ const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q);
256
530
 
257
531
  if (matches) {
258
532
  suggestions.push({
@@ -266,6 +540,44 @@ export function getSuggestions(context, configuredKeywords) {
266
540
  });
267
541
  }
268
542
  }
543
+
544
+ if (!context.operator) {
545
+ const normalizedCombs = normalizeCombinators(configuredCombinators);
546
+ if (normalizedCombs.length > 0) {
547
+ let shouldIncludeCombinators = true;
548
+ if (!q && Array.isArray(context.tokens)) {
549
+ const precedingTokens = context.tokens.filter(
550
+ (t) => t.type !== 'whitespace' && t.end <= context.replaceStart
551
+ );
552
+ const lastPreceding = precedingTokens[precedingTokens.length - 1];
553
+ // Don't suggest combinators at the very start of an empty query (unless typing a query prefix),
554
+ // immediately after another combinator, or immediately after an opening delimiter
555
+ if (
556
+ precedingTokens.length === 0 ||
557
+ lastPreceding?.type === 'combinator' ||
558
+ (lastPreceding?.type === 'delimiter' && lastPreceding?.role === 'open')
559
+ ) {
560
+ shouldIncludeCombinators = false;
561
+ }
562
+ }
563
+ if (shouldIncludeCombinators) {
564
+ for (const comb of normalizedCombs) {
565
+ const combLower = comb.toLowerCase();
566
+ if (!q || combLower.startsWith(q)) {
567
+ suggestions.push({
568
+ type: 'combinator',
569
+ id: comb,
570
+ combinator: comb,
571
+ label: comb,
572
+ display: comb,
573
+ insertText: comb,
574
+ description: 'Combinator',
575
+ });
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
269
581
  } else if (context.mode === 'value') {
270
582
  const kwConfig = configuredKeywords.get(context.keywordLower);
271
583
  if (!kwConfig) return [];
@@ -320,11 +632,25 @@ export function applySuggestion(inputStr, suggestion, context) {
320
632
  let after = inputStr.slice(replaceEnd);
321
633
  let insertText = suggestion.insertText;
322
634
 
635
+ const followingToken = Array.isArray(context.tokens)
636
+ ? context.tokens.find((t) => t.start === replaceEnd)
637
+ : null;
638
+ const isFollowedByCloseDelimiter =
639
+ followingToken?.type === 'delimiter' && followingToken?.role === 'close';
640
+
323
641
  if (suggestion.type === 'keyword') {
324
642
  // When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
325
643
  // If after text starts with colon, remove it to avoid `mix::`
326
644
  if (after.startsWith(':')) {
327
645
  after = after.slice(1);
646
+ } else if (
647
+ context.token?.type !== 'keyword' &&
648
+ after.length > 0 &&
649
+ !/^\s/.test(after) &&
650
+ !isFollowedByCloseDelimiter
651
+ ) {
652
+ // Ensure space before following token so it doesn't become the value of this keyword
653
+ after = ' ' + after;
328
654
  }
329
655
  const newValue = before + insertText + after;
330
656
  const newCaret = before.length + insertText.length;
@@ -332,13 +658,14 @@ export function applySuggestion(inputStr, suggestion, context) {
332
658
  }
333
659
 
334
660
  // When inserting a value (e.g. `"We Play House Recordings"`)
335
- // Add a trailing space if after does not already start with whitespace
336
- if (after.length === 0 || !/^\s/.test(after)) {
661
+ // Add a trailing space if after does not already start with whitespace or a closing delimiter
662
+ const hasLeadingSpaceAfter = /^\s/.test(after);
663
+ if ((after.length === 0 || !hasLeadingSpaceAfter) && !isFollowedByCloseDelimiter) {
337
664
  insertText += ' ';
338
665
  }
339
666
 
340
667
  const newValue = before + insertText + after;
341
- const newCaret = before.length + insertText.length;
668
+ const newCaret = before.length + insertText.length + (hasLeadingSpaceAfter ? 1 : 0);
342
669
  return { newValue, newCaret };
343
670
  }
344
671