a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(bluesky-richtext-parser): parse cashtag syntax

Mary 98aef692 947e2179

+222 -1
+5
.changeset/breezy-geese-drum.md
··· 1 + --- 2 + '@atcute/bluesky-richtext-parser': minor 3 + --- 4 + 5 + parse cashtag syntax
+193
packages/bluesky/richtext-parser/lib/index.test.ts
··· 437 437 ]); 438 438 }); 439 439 440 + it('cashtags', () => { 441 + expect(tokenize('$AAPL')).toEqual([ 442 + { 443 + name: 'AAPL', 444 + raw: '$AAPL', 445 + type: 'cashtag', 446 + }, 447 + ]); 448 + 449 + expect(tokenize('$AAPL')).toEqual([ 450 + { 451 + name: 'AAPL', 452 + raw: '$AAPL', 453 + type: 'cashtag', 454 + }, 455 + ]); 456 + 457 + expect(tokenize('$aapl')).toEqual([ 458 + { 459 + name: 'aapl', 460 + raw: '$aapl', 461 + type: 'cashtag', 462 + }, 463 + ]); 464 + 465 + expect(tokenize('$BTC')).toEqual([ 466 + { 467 + name: 'BTC', 468 + raw: '$BTC', 469 + type: 'cashtag', 470 + }, 471 + ]); 472 + 473 + expect(tokenize('$DOGE2')).toEqual([ 474 + { 475 + name: 'DOGE2', 476 + raw: '$DOGE2', 477 + type: 'cashtag', 478 + }, 479 + ]); 480 + 481 + expect(tokenize('$LONGNAME')).toEqual([ 482 + { 483 + name: 'LONGNAME', 484 + raw: '$LONGNAME', 485 + type: 'cashtag', 486 + }, 487 + ]); 488 + 489 + // too long - doesn't match (no boundary after 8 chars) 490 + expect(tokenize('$TOOLONGNAME')).toEqual([ 491 + { 492 + content: '$TOOLONGNAME', 493 + raw: '$TOOLONGNAME', 494 + type: 'text', 495 + }, 496 + ]); 497 + 498 + // must start with letter 499 + expect(tokenize('$123')).toEqual([ 500 + { 501 + content: '$123', 502 + raw: '$123', 503 + type: 'text', 504 + }, 505 + ]); 506 + 507 + expect(tokenize('hello$AAPL')).toEqual([ 508 + { 509 + content: 'hello$AAPL', 510 + raw: 'hello$AAPL', 511 + type: 'text', 512 + }, 513 + ]); 514 + 515 + expect(tokenize('hello($AAPL')).toEqual([ 516 + { 517 + content: 'hello(', 518 + raw: 'hello(', 519 + type: 'text', 520 + }, 521 + { 522 + name: 'AAPL', 523 + raw: '$AAPL', 524 + type: 'cashtag', 525 + }, 526 + ]); 527 + 528 + expect(tokenize('$AAPL.')).toEqual([ 529 + { 530 + name: 'AAPL', 531 + raw: '$AAPL', 532 + type: 'cashtag', 533 + }, 534 + { 535 + content: '.', 536 + raw: '.', 537 + type: 'text', 538 + }, 539 + ]); 540 + 541 + expect(tokenize('$AAPL$')).toEqual([ 542 + { 543 + content: '$AAPL$', 544 + raw: '$AAPL$', 545 + type: 'text', 546 + }, 547 + ]); 548 + 549 + expect(tokenize('hello $AAPL$')).toEqual([ 550 + { 551 + content: 'hello ', 552 + raw: 'hello $AAPL$', 553 + type: 'text', 554 + }, 555 + ]); 556 + 557 + expect(tokenize('$AAPL.$')).toEqual([ 558 + { 559 + name: 'AAPL', 560 + raw: '$AAPL', 561 + type: 'cashtag', 562 + }, 563 + { 564 + content: '.', 565 + raw: '.$', 566 + type: 'text', 567 + }, 568 + ]); 569 + 570 + expect(tokenize('$AAPL hello')).toEqual([ 571 + { 572 + name: 'AAPL', 573 + raw: '$AAPL', 574 + type: 'cashtag', 575 + }, 576 + { 577 + content: ' hello', 578 + raw: ' hello', 579 + type: 'text', 580 + }, 581 + ]); 582 + 583 + expect(tokenize('hello $AAPL hello')).toEqual([ 584 + { 585 + content: 'hello ', 586 + raw: 'hello ', 587 + type: 'text', 588 + }, 589 + { 590 + name: 'AAPL', 591 + raw: '$AAPL', 592 + type: 'cashtag', 593 + }, 594 + { 595 + content: ' hello', 596 + raw: ' hello', 597 + type: 'text', 598 + }, 599 + ]); 600 + 601 + expect(tokenize('hello $AAPL')).toEqual([ 602 + { 603 + content: 'hello ', 604 + raw: 'hello ', 605 + type: 'text', 606 + }, 607 + { 608 + name: 'AAPL', 609 + raw: '$AAPL', 610 + type: 'cashtag', 611 + }, 612 + ]); 613 + 614 + expect(tokenize('$AAPL $MSFT')).toEqual([ 615 + { 616 + name: 'AAPL', 617 + raw: '$AAPL', 618 + type: 'cashtag', 619 + }, 620 + { 621 + content: ' ', 622 + raw: ' ', 623 + type: 'text', 624 + }, 625 + { 626 + name: 'MSFT', 627 + raw: '$MSFT', 628 + type: 'cashtag', 629 + }, 630 + ]); 631 + }); 632 + 440 633 it('autolinks', () => { 441 634 expect(tokenize('https://example.com')).toEqual([ 442 635 {
+24 -1
packages/bluesky/richtext-parser/lib/index.ts
··· 5 5 const TOPIC_RE = 6 6 /^(?:#(?!\ufe0f|\u20e3)|#)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/u; 7 7 8 + const CASHTAG_RE = /^[$$]([A-Za-z][A-Za-z0-9]{0,7})($|\s|\p{P})/u; 9 + 8 10 const EMOTE_RE = /^:([\w-]+):/; 9 11 10 12 const AUTOLINK_RE = /^https?:\/\/[\S]+/; ··· 26 28 const CODE_RE = /^(`+)([^]*?[^`])\1(?!`)/; 27 29 const CODE_ESCAPE_BACKTICKS_RE = /^ (?= *`)|(` *) $/g; 28 30 29 - const TEXT_RE = /^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@@##]))/; 31 + const TEXT_RE = /^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@@##$$]))/; 30 32 31 33 export interface EscapeToken { 32 34 type: 'escape'; ··· 46 48 name: string; 47 49 } 48 50 51 + export interface CashtagToken { 52 + type: 'cashtag'; 53 + raw: string; 54 + name: string; 55 + } 56 + 49 57 export interface EmoteToken { 50 58 type: 'emote'; 51 59 raw: string; ··· 105 113 | EscapeToken 106 114 | MentionToken 107 115 | TopicToken 116 + | CashtagToken 108 117 | EmoteToken 109 118 | AutolinkToken 110 119 | LinkToken ··· 152 161 } 153 162 }; 154 163 164 + const tokenizeCashtag = (src: string): CashtagToken | undefined => { 165 + const match = CASHTAG_RE.exec(src); 166 + if (match && match[2] !== '$') { 167 + const suffix = match[2].length; 168 + 169 + return { 170 + type: 'cashtag', 171 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 172 + name: match[1], 173 + }; 174 + } 175 + }; 176 + 155 177 const tokenizeEmote = (src: string): EmoteToken | undefined => { 156 178 const match = EMOTE_RE.exec(src); 157 179 if (match) { ··· 296 318 tokenizeAutolink(src) || 297 319 tokenizeMention(src) || 298 320 tokenizeTopic(src) || 321 + tokenizeCashtag(src) || 299 322 tokenizeEmote(src) || 300 323 tokenizeLink(src) || 301 324 tokenizeEmStrongU(src) ||