@bsky.app/tapper 0.5.7 → 0.6.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @bsky.app/tapper
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#55](https://github.com/bluesky-social/toolbox/pull/55) [`509c0a7`](https://github.com/bluesky-social/toolbox/commit/509c0a705fe9104631dff523780ccbd47cec453d) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Fix URL facets not highlighting on native. When an app bundles tapper, Babel's named-capture-group transform strips the `(?<domain>)` name from the `url` facet's regex source but can't rewrite the `m.groups.domain` access in its validate function, so the domain read back `undefined` and every bare domain was rejected. Validate now reads the domain by capture index instead. Parsing also switched from `matchAll` to an `exec` loop (Hermes drops named groups under `matchAll` too), and non-global facet patterns are now compiled with the `g` flag automatically instead of throwing.
8
+
9
+ - [#53](https://github.com/bluesky-social/toolbox/pull/53) [`bc83ab8`](https://github.com/bluesky-social/toolbox/commit/bc83ab8a5e761302eff0b75bb17518ce9b5aa638) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Update npmignore to prune some files
10
+
11
+ ## 0.6.0
12
+
13
+ ### Minor Changes
14
+
15
+ - [#51](https://github.com/bluesky-social/toolbox/pull/51) [`ccaacf8`](https://github.com/bluesky-social/toolbox/commit/ccaacf82166d74f684019718ae62812e19ae9267) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Facet config entries can now be `{pattern, validate}` definitions in addition to bare `RegExp`s. `validate` runs on each regex match and can reject it, so the text renders as plain text instead of a facet.
16
+
17
+ The default `url` facet uses this to validate bare domains against the real TLD list (a shared module-level `Set` built from the `tlds` package), so dotted abbreviations like `P.S.` or `e.g.` and fake domains like `example.notatld` no longer highlight as URLs. This matches the validation `RichText#detectFacets` performs in `@atproto/api` — the editor never highlights a "URL" that won't become a facet.
18
+
19
+ **New peer dependency:** `tlds`. Install it alongside tapper (`pnpm add tlds`) — if your app also uses `@atproto/api`, it already depends on it.
20
+
3
21
  ## 0.5.7
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -8,10 +8,17 @@ transparent `TextInput`.
8
8
  ## Installation
9
9
 
10
10
  ```sh
11
- pnpm add @bsky.app/tapper
11
+ pnpm add @bsky.app/tapper tlds
12
12
  ```
13
13
 
14
- Peer dependencies: `react`, `react-native`.
14
+ Peer dependencies: `react`, `react-native`, and [`tlds`](https://www.npmjs.com/package/tlds).
15
+
16
+ The `tlds` peer is used by the default `url` facet to validate bare domains
17
+ (e.g. `bsky.app`) against the real TLD list, matching the validation
18
+ `RichText#detectFacets` performs in `@atproto/api` — so the editor never
19
+ highlights something (like `P.S.`) that won't become a facet. It's a ~17KB
20
+ JSON list and `@atproto/api` already depends on it, so apps using both add no
21
+ extra weight.
15
22
 
16
23
  ## Basic example
17
24
 
@@ -211,6 +218,25 @@ const {state} = useTapper({
211
218
  })
212
219
  ```
213
220
 
221
+ ## Facet config
222
+
223
+ Each entry in the `facets` config is either a bare `RegExp` or a
224
+ `{pattern, validate}` definition. `validate` runs on every regex match and
225
+ receives the raw `RegExpMatchArray` (boundary capture and named groups
226
+ included); return `false` to reject the match so it renders as plain text:
227
+
228
+ ```tsx
229
+ const {state} = useTapper({
230
+ facets: {
231
+ mention: /@([a-zA-Z0-9._]+)/g, // bare RegExp
232
+ url: {
233
+ pattern: /\b[a-z0-9.-]+\.[a-z]{2,}\b/g,
234
+ validate: m => isDomainICareAbout(m[0]),
235
+ },
236
+ },
237
+ })
238
+ ```
239
+
214
240
  ## Default facets
215
241
 
216
242
  If no `facets` config is provided, tapper uses built-in patterns for mentions,
@@ -220,6 +246,12 @@ emoji, tags, and URLs. These are also exported individually:
220
246
  import {mention, emoji, tag, url} from '@bsky.app/tapper'
221
247
  ```
222
248
 
249
+ The default `url` facet is a `{pattern, validate}` definition: its validate
250
+ checks bare domains against the [`tlds`](https://www.npmjs.com/package/tlds)
251
+ list (a module-level `Set`, built once and shared), so e.g. `P.S.` or
252
+ `example.notatld` don't highlight. Explicit `https?://` URLs skip validation,
253
+ mirroring `RichText#detectFacets`.
254
+
223
255
  ## `replaceText`
224
256
 
225
257
  The `Tapper` class (used internally by `useTapper`) exposes `replaceText(text,
package/build/facets.d.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { TapperFacetDefinition } from './types';
1
2
  export declare const mention: RegExp;
2
3
  export declare const emoji: RegExp;
3
4
  export declare const tag: RegExp;
4
- export declare const url: RegExp;
5
+ export declare const url: TapperFacetDefinition;
5
6
  //# sourceMappingURL=facets.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"facets.d.ts","sourceRoot":"","sources":["../src/facets.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,OAAO,QAAkC,CAAA;AACtD,eAAO,MAAM,KAAK,QAAgC,CAAA;AAClD,eAAO,MAAM,GAAG,QAC4K,CAAA;AAC5L,eAAO,MAAM,GAAG,QACmE,CAAA"}
1
+ {"version":3,"file":"facets.d.ts","sourceRoot":"","sources":["../src/facets.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,qBAAqB,EAAC,MAAM,SAAS,CAAA;AAM7C,eAAO,MAAM,OAAO,QAAkC,CAAA;AACtD,eAAO,MAAM,KAAK,QAAgC,CAAA;AAClD,eAAO,MAAM,GAAG,QAC4K,CAAA;AAiB5L,eAAO,MAAM,GAAG,EAAE,qBASjB,CAAA"}
package/build/facets.js CHANGED
@@ -1,3 +1,4 @@
1
+ import TLDs from 'tlds';
1
2
  // Each regex begins with a `(^|\s|\()` capture group: a backwards-compatible
2
3
  // alternative to lookbehind (Safari < 16.4 throws on lookbehind at parse time).
3
4
  // The boundary char is captured into m[1]; util.ts strips it so positions and
@@ -5,5 +6,29 @@
5
6
  export const mention = /(^|\s|\()@([a-zA-Z0-9.-]+)\b/g;
6
7
  export const emoji = /(^|\s|\():([a-zA-Z0-9_]+):/g;
7
8
  export const tag = /(^|\s|\()[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2\p{P}]*)?/gu;
8
- export const url = /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim;
9
+ // Built once at module load and shared by every Tapper instance.
10
+ const TLD_SET = new Set(TLDs);
11
+ // Bare-domain matches must end in a real TLD, mirroring the validation
12
+ // RichText's detectFacets applies after its identical URL_REGEX matches.
13
+ // Without this, dotted abbreviations like `P.S.` or `e.g.` highlight in the
14
+ // editor but never become facets. `https?://` matches (m[3]) skip validation,
15
+ // same as RichText. The TLD check is deliberately case-sensitive: RichText's
16
+ // isValidDomain is too (`BSKY.APP` never facets), so don't lowercase here.
17
+ //
18
+ // Capture indices are load-bearing: validate reads the domain from m[5]. We
19
+ // can't use the (?<domain>) name at runtime — bundlers (Babel's
20
+ // transform-named-capturing-groups-regex) strip the name from the regex source
21
+ // when an app bundles us, so m.groups.domain reads back undefined. If you
22
+ // reorder or add capture groups in the pattern, update the m[5] index below.
23
+ export const url = {
24
+ pattern: /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim,
25
+ validate: m => {
26
+ if (m[3])
27
+ return true;
28
+ const domain = m[5];
29
+ if (!domain)
30
+ return false;
31
+ return TLD_SET.has(domain.slice(domain.lastIndexOf('.') + 1));
32
+ },
33
+ };
9
34
  //# sourceMappingURL=facets.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"facets.js","sourceRoot":"","sources":["../src/facets.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,gFAAgF;AAChF,8EAA8E;AAC9E,kCAAkC;AAClC,MAAM,CAAC,MAAM,OAAO,GAAG,+BAA+B,CAAA;AACtD,MAAM,CAAC,MAAM,KAAK,GAAG,6BAA6B,CAAA;AAClD,MAAM,CAAC,MAAM,GAAG,GACd,0LAA0L,CAAA;AAC5L,MAAM,CAAC,MAAM,GAAG,GACd,iFAAiF,CAAA","sourcesContent":["// Each regex begins with a `(^|\\s|\\()` capture group: a backwards-compatible\n// alternative to lookbehind (Safari < 16.4 throws on lookbehind at parse time).\n// The boundary char is captured into m[1]; util.ts strips it so positions and\n// `raw` reflect the facet itself.\nexport const mention = /(^|\\s|\\()@([a-zA-Z0-9.-]+)\\b/g\nexport const emoji = /(^|\\s|\\():([a-zA-Z0-9_]+):/g\nexport const tag =\n /(^|\\s|\\()[##]((?!\\ufe0f)[^\\s\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2]*[^\\d\\s\\p{P}\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2]+[^\\s\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2\\p{P}]*)?/gu\nexport const url =\n /(^|\\s|\\()((https?:\\/\\/[\\S]+)|((?<domain>[a-z][a-z0-9]*(\\.[a-z0-9]+)+)[\\S]*))/gim\n"]}
1
+ {"version":3,"file":"facets.js","sourceRoot":"","sources":["../src/facets.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAA;AAIvB,6EAA6E;AAC7E,gFAAgF;AAChF,8EAA8E;AAC9E,kCAAkC;AAClC,MAAM,CAAC,MAAM,OAAO,GAAG,+BAA+B,CAAA;AACtD,MAAM,CAAC,MAAM,KAAK,GAAG,6BAA6B,CAAA;AAClD,MAAM,CAAC,MAAM,GAAG,GACd,0LAA0L,CAAA;AAE5L,iEAAiE;AACjE,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAA;AAE7B,uEAAuE;AACvE,yEAAyE;AACzE,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,gEAAgE;AAChE,+EAA+E;AAC/E,0EAA0E;AAC1E,6EAA6E;AAC7E,MAAM,CAAC,MAAM,GAAG,GAA0B;IACxC,OAAO,EACL,iFAAiF;IACnF,QAAQ,EAAE,CAAC,CAAC,EAAE;QACZ,IAAI,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAA;QACrB,MAAM,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QACnB,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAA;QACzB,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;IAC/D,CAAC;CACF,CAAA","sourcesContent":["import TLDs from 'tlds'\n\nimport {TapperFacetDefinition} from './types'\n\n// Each regex begins with a `(^|\\s|\\()` capture group: a backwards-compatible\n// alternative to lookbehind (Safari < 16.4 throws on lookbehind at parse time).\n// The boundary char is captured into m[1]; util.ts strips it so positions and\n// `raw` reflect the facet itself.\nexport const mention = /(^|\\s|\\()@([a-zA-Z0-9.-]+)\\b/g\nexport const emoji = /(^|\\s|\\():([a-zA-Z0-9_]+):/g\nexport const tag =\n /(^|\\s|\\()[##]((?!\\ufe0f)[^\\s\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2]*[^\\d\\s\\p{P}\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2]+[^\\s\\u00AD\\u2060\\u200A\\u200B\\u200C\\u200D\\u20e2\\p{P}]*)?/gu\n\n// Built once at module load and shared by every Tapper instance.\nconst TLD_SET = new Set(TLDs)\n\n// Bare-domain matches must end in a real TLD, mirroring the validation\n// RichText's detectFacets applies after its identical URL_REGEX matches.\n// Without this, dotted abbreviations like `P.S.` or `e.g.` highlight in the\n// editor but never become facets. `https?://` matches (m[3]) skip validation,\n// same as RichText. The TLD check is deliberately case-sensitive: RichText's\n// isValidDomain is too (`BSKY.APP` never facets), so don't lowercase here.\n//\n// Capture indices are load-bearing: validate reads the domain from m[5]. We\n// can't use the (?<domain>) name at runtime — bundlers (Babel's\n// transform-named-capturing-groups-regex) strip the name from the regex source\n// when an app bundles us, so m.groups.domain reads back undefined. If you\n// reorder or add capture groups in the pattern, update the m[5] index below.\nexport const url: TapperFacetDefinition = {\n pattern:\n /(^|\\s|\\()((https?:\\/\\/[\\S]+)|((?<domain>[a-z][a-z0-9]*(\\.[a-z0-9]+)+)[\\S]*))/gim,\n validate: m => {\n if (m[3]) return true\n const domain = m[5]\n if (!domain) return false\n return TLD_SET.has(domain.slice(domain.lastIndexOf('.') + 1))\n },\n}\n"]}
package/build/types.d.ts CHANGED
@@ -1,4 +1,13 @@
1
- export type TapperFacetConfig = Record<string, RegExp>;
1
+ export type TapperFacetDefinition = {
2
+ pattern: RegExp;
3
+ /**
4
+ * Optional post-match validation. Receives the raw regex match (boundary
5
+ * capture and named groups included). Return false to reject the match —
6
+ * the text renders as plain text instead of a facet.
7
+ */
8
+ validate?: (match: RegExpMatchArray) => boolean;
9
+ };
10
+ export type TapperFacetConfig = Record<string, RegExp | TapperFacetDefinition>;
2
11
  export type TapperNode = {
3
12
  id: number;
4
13
  type: 'text';
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAEtD,MAAM,MAAM,UAAU,GAClB;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB,GACD;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,SAAS,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,GACD;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAEL,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG;IAC5C,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAC,eAAe,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,IAAI,CAAA;CACxE,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAC,CAAA;AAE1D,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,eAAe,CAAA;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,cAAc,EAAE,WAAW,CAAA;IAC3B,WAAW,EAAE,WAAW,CAAA;CACzB,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAA;CAChD,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,qBAAqB,CAAC,CAAA;AAE9E,MAAM,MAAM,UAAU,GAClB;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB,GACD;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,SAAS,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,GACD;IACE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAEL,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG;IAC5C,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAC,eAAe,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,IAAI,CAAA;CACxE,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAC,CAAA;AAE1D,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,eAAe,CAAA;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,cAAc,EAAE,WAAW,CAAA;IAC3B,WAAW,EAAE,WAAW,CAAA;CACzB,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["export type TapperFacetConfig = Record<string, RegExp>\n\nexport type TapperNode =\n | {\n id: number\n type: 'text'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType?: undefined\n }\n | {\n id: number\n type: 'trigger'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n | {\n id: number\n type: 'facet'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n\nexport type TapperFacet = {\n type: string\n raw: string\n value: string\n range: {start: number; end: number}\n}\n\nexport type TapperActiveFacet = TapperFacet & {\n replace: (value: string, options?: {noTrailingSpace?: boolean}) => void\n}\n\nexport type TapperSelection = {start: number; end: number}\n\nexport type TapperSnapshot = {\n text: string\n selection: TapperSelection\n nodes: TapperNode[]\n activeFacet: TapperActiveFacet | null\n}\n\nexport type TapperConfig = {\n facets?: TapperFacetConfig\n initialText?: string\n}\n\nexport type TapperEvents = {\n activeFacet: TapperActiveFacet | null\n facetCommitted: TapperFacet\n afterInsert: TapperFacet\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["export type TapperFacetDefinition = {\n pattern: RegExp\n /**\n * Optional post-match validation. Receives the raw regex match (boundary\n * capture and named groups included). Return false to reject the match —\n * the text renders as plain text instead of a facet.\n */\n validate?: (match: RegExpMatchArray) => boolean\n}\n\nexport type TapperFacetConfig = Record<string, RegExp | TapperFacetDefinition>\n\nexport type TapperNode =\n | {\n id: number\n type: 'text'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType?: undefined\n }\n | {\n id: number\n type: 'trigger'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n | {\n id: number\n type: 'facet'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n\nexport type TapperFacet = {\n type: string\n raw: string\n value: string\n range: {start: number; end: number}\n}\n\nexport type TapperActiveFacet = TapperFacet & {\n replace: (value: string, options?: {noTrailingSpace?: boolean}) => void\n}\n\nexport type TapperSelection = {start: number; end: number}\n\nexport type TapperSnapshot = {\n text: string\n selection: TapperSelection\n nodes: TapperNode[]\n activeFacet: TapperActiveFacet | null\n}\n\nexport type TapperConfig = {\n facets?: TapperFacetConfig\n initialText?: string\n}\n\nexport type TapperEvents = {\n activeFacet: TapperActiveFacet | null\n facetCommitted: TapperFacet\n afterInsert: TapperFacet\n}\n"]}
package/build/util.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { TapperFacetConfig, TapperNode, TapperFacet } from './types';
2
- export type CompiledFacetRegexes = Map<string, RegExp>;
2
+ type CompiledFacet = {
3
+ regex: RegExp;
4
+ validate?: (match: RegExpMatchArray) => boolean;
5
+ };
6
+ export type CompiledFacetRegexes = Map<string, CompiledFacet>;
3
7
  /**
4
8
  * Pre-compile facet regexes once at init time. This avoids re-creating
5
9
  * RegExp objects on every keystroke in parseNodesFromText. Each Tapper
6
10
  * instance gets its own compiled copy so lastIndex state can't leak
7
- * between instances.
11
+ * between instances. Config values may be a bare RegExp or a
12
+ * {pattern, validate} definition.
8
13
  */
9
14
  export declare function compileFacetRegexes(config: TapperFacetConfig): CompiledFacetRegexes;
10
15
  export declare function parseNodesFromText(text: string, regexes: CompiledFacetRegexes, prevNodes?: TapperNode[], cursor?: number, triggers?: Map<string, string>): TapperNode[];
11
16
  export declare function deriveTriggers(config: TapperFacetConfig): Map<string, string>;
12
17
  export declare function nodeToFacet(node: TapperNode): TapperFacet;
13
18
  export declare function detectActiveFacet(nodes: TapperNode[], text: string, cursor: number, triggers: Map<string, string>): TapperFacet | null;
19
+ export {};
14
20
  //# sourceMappingURL=util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAC,MAAM,SAAS,CAAA;AAmBlE,MAAM,MAAM,oBAAoB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAEtD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,iBAAiB,GACxB,oBAAoB,CAMtB;AAED,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,oBAAoB,EAC7B,SAAS,CAAC,EAAE,UAAU,EAAE,EACxB,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,UAAU,EAAE,CA6Kd;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,iBAAiB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAW7E;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAOzD;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,WAAW,GAAG,IAAI,CAkDpB"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,UAAU,EAAE,WAAW,EAAC,MAAM,SAAS,CAAA;AAmBlE,KAAK,aAAa,GAAG;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAA;CAChD,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;AAE7D;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,iBAAiB,GACxB,oBAAoB,CAatB;AAED,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,oBAAoB,EAC7B,SAAS,CAAC,EAAE,UAAU,EAAE,EACxB,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,UAAU,EAAE,CAsLd;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,iBAAiB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAY7E;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAOzD;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,WAAW,GAAG,IAAI,CAkDpB"}
package/build/util.js CHANGED
@@ -18,25 +18,44 @@ let nextNodeId = 0;
18
18
  * Pre-compile facet regexes once at init time. This avoids re-creating
19
19
  * RegExp objects on every keystroke in parseNodesFromText. Each Tapper
20
20
  * instance gets its own compiled copy so lastIndex state can't leak
21
- * between instances.
21
+ * between instances. Config values may be a bare RegExp or a
22
+ * {pattern, validate} definition.
22
23
  */
23
24
  export function compileFacetRegexes(config) {
24
25
  const compiled = new Map();
25
- for (const [name, re] of Object.entries(config)) {
26
- compiled.set(name, new RegExp(re.source, re.flags));
26
+ for (const [name, def] of Object.entries(config)) {
27
+ const re = def instanceof RegExp ? def : def.pattern;
28
+ // Force the global flag: the exec loop in parseNodesFromText would spin
29
+ // forever on a non-global regex (matchAll used to throw on these).
30
+ const flags = re.flags.includes('g') ? re.flags : re.flags + 'g';
31
+ compiled.set(name, {
32
+ regex: new RegExp(re.source, flags),
33
+ validate: def instanceof RegExp ? undefined : def.validate,
34
+ });
27
35
  }
28
36
  return compiled;
29
37
  }
30
38
  export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
31
39
  const allMatches = [];
32
- for (const [name, re] of regexes) {
40
+ for (const [name, { regex: re, validate }] of regexes) {
33
41
  // Reset lastIndex so stateful (global) regexes don't carry over
34
42
  // match positions from the previous parse call.
35
43
  re.lastIndex = 0;
36
44
  // Boundary-gated regexes shift the effective start past the captured
37
45
  // boundary char (m[1]) and bump the value capture index by one.
38
46
  const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX);
39
- for (const m of text.matchAll(re)) {
47
+ // exec loop instead of matchAll: Hermes's matchAll drops named capture
48
+ // groups (m.groups is undefined), which breaks validate functions that
49
+ // read them. exec populates groups correctly on all engines, and resets
50
+ // lastIndex to 0 itself when it exhausts the matches.
51
+ let m;
52
+ while ((m = re.exec(text)) !== null) {
53
+ // Guard against zero-length matches pinning lastIndex in place
54
+ // (matchAll did this advance implicitly).
55
+ if (m[0] === '')
56
+ re.lastIndex++;
57
+ if (validate && !validate(m))
58
+ continue;
40
59
  const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0;
41
60
  const fullMatch = m[0].slice(boundaryLen);
42
61
  allMatches.push({
@@ -188,7 +207,8 @@ export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
188
207
  }
189
208
  export function deriveTriggers(config) {
190
209
  const triggers = new Map();
191
- for (const [name, re] of Object.entries(config)) {
210
+ for (const [name, def] of Object.entries(config)) {
211
+ const re = def instanceof RegExp ? def : def.pattern;
192
212
  // Skip the boundary prefix (if present) before extracting the trigger char.
193
213
  const src = re.source.startsWith(BOUNDARY_PREFIX)
194
214
  ? re.source.slice(BOUNDARY_PREFIX.length)
package/build/util.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"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","sourcesContent":["import {TapperFacetConfig, TapperNode, TapperFacet} from './types'\n\nconst WHITESPACE = /\\s/\n// Regexes whose source starts with this prefix use a leading capture group\n// to gate matches on a boundary (start-of-string, whitespace, or `(`) without\n// lookbehind. The captured boundary char is stripped from the match so\n// positions and `raw` reflect the facet itself.\nconst BOUNDARY_PREFIX = '(^|\\\\s|\\\\()'\n\n// Mirror the regex's `(^|\\s|\\()` gate so trigger detection (for chars typed\n// before any regex match exists) doesn't fire mid-word — e.g. the `@` inside\n// `eric@blueskyweb.xyz` should not synthesize a mention trigger.\nfunction isBoundaryBefore(text: string, i: number) {\n if (i === 0) return true\n const prev = text[i - 1]\n return prev === '(' || WHITESPACE.test(prev)\n}\nlet nextNodeId = 0\n\nexport type CompiledFacetRegexes = Map<string, RegExp>\n\n/**\n * Pre-compile facet regexes once at init time. This avoids re-creating\n * RegExp objects on every keystroke in parseNodesFromText. Each Tapper\n * instance gets its own compiled copy so lastIndex state can't leak\n * between instances.\n */\nexport function compileFacetRegexes(\n config: TapperFacetConfig,\n): CompiledFacetRegexes {\n const compiled = new Map<string, RegExp>()\n for (const [name, re] of Object.entries(config)) {\n compiled.set(name, new RegExp(re.source, re.flags))\n }\n return compiled\n}\n\nexport function parseNodesFromText(\n text: string,\n regexes: CompiledFacetRegexes,\n prevNodes?: TapperNode[],\n cursor?: number,\n triggers?: Map<string, string>,\n): TapperNode[] {\n const allMatches: {\n facetName: string\n fullMatch: string\n capture: string\n index: number\n }[] = []\n\n for (const [name, re] of regexes) {\n // Reset lastIndex so stateful (global) regexes don't carry over\n // match positions from the previous parse call.\n re.lastIndex = 0\n // Boundary-gated regexes shift the effective start past the captured\n // boundary char (m[1]) and bump the value capture index by one.\n const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX)\n for (const m of text.matchAll(re)) {\n const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0\n const fullMatch = m[0].slice(boundaryLen)\n allMatches.push({\n facetName: name,\n fullMatch,\n capture: (hasBoundary ? m[2] : m[1]) ?? fullMatch,\n index: m.index + boundaryLen,\n })\n }\n }\n\n allMatches.sort(\n (a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length,\n )\n\n const accepted: typeof allMatches = []\n let lastEnd = 0\n for (const m of allMatches) {\n if (m.index >= lastEnd) {\n accepted.push(m)\n lastEnd = m.index + m.fullMatch.length\n }\n }\n\n const nodes: TapperNode[] = []\n let pos = 0\n\n for (const m of accepted) {\n if (m.index > pos) {\n const raw = text.slice(pos, m.index)\n nodes.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: m.index,\n })\n }\n nodes.push({\n id: nextNodeId++,\n type: 'facet',\n facetType: m.facetName,\n raw: m.fullMatch,\n value: m.capture,\n start: m.index,\n end: m.index + m.fullMatch.length,\n })\n pos = m.index + m.fullMatch.length\n }\n\n if (pos < text.length) {\n const raw = text.slice(pos)\n nodes.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: text.length,\n })\n }\n\n // If the cursor is right after a trigger char that the regex didn't match,\n // splice a 'trigger' node out of the containing text node.\n if (cursor != null && triggers) {\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n if (WHITESPACE.test(ch)) break\n const facetType = triggers.get(ch)\n if (facetType && isBoundaryBefore(text, i)) {\n // Only create a trigger node if the trigger is inside a text node\n // (i.e. the regex didn't already match it as a facet)\n const textNodeIdx = nodes.findIndex(\n n => n.type === 'text' && n.start <= i && n.end > i,\n )\n if (textNodeIdx !== -1) {\n const node = nodes[textNodeIdx]\n const spliced: TapperNode[] = []\n if (node.start < i) {\n const raw = text.slice(node.start, i)\n spliced.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: node.start,\n end: i,\n })\n }\n const triggerRaw = text.slice(i, cursor)\n spliced.push({\n id: nextNodeId++,\n type: 'trigger',\n facetType,\n raw: triggerRaw,\n value: text.slice(i + ch.length, cursor),\n start: i,\n end: cursor,\n })\n if (cursor < node.end) {\n const raw = text.slice(cursor, node.end)\n spliced.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: cursor,\n end: node.end,\n })\n }\n nodes.splice(textNodeIdx, 1, ...spliced)\n }\n break\n }\n }\n }\n\n // Transfer committed flags and stable IDs from previous nodes\n if (prevNodes) {\n // Facet nodes: match by facetType + occurrence index\n const counts = new Map<string, number>()\n const prevFacetsByTypeIndex = new Map<string, TapperNode>()\n\n for (const node of prevNodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n prevFacetsByTypeIndex.set(`${node.facetType}:${idx}`, node)\n }\n\n counts.clear()\n for (const node of nodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n const prev = prevFacetsByTypeIndex.get(`${node.facetType}:${idx}`)\n if (prev) {\n node.id = prev.id\n if (prev.committed) node.committed = true\n }\n }\n\n // Text nodes: match by start position\n const prevTextByStart = new Map<number, TapperNode>()\n for (const node of prevNodes) {\n if (node.type === 'text') prevTextByStart.set(node.start, node)\n }\n for (const node of nodes) {\n if (node.type === 'text') {\n const prev = prevTextByStart.get(node.start)\n if (prev) node.id = prev.id\n }\n }\n }\n\n return nodes\n}\n\nexport function deriveTriggers(config: TapperFacetConfig): Map<string, string> {\n const triggers = new Map<string, string>()\n for (const [name, re] of Object.entries(config)) {\n // Skip the boundary prefix (if present) before extracting the trigger char.\n const src = re.source.startsWith(BOUNDARY_PREFIX)\n ? re.source.slice(BOUNDARY_PREFIX.length)\n : re.source\n const m = src.match(/^[^\\\\([\\]{}.*+?^$|]+/)\n if (m) triggers.set(m[0], name)\n }\n return triggers\n}\n\nexport function nodeToFacet(node: TapperNode): TapperFacet {\n return {\n type: node.facetType!,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n}\n\nexport function detectActiveFacet(\n nodes: TapperNode[],\n text: string,\n cursor: number,\n triggers: Map<string, string>,\n): TapperFacet | null {\n for (const node of nodes) {\n if (node.type === 'trigger' && node.start < cursor && cursor <= node.end) {\n return {\n type: node.facetType,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n if (\n node.type === 'facet' &&\n !node.committed &&\n node.start < cursor &&\n cursor <= node.end\n ) {\n return {\n type: node.facetType,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n }\n\n // Scan backward from cursor for a trigger char (partial facet not yet matched by regex).\n // Skip if the cursor is inside or at the end of a committed facet.\n const inCommitted = nodes.some(\n n =>\n n.type === 'facet' && n.committed && n.start < cursor && cursor <= n.end,\n )\n if (!inCommitted) {\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n // Stop at whitespace — triggers don't span across words\n if (WHITESPACE.test(ch)) break\n const type = triggers.get(ch)\n if (type && isBoundaryBefore(text, i)) {\n const raw = text.slice(i, cursor)\n return {\n type,\n raw,\n value: text.slice(i + 1, cursor),\n range: {start: i, end: cursor},\n }\n }\n }\n }\n\n return null\n}\n"]}
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"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","sourcesContent":["import {TapperFacetConfig, TapperNode, TapperFacet} from './types'\n\nconst WHITESPACE = /\\s/\n// Regexes whose source starts with this prefix use a leading capture group\n// to gate matches on a boundary (start-of-string, whitespace, or `(`) without\n// lookbehind. The captured boundary char is stripped from the match so\n// positions and `raw` reflect the facet itself.\nconst BOUNDARY_PREFIX = '(^|\\\\s|\\\\()'\n\n// Mirror the regex's `(^|\\s|\\()` gate so trigger detection (for chars typed\n// before any regex match exists) doesn't fire mid-word — e.g. the `@` inside\n// `eric@blueskyweb.xyz` should not synthesize a mention trigger.\nfunction isBoundaryBefore(text: string, i: number) {\n if (i === 0) return true\n const prev = text[i - 1]\n return prev === '(' || WHITESPACE.test(prev)\n}\nlet nextNodeId = 0\n\ntype CompiledFacet = {\n regex: RegExp\n validate?: (match: RegExpMatchArray) => boolean\n}\n\nexport type CompiledFacetRegexes = Map<string, CompiledFacet>\n\n/**\n * Pre-compile facet regexes once at init time. This avoids re-creating\n * RegExp objects on every keystroke in parseNodesFromText. Each Tapper\n * instance gets its own compiled copy so lastIndex state can't leak\n * between instances. Config values may be a bare RegExp or a\n * {pattern, validate} definition.\n */\nexport function compileFacetRegexes(\n config: TapperFacetConfig,\n): CompiledFacetRegexes {\n const compiled = new Map<string, CompiledFacet>()\n for (const [name, def] of Object.entries(config)) {\n const re = def instanceof RegExp ? def : def.pattern\n // Force the global flag: the exec loop in parseNodesFromText would spin\n // forever on a non-global regex (matchAll used to throw on these).\n const flags = re.flags.includes('g') ? re.flags : re.flags + 'g'\n compiled.set(name, {\n regex: new RegExp(re.source, flags),\n validate: def instanceof RegExp ? undefined : def.validate,\n })\n }\n return compiled\n}\n\nexport function parseNodesFromText(\n text: string,\n regexes: CompiledFacetRegexes,\n prevNodes?: TapperNode[],\n cursor?: number,\n triggers?: Map<string, string>,\n): TapperNode[] {\n const allMatches: {\n facetName: string\n fullMatch: string\n capture: string\n index: number\n }[] = []\n\n for (const [name, {regex: re, validate}] of regexes) {\n // Reset lastIndex so stateful (global) regexes don't carry over\n // match positions from the previous parse call.\n re.lastIndex = 0\n // Boundary-gated regexes shift the effective start past the captured\n // boundary char (m[1]) and bump the value capture index by one.\n const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX)\n // exec loop instead of matchAll: Hermes's matchAll drops named capture\n // groups (m.groups is undefined), which breaks validate functions that\n // read them. exec populates groups correctly on all engines, and resets\n // lastIndex to 0 itself when it exhausts the matches.\n let m: RegExpExecArray | null\n while ((m = re.exec(text)) !== null) {\n // Guard against zero-length matches pinning lastIndex in place\n // (matchAll did this advance implicitly).\n if (m[0] === '') re.lastIndex++\n if (validate && !validate(m)) continue\n const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0\n const fullMatch = m[0].slice(boundaryLen)\n allMatches.push({\n facetName: name,\n fullMatch,\n capture: (hasBoundary ? m[2] : m[1]) ?? fullMatch,\n index: m.index + boundaryLen,\n })\n }\n }\n\n allMatches.sort(\n (a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length,\n )\n\n const accepted: typeof allMatches = []\n let lastEnd = 0\n for (const m of allMatches) {\n if (m.index >= lastEnd) {\n accepted.push(m)\n lastEnd = m.index + m.fullMatch.length\n }\n }\n\n const nodes: TapperNode[] = []\n let pos = 0\n\n for (const m of accepted) {\n if (m.index > pos) {\n const raw = text.slice(pos, m.index)\n nodes.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: m.index,\n })\n }\n nodes.push({\n id: nextNodeId++,\n type: 'facet',\n facetType: m.facetName,\n raw: m.fullMatch,\n value: m.capture,\n start: m.index,\n end: m.index + m.fullMatch.length,\n })\n pos = m.index + m.fullMatch.length\n }\n\n if (pos < text.length) {\n const raw = text.slice(pos)\n nodes.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: text.length,\n })\n }\n\n // If the cursor is right after a trigger char that the regex didn't match,\n // splice a 'trigger' node out of the containing text node.\n if (cursor != null && triggers) {\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n if (WHITESPACE.test(ch)) break\n const facetType = triggers.get(ch)\n if (facetType && isBoundaryBefore(text, i)) {\n // Only create a trigger node if the trigger is inside a text node\n // (i.e. the regex didn't already match it as a facet)\n const textNodeIdx = nodes.findIndex(\n n => n.type === 'text' && n.start <= i && n.end > i,\n )\n if (textNodeIdx !== -1) {\n const node = nodes[textNodeIdx]\n const spliced: TapperNode[] = []\n if (node.start < i) {\n const raw = text.slice(node.start, i)\n spliced.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: node.start,\n end: i,\n })\n }\n const triggerRaw = text.slice(i, cursor)\n spliced.push({\n id: nextNodeId++,\n type: 'trigger',\n facetType,\n raw: triggerRaw,\n value: text.slice(i + ch.length, cursor),\n start: i,\n end: cursor,\n })\n if (cursor < node.end) {\n const raw = text.slice(cursor, node.end)\n spliced.push({\n id: nextNodeId++,\n type: 'text',\n raw,\n value: raw,\n start: cursor,\n end: node.end,\n })\n }\n nodes.splice(textNodeIdx, 1, ...spliced)\n }\n break\n }\n }\n }\n\n // Transfer committed flags and stable IDs from previous nodes\n if (prevNodes) {\n // Facet nodes: match by facetType + occurrence index\n const counts = new Map<string, number>()\n const prevFacetsByTypeIndex = new Map<string, TapperNode>()\n\n for (const node of prevNodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n prevFacetsByTypeIndex.set(`${node.facetType}:${idx}`, node)\n }\n\n counts.clear()\n for (const node of nodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n const prev = prevFacetsByTypeIndex.get(`${node.facetType}:${idx}`)\n if (prev) {\n node.id = prev.id\n if (prev.committed) node.committed = true\n }\n }\n\n // Text nodes: match by start position\n const prevTextByStart = new Map<number, TapperNode>()\n for (const node of prevNodes) {\n if (node.type === 'text') prevTextByStart.set(node.start, node)\n }\n for (const node of nodes) {\n if (node.type === 'text') {\n const prev = prevTextByStart.get(node.start)\n if (prev) node.id = prev.id\n }\n }\n }\n\n return nodes\n}\n\nexport function deriveTriggers(config: TapperFacetConfig): Map<string, string> {\n const triggers = new Map<string, string>()\n for (const [name, def] of Object.entries(config)) {\n const re = def instanceof RegExp ? def : def.pattern\n // Skip the boundary prefix (if present) before extracting the trigger char.\n const src = re.source.startsWith(BOUNDARY_PREFIX)\n ? re.source.slice(BOUNDARY_PREFIX.length)\n : re.source\n const m = src.match(/^[^\\\\([\\]{}.*+?^$|]+/)\n if (m) triggers.set(m[0], name)\n }\n return triggers\n}\n\nexport function nodeToFacet(node: TapperNode): TapperFacet {\n return {\n type: node.facetType!,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n}\n\nexport function detectActiveFacet(\n nodes: TapperNode[],\n text: string,\n cursor: number,\n triggers: Map<string, string>,\n): TapperFacet | null {\n for (const node of nodes) {\n if (node.type === 'trigger' && node.start < cursor && cursor <= node.end) {\n return {\n type: node.facetType,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n if (\n node.type === 'facet' &&\n !node.committed &&\n node.start < cursor &&\n cursor <= node.end\n ) {\n return {\n type: node.facetType,\n raw: node.raw,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n }\n\n // Scan backward from cursor for a trigger char (partial facet not yet matched by regex).\n // Skip if the cursor is inside or at the end of a committed facet.\n const inCommitted = nodes.some(\n n =>\n n.type === 'facet' && n.committed && n.start < cursor && cursor <= n.end,\n )\n if (!inCommitted) {\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n // Stop at whitespace — triggers don't span across words\n if (WHITESPACE.test(ch)) break\n const type = triggers.get(ch)\n if (type && isBoundaryBefore(text, i)) {\n const raw = text.slice(i, cursor)\n return {\n type,\n raw,\n value: text.slice(i + 1, cursor),\n range: {start: i, end: cursor},\n }\n }\n }\n }\n\n return null\n}\n"]}
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@bsky.app/tapper",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "description": "A minimal rich text editor for React Native and web.",
6
- "repository": "https://github.com/bluesky-social/toolbox",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/bluesky-social/toolbox"
9
+ },
7
10
  "author": "Eric Bailey <git@esb.lol> (https://github.com/estrattonbailey)",
8
11
  "homepage": "https://github.com/bluesky-social/toolbox/tree/main/packages/tapper",
9
12
  "main": "build/index.js",
@@ -19,12 +22,14 @@
19
22
  "expo": "^55.0.8",
20
23
  "expo-module-scripts": "^55.0.2",
21
24
  "react-native": "0.82.1",
22
- "vitest": "^3.1.1"
25
+ "vitest": "^3.1.1",
26
+ "tlds": "^1.234.0"
23
27
  },
24
28
  "peerDependencies": {
25
29
  "expo": "*",
26
30
  "react": "*",
27
- "react-native": "*"
31
+ "react-native": "*",
32
+ "tlds": "*"
28
33
  },
29
34
  "scripts": {
30
35
  "build": "expo-module clean && EXPO_NONINTERACTIVE=1 expo-module build",
package/src/facets.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import TLDs from 'tlds'
2
+
3
+ import {TapperFacetDefinition} from './types'
4
+
1
5
  // Each regex begins with a `(^|\s|\()` capture group: a backwards-compatible
2
6
  // alternative to lookbehind (Safari < 16.4 throws on lookbehind at parse time).
3
7
  // The boundary char is captured into m[1]; util.ts strips it so positions and
@@ -6,5 +10,29 @@ export const mention = /(^|\s|\()@([a-zA-Z0-9.-]+)\b/g
6
10
  export const emoji = /(^|\s|\():([a-zA-Z0-9_]+):/g
7
11
  export const tag =
8
12
  /(^|\s|\()[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2\p{P}]*)?/gu
9
- export const url =
10
- /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
13
+
14
+ // Built once at module load and shared by every Tapper instance.
15
+ const TLD_SET = new Set(TLDs)
16
+
17
+ // Bare-domain matches must end in a real TLD, mirroring the validation
18
+ // RichText's detectFacets applies after its identical URL_REGEX matches.
19
+ // Without this, dotted abbreviations like `P.S.` or `e.g.` highlight in the
20
+ // editor but never become facets. `https?://` matches (m[3]) skip validation,
21
+ // same as RichText. The TLD check is deliberately case-sensitive: RichText's
22
+ // isValidDomain is too (`BSKY.APP` never facets), so don't lowercase here.
23
+ //
24
+ // Capture indices are load-bearing: validate reads the domain from m[5]. We
25
+ // can't use the (?<domain>) name at runtime — bundlers (Babel's
26
+ // transform-named-capturing-groups-regex) strip the name from the regex source
27
+ // when an app bundles us, so m.groups.domain reads back undefined. If you
28
+ // reorder or add capture groups in the pattern, update the m[5] index below.
29
+ export const url: TapperFacetDefinition = {
30
+ pattern:
31
+ /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim,
32
+ validate: m => {
33
+ if (m[3]) return true
34
+ const domain = m[5]
35
+ if (!domain) return false
36
+ return TLD_SET.has(domain.slice(domain.lastIndexOf('.') + 1))
37
+ },
38
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,14 @@
1
- export type TapperFacetConfig = Record<string, RegExp>
1
+ export type TapperFacetDefinition = {
2
+ pattern: RegExp
3
+ /**
4
+ * Optional post-match validation. Receives the raw regex match (boundary
5
+ * capture and named groups included). Return false to reject the match —
6
+ * the text renders as plain text instead of a facet.
7
+ */
8
+ validate?: (match: RegExpMatchArray) => boolean
9
+ }
10
+
11
+ export type TapperFacetConfig = Record<string, RegExp | TapperFacetDefinition>
2
12
 
3
13
  export type TapperNode =
4
14
  | {
package/src/util.ts CHANGED
@@ -17,20 +17,33 @@ function isBoundaryBefore(text: string, i: number) {
17
17
  }
18
18
  let nextNodeId = 0
19
19
 
20
- export type CompiledFacetRegexes = Map<string, RegExp>
20
+ type CompiledFacet = {
21
+ regex: RegExp
22
+ validate?: (match: RegExpMatchArray) => boolean
23
+ }
24
+
25
+ export type CompiledFacetRegexes = Map<string, CompiledFacet>
21
26
 
22
27
  /**
23
28
  * Pre-compile facet regexes once at init time. This avoids re-creating
24
29
  * RegExp objects on every keystroke in parseNodesFromText. Each Tapper
25
30
  * instance gets its own compiled copy so lastIndex state can't leak
26
- * between instances.
31
+ * between instances. Config values may be a bare RegExp or a
32
+ * {pattern, validate} definition.
27
33
  */
28
34
  export function compileFacetRegexes(
29
35
  config: TapperFacetConfig,
30
36
  ): CompiledFacetRegexes {
31
- const compiled = new Map<string, RegExp>()
32
- for (const [name, re] of Object.entries(config)) {
33
- compiled.set(name, new RegExp(re.source, re.flags))
37
+ const compiled = new Map<string, CompiledFacet>()
38
+ for (const [name, def] of Object.entries(config)) {
39
+ const re = def instanceof RegExp ? def : def.pattern
40
+ // Force the global flag: the exec loop in parseNodesFromText would spin
41
+ // forever on a non-global regex (matchAll used to throw on these).
42
+ const flags = re.flags.includes('g') ? re.flags : re.flags + 'g'
43
+ compiled.set(name, {
44
+ regex: new RegExp(re.source, flags),
45
+ validate: def instanceof RegExp ? undefined : def.validate,
46
+ })
34
47
  }
35
48
  return compiled
36
49
  }
@@ -49,14 +62,23 @@ export function parseNodesFromText(
49
62
  index: number
50
63
  }[] = []
51
64
 
52
- for (const [name, re] of regexes) {
65
+ for (const [name, {regex: re, validate}] of regexes) {
53
66
  // Reset lastIndex so stateful (global) regexes don't carry over
54
67
  // match positions from the previous parse call.
55
68
  re.lastIndex = 0
56
69
  // Boundary-gated regexes shift the effective start past the captured
57
70
  // boundary char (m[1]) and bump the value capture index by one.
58
71
  const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX)
59
- for (const m of text.matchAll(re)) {
72
+ // exec loop instead of matchAll: Hermes's matchAll drops named capture
73
+ // groups (m.groups is undefined), which breaks validate functions that
74
+ // read them. exec populates groups correctly on all engines, and resets
75
+ // lastIndex to 0 itself when it exhausts the matches.
76
+ let m: RegExpExecArray | null
77
+ while ((m = re.exec(text)) !== null) {
78
+ // Guard against zero-length matches pinning lastIndex in place
79
+ // (matchAll did this advance implicitly).
80
+ if (m[0] === '') re.lastIndex++
81
+ if (validate && !validate(m)) continue
60
82
  const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0
61
83
  const fullMatch = m[0].slice(boundaryLen)
62
84
  allMatches.push({
@@ -218,7 +240,8 @@ export function parseNodesFromText(
218
240
 
219
241
  export function deriveTriggers(config: TapperFacetConfig): Map<string, string> {
220
242
  const triggers = new Map<string, string>()
221
- for (const [name, re] of Object.entries(config)) {
243
+ for (const [name, def] of Object.entries(config)) {
244
+ const re = def instanceof RegExp ? def : def.pattern
222
245
  // Skip the boundary prefix (if present) before extracting the trigger char.
223
246
  const src = re.source.startsWith(BOUNDARY_PREFIX)
224
247
  ? re.source.slice(BOUNDARY_PREFIX.length)
package/eslint.config.js DELETED
@@ -1,4 +0,0 @@
1
- // @generated by expo-module-scripts
2
- const {defineConfig} = require('eslint/config')
3
- const baseConfig = require('expo-module-scripts/eslint.config.base')
4
- module.exports = defineConfig([baseConfig])
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- // @generated by expo-module-scripts
2
- {
3
- "extends": "expo-module-scripts/tsconfig.base",
4
- "compilerOptions": {
5
- "rootDir": "./src",
6
- "outDir": "./build"
7
- },
8
- "include": ["./src"],
9
- "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
10
- }
package/vitest.config.ts DELETED
@@ -1,13 +0,0 @@
1
- import {defineConfig} from 'vitest/config'
2
-
3
- export default defineConfig({
4
- test: {
5
- include: ['src/__tests__/**/*.test.ts'],
6
- alias: {
7
- 'react-native': new URL(
8
- 'src/__tests__/__mocks__/react-native.ts',
9
- import.meta.url,
10
- ).pathname,
11
- },
12
- },
13
- })