@bsky.app/tapper 0.5.6 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @bsky.app/tapper
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ 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.
10
+
11
+ **New peer dependency:** `tlds`. Install it alongside tapper (`pnpm add tlds`) — if your app also uses `@atproto/api`, it already depends on it.
12
+
13
+ ## 0.5.7
14
+
15
+ ### Patch Changes
16
+
17
+ - [`585597d`](https://github.com/bluesky-social/toolbox/commit/585597d1d9c14bee8b074e6fd7fed5ed1f277294) Thanks [@mozzius](https://github.com/mozzius)! - Enable provenance via env
18
+
3
19
  ## 0.5.6
4
20
 
5
21
  ### 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;AAW5L,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,23 @@
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
+ export const url = {
18
+ pattern: /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim,
19
+ validate: m => {
20
+ if (m[3])
21
+ return true;
22
+ const domain = m.groups?.domain;
23
+ if (!domain)
24
+ return false;
25
+ return TLD_SET.has(domain.slice(domain.lastIndexOf('.') + 1));
26
+ },
27
+ };
9
28
  //# 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,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,MAAM,EAAE,MAAM,CAAA;QAC/B,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.\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.groups?.domain\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,CAUtB;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,CA8Kd;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,18 +18,23 @@ 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
+ compiled.set(name, {
29
+ regex: new RegExp(re.source, re.flags),
30
+ validate: def instanceof RegExp ? undefined : def.validate,
31
+ });
27
32
  }
28
33
  return compiled;
29
34
  }
30
35
  export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
31
36
  const allMatches = [];
32
- for (const [name, re] of regexes) {
37
+ for (const [name, { regex: re, validate }] of regexes) {
33
38
  // Reset lastIndex so stateful (global) regexes don't carry over
34
39
  // match positions from the previous parse call.
35
40
  re.lastIndex = 0;
@@ -37,6 +42,8 @@ export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
37
42
  // boundary char (m[1]) and bump the value capture index by one.
38
43
  const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX);
39
44
  for (const m of text.matchAll(re)) {
45
+ if (validate && !validate(m))
46
+ continue;
40
47
  const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0;
41
48
  const fullMatch = m[0].slice(boundaryLen);
42
49
  allMatches.push({
@@ -188,7 +195,8 @@ export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
188
195
  }
189
196
  export function deriveTriggers(config) {
190
197
  const triggers = new Map();
191
- for (const [name, re] of Object.entries(config)) {
198
+ for (const [name, def] of Object.entries(config)) {
199
+ const re = def instanceof RegExp ? def : def.pattern;
192
200
  // Skip the boundary prefix (if present) before extracting the trigger char.
193
201
  const src = re.source.startsWith(BOUNDARY_PREFIX)
194
202
  ? 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 compiled.set(name, {\n regex: new RegExp(re.source, re.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 for (const m of text.matchAll(re)) {\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.6",
3
+ "version": "0.6.0",
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,23 @@ 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
+ export const url: TapperFacetDefinition = {
24
+ pattern:
25
+ /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim,
26
+ validate: m => {
27
+ if (m[3]) return true
28
+ const domain = m.groups?.domain
29
+ if (!domain) return false
30
+ return TLD_SET.has(domain.slice(domain.lastIndexOf('.') + 1))
31
+ },
32
+ }
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,30 @@ 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
+ compiled.set(name, {
41
+ regex: new RegExp(re.source, re.flags),
42
+ validate: def instanceof RegExp ? undefined : def.validate,
43
+ })
34
44
  }
35
45
  return compiled
36
46
  }
@@ -49,7 +59,7 @@ export function parseNodesFromText(
49
59
  index: number
50
60
  }[] = []
51
61
 
52
- for (const [name, re] of regexes) {
62
+ for (const [name, {regex: re, validate}] of regexes) {
53
63
  // Reset lastIndex so stateful (global) regexes don't carry over
54
64
  // match positions from the previous parse call.
55
65
  re.lastIndex = 0
@@ -57,6 +67,7 @@ export function parseNodesFromText(
57
67
  // boundary char (m[1]) and bump the value capture index by one.
58
68
  const hasBoundary = re.source.startsWith(BOUNDARY_PREFIX)
59
69
  for (const m of text.matchAll(re)) {
70
+ if (validate && !validate(m)) continue
60
71
  const boundaryLen = hasBoundary ? (m[1]?.length ?? 0) : 0
61
72
  const fullMatch = m[0].slice(boundaryLen)
62
73
  allMatches.push({
@@ -218,7 +229,8 @@ export function parseNodesFromText(
218
229
 
219
230
  export function deriveTriggers(config: TapperFacetConfig): Map<string, string> {
220
231
  const triggers = new Map<string, string>()
221
- for (const [name, re] of Object.entries(config)) {
232
+ for (const [name, def] of Object.entries(config)) {
233
+ const re = def instanceof RegExp ? def : def.pattern
222
234
  // Skip the boundary prefix (if present) before extracting the trigger char.
223
235
  const src = re.source.startsWith(BOUNDARY_PREFIX)
224
236
  ? re.source.slice(BOUNDARY_PREFIX.length)