Skip to content

Commit c673fba

Browse files
feat: add email autolinking support (508/634)
1 parent 9eb1ce6 commit c673fba

1 file changed

Lines changed: 103 additions & 9 deletions

File tree

packages/message-parser/src/parser.ts

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
autoLink,
2727
bigEmoji,
2828
emoji,
29+
autoEmail,
2930
} from './utils';
3031
import { Scanner } from './scanner';
3132
import { isNewline, isPlainChar, isSpace, isAlpha, isAlphaNum, isDigit } from './chars';
@@ -138,11 +139,12 @@ export function parse(input: string, options: Options = {}): Root {
138139

139140
function tryCodeFence(scanner: Scanner): Root[number] | null {
140141
const start = scanner.save();
142+
const fence = '```';
141143

142-
if (!scanner.matches('```')) {
144+
if (!scanner.matches(fence)) {
143145
return null;
144146
}
145-
scanner.advance(3);
147+
scanner.advance(fence.length);
146148

147149
// Optional language tag
148150
const langStart = scanner.save();
@@ -168,8 +170,8 @@ function tryCodeFence(scanner: Scanner): Root[number] | null {
168170

169171
while (!scanner.isEnd()) {
170172
// Check for closing ```
171-
if (scanner.matches('```')) {
172-
scanner.advance(3);
173+
if (scanner.matches(fence)) {
174+
scanner.advance(fence.length);
173175
// Consume any trailing content after closing ```
174176
while (!scanner.isEnd() && !isNewline(scanner.char())) {
175177
scanner.advance();
@@ -388,6 +390,16 @@ function parseInline(scanner: Scanner, options: Options): Inlines[] {
388390
}
389391
}
390392

393+
// Email (local@domain) — before bare URL / autolink so it claims the @-region
394+
if (isAlpha(ch) || isDigit(ch) || ch.charCodeAt(0) > 127) {
395+
const result = tryEmail(scanner);
396+
if (result !== null) {
397+
nodes.push(result);
398+
prevChar = '';
399+
continue;
400+
}
401+
}
402+
391403
// Bare URL (word.word style)
392404
if (isAlpha(ch)) {
393405
const result = tryBareUrl(scanner);
@@ -408,10 +420,16 @@ function parseInline(scanner: Scanner, options: Options): Inlines[] {
408420
}
409421
}
410422

411-
// Plain run
412-
if (isPlainChar(ch)) {
423+
// Plain run — stop at '(' and ')' so dispatch can re-attempt email/URL after them
424+
if (isPlainChar(ch) && ch !== '(' && ch !== ')') {
413425
const start = scanner.save();
414-
while (!scanner.isEnd() && isPlainChar(scanner.char()) && !isSpace(scanner.char())) {
426+
while (
427+
!scanner.isEnd() &&
428+
isPlainChar(scanner.char()) &&
429+
!isSpace(scanner.char()) &&
430+
scanner.char() !== '(' &&
431+
scanner.char() !== ')'
432+
) {
415433
scanner.advance();
416434
}
417435
const text = scanner.sliceFrom(start);
@@ -581,6 +599,16 @@ function parseInlineContent(scanner: Scanner, options: Options, stopChar: string
581599
}
582600
}
583601

602+
// Email (local@domain) — before bare URL / autolink so it claims the @-region
603+
if (isAlpha(ch) || isDigit(ch) || ch.charCodeAt(0) > 127) {
604+
const result = tryEmail(scanner);
605+
if (result !== null) {
606+
nodes.push(result);
607+
prevChar = '';
608+
continue;
609+
}
610+
}
611+
584612
// Bare URL
585613
if (isAlpha(ch)) {
586614
const result = tryBareUrl(scanner);
@@ -602,9 +630,16 @@ function parseInlineContent(scanner: Scanner, options: Options, stopChar: string
602630
}
603631

604632
// Plain run
605-
if (isPlainChar(ch)) {
633+
if (isPlainChar(ch) && ch !== '(' && ch !== ')') {
606634
const start = scanner.save();
607-
while (!scanner.isEnd() && isPlainChar(scanner.char()) && !isSpace(scanner.char()) && !scanner.matches(stopChar)) {
635+
while (
636+
!scanner.isEnd() &&
637+
isPlainChar(scanner.char()) &&
638+
!isSpace(scanner.char()) &&
639+
!scanner.matches(stopChar) &&
640+
scanner.char() !== '(' &&
641+
scanner.char() !== ')'
642+
) {
608643
scanner.advance();
609644
}
610645
const text = scanner.sliceFrom(start);
@@ -1480,3 +1515,62 @@ function tryBigEmoji(input: string): Root | null {
14801515

14811516
return [bigEmoji(emojis as any)];
14821517
}
1518+
1519+
// ─── Email ─────────────────────────────────────────────────────────────────
1520+
1521+
function tryEmail(scanner: Scanner): Inlines | null {
1522+
const start = scanner.save();
1523+
const delimiter = 'mailto:';
1524+
1525+
// Optional "mailto:" prefix — consumed, but NOT part of the captured address
1526+
if (scanner.matches(delimiter)) {
1527+
scanner.advance(delimiter.length);
1528+
}
1529+
1530+
// Local part: alphanumeric / unicode, plus . _ + - '
1531+
const localStart = scanner.save();
1532+
while (!scanner.isEnd()) {
1533+
const ch = scanner.char();
1534+
if (isAlphaNum(ch) || ch.charCodeAt(0) > 127 || ch === '.' || ch === '_' || ch === '+' || ch === '-' || ch === "'") {
1535+
scanner.advance();
1536+
} else {
1537+
break;
1538+
}
1539+
}
1540+
const local = scanner.sliceFrom(localStart);
1541+
1542+
// Must have a non-empty local part immediately followed by '@'
1543+
if (local.length === 0 || scanner.char() !== '@') {
1544+
scanner.restore(start);
1545+
return null;
1546+
}
1547+
scanner.advance(); // consume '@'
1548+
1549+
// Domain: alphanumeric / unicode, plus . -
1550+
const domainStart = scanner.save();
1551+
while (!scanner.isEnd()) {
1552+
const ch = scanner.char();
1553+
if (isAlphaNum(ch) || ch.charCodeAt(0) > 127 || ch === '.' || ch === '-') {
1554+
scanner.advance();
1555+
} else {
1556+
break;
1557+
}
1558+
}
1559+
1560+
// Trim trailing '.' / '-' back out of the domain ("joe.com." → "joe.com")
1561+
while (scanner.pos > domainStart && (scanner.charAt(-1) === '.' || scanner.charAt(-1) === '-')) {
1562+
scanner.advance(-1);
1563+
}
1564+
1565+
const domain = scanner.sliceFrom(domainStart);
1566+
1567+
// Domain must contain a dot that is not at the very start or end
1568+
const dotIdx = domain.indexOf('.');
1569+
if (dotIdx <= 0 || dotIdx === domain.length - 1) {
1570+
scanner.restore(start);
1571+
return null;
1572+
}
1573+
1574+
// autoEmail validates the TLD via tldts (same logic the grammar uses):
1575+
return autoEmail(local + '@' + domain);
1576+
}

0 commit comments

Comments
 (0)