@@ -26,6 +26,7 @@ import {
2626 autoLink ,
2727 bigEmoji ,
2828 emoji ,
29+ autoEmail ,
2930} from './utils' ;
3031import { Scanner } from './scanner' ;
3132import { isNewline , isPlainChar , isSpace , isAlpha , isAlphaNum , isDigit } from './chars' ;
@@ -138,11 +139,12 @@ export function parse(input: string, options: Options = {}): Root {
138139
139140function 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