diff --git a/packages/message-parser/jest.config.ts b/packages/message-parser/jest.config.ts index cf51860fc6600..24eccc2e9e793 100644 --- a/packages/message-parser/jest.config.ts +++ b/packages/message-parser/jest.config.ts @@ -1,18 +1,8 @@ -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - import server from '@rocket.chat/jest-presets/server'; import type { Config } from 'jest'; -// Jest 30 loads this config via Node's native type stripping (Node 22.18+), -// which treats the file as ESM where __dirname is not defined as a global. -const __dirname = dirname(fileURLToPath(import.meta.url)); - export default { preset: server.preset, - transform: { - '\\.pegjs$': resolve(__dirname, './loaders/pegtransform.js'), - }, - moduleFileExtensions: ['js', 'ts', 'pegjs'], + moduleFileExtensions: ['js', 'ts'], testPathIgnorePatterns: ['/node_modules/', '\\.bench\\.ts$'], } satisfies Config; diff --git a/packages/message-parser/loaders/pegjs-register.js b/packages/message-parser/loaders/pegjs-register.js deleted file mode 100644 index 9a4de3c665268..0000000000000 --- a/packages/message-parser/loaders/pegjs-register.js +++ /dev/null @@ -1,13 +0,0 @@ -const fs = require('fs'); -const Module = require('module'); - -const peggy = require('peggy'); - -Module._extensions['.pegjs'] = function (mod, filename) { - const content = fs.readFileSync(filename, 'utf-8'); - const code = peggy.generate(content, { - output: 'source', - format: 'commonjs', - }); - mod._compile(code, filename); -}; diff --git a/packages/message-parser/loaders/pegtransform.js b/packages/message-parser/loaders/pegtransform.js deleted file mode 100644 index 52aca042a84fb..0000000000000 --- a/packages/message-parser/loaders/pegtransform.js +++ /dev/null @@ -1,10 +0,0 @@ -const pegjs = require('peggy'); - -module.exports = { - process: (content) => ({ - code: pegjs.generate(content, { - output: 'source', - format: 'commonjs', - }), - }), -}; diff --git a/packages/message-parser/package.json b/packages/message-parser/package.json index e825de9648617..f9228541cf1dd 100644 --- a/packages/message-parser/package.json +++ b/packages/message-parser/package.json @@ -35,7 +35,7 @@ ".:build:bundle": "webpack-cli", ".:build:clean": "rimraf dist", "build": "run-s .:build:clean .:build:bundle", - "bench": "ts-node --compiler-options '{\"module\":\"commonjs\"}' -r ./loaders/pegjs-register.js benchmarks/parser.bench.ts", + "bench": "ts-node --compiler-options '{\"module\":\"commonjs\"}' benchmarks/parser.bench.ts", "bench:size": "node -e \"const fs=require('fs'),zlib=require('zlib'),p='dist/messageParser.js';try{const s=fs.statSync(p),c=fs.readFileSync(p),g=zlib.gzipSync(c);console.log('Bundle:',p);console.log('Raw:',s.size,'bytes ('+(s.size/1024).toFixed(1),'KB)');console.log('Gzip:',g.length,'bytes ('+(g.length/1024).toFixed(1),'KB)')}catch(e){console.error('Build first: yarn build');process.exit(1)}\"", "lint": "eslint .", "test": "jest", @@ -47,7 +47,6 @@ }, "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", - "@rocket.chat/peggy-loader": "workspace:~", "@rocket.chat/prettier-config": "~0.31.25", "@types/jest": "~30.0.0", "@types/node": "~22.19.21", @@ -55,9 +54,7 @@ "fast-check": "^4.6.0", "jest": "~30.2.0", "npm-run-all": "^4.1.5", - "peggy": "4.1.1", "prettier": "~3.3.3", - "prettier-plugin-pegjs": "~0.5.4", "rimraf": "^6.0.1", "tinybench": "^3.0.7", "ts-loader": "~9.5.7", diff --git a/packages/message-parser/src/chars.ts b/packages/message-parser/src/chars.ts new file mode 100644 index 0000000000000..977b945dcbcbc --- /dev/null +++ b/packages/message-parser/src/chars.ts @@ -0,0 +1,100 @@ +export function isNewline(ch: string): boolean { + return ch === '\n' || ch === '\r'; +} + +export function isSpace(ch: string): boolean { + return ch === ' ' || ch === '\t'; +} + +export function isWhitespace(ch: string): boolean { + return isSpace(ch) || isNewline(ch); +} + +export function isDigit(ch: string): boolean { + return ch >= '0' && ch <= '9'; +} + +export function isAlpha(ch: string): boolean { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); +} + +export function isAlphaNum(ch: string): boolean { + return isAlpha(ch) || isDigit(ch); +} + +export function isMarkupChar(ch: string): boolean { + return '*_~`#@:|\\[!<$+()'.includes(ch); +} + +export function isPlainChar(ch: string): boolean { + return ch !== '' && !isNewline(ch) && !isMarkupChar(ch) && !isSpace(ch); +} + +export function isUrlStart(ch: string): boolean { + return isAlpha(ch) || isDigit(ch); +} + +export function isEmailStart(ch: string): boolean { + return isUrlStart(ch) || ch.charCodeAt(0) > 127; +} + +export function isEmojiStart(ch: string): boolean { + const code = ch.charCodeAt(0); + return ( + code === 0xa9 || + code === 0xae || + code === 0x203c || + code === 0x2049 || + code === 0x2122 || + code === 0x2139 || + (code >= 0x2194 && code <= 0x21aa) || // Arrows + (code >= 0x231a && code <= 0x23fa) || // Misc Technical + code === 0x24c2 || + (code >= 0x25aa && code <= 0x27bf) || // Geometric Shapes, Symbols, Dingbats + (code >= 0x2934 && code <= 0x2b55) || // Supplemental Arrows + code === 0x3030 || + code === 0x303d || + code === 0x3297 || + code === 0x3299 || + (code >= 0xd800 && code <= 0xdbff) || // High surrogates (U+10000+ emoji) + ch === '#' || // keycap bases + ch === '*' || + (ch >= '0' && ch <= '9') + ); +} + +export function isHexDigit(ch: string): boolean { + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'); +} + +// ─── Emoticon ────────────────────────────────────────────────────────────── +export const EMOTICONS: Record = { + ':)': 'slight_smile', + ':-)': 'slight_smile', + ':(': 'frowning', + ':-(': 'frowning', + 'D:': 'fearful', + ':D': 'grinning', + ':-D': 'grinning', + ':P': 'stuck_out_tongue', + ':-P': 'stuck_out_tongue', + ':p': 'stuck_out_tongue', + ':-p': 'stuck_out_tongue', + ';)': 'wink', + ';-)': 'wink', + ':o': 'open_mouth', + ':-o': 'open_mouth', + ':O': 'open_mouth', + ':-O': 'open_mouth', + ':|': 'neutral_face', + ':-|': 'neutral_face', + ':/': 'confused', + ':-/': 'confused', + ':\\': 'confused', + ':-\\': 'confused', + ':*': 'kissing_heart', + '-_-': 'expressionless', +}; + +// Sorted longest-first so e.g. ":-D" wins over ":D" +export const EMOTICON_KEYS = Object.keys(EMOTICONS).sort((a, b) => b.length - a.length); diff --git a/packages/message-parser/src/grammar.pegjs b/packages/message-parser/src/grammar.pegjs deleted file mode 100644 index 0ac2f4a2525f3..0000000000000 --- a/packages/message-parser/src/grammar.pegjs +++ /dev/null @@ -1,938 +0,0 @@ -{{ - const { - autoEmail, - autoLink, - bigEmoji, - bold, - code, - codeLine, - color, - emoji, - emojiUnicode, - emoticon, - heading, - image, - inlineCode, - inlineKatex, - horizontalRule, - italic, - katex, - lineBreak, - link, - listItem, - mentionChannel, - mentionUser, - orderedList, - paragraph, - phoneChecker, - plain, - quote, - reducePlainTexts, - spoiler, - spoilerBlock, - strike, - table, - task, - tasks, - unorderedList, - timestamp, - timestampFromHours, - timestampFromIsoTime, - } = require('./utils'); - -let skipBold = false; -let skipItalic = false; -let skipStrikethrough = false; -let skipReferences = false; -}} - -Start - = @BigEmoji !. - / (Blocks / Paragraph / EndOfLine { return paragraph([plain('')]); })+ - -/** - * - * Blocks - * - */ -Blocks - = Blockquote - / BlockSpoiler - / Code - / HorizontalRule - / Table - / Heading - / Tasks - / OrderedList - / UnorderedList - / Katex - / LineBreak - -/** - * - * Blockquote - * e.g: > This is a blockquote - * - */ -Blockquote = b:BlockquoteLine+ { return quote(b); } - -BlockquoteLine - = ">" [ \t]* EndOfLine { return paragraph([plain('')]); } - / ">" [ \t]* @Paragraph - -/** - * Block Spoiler - * e.g: - * || - * line one - * line two - * || - */ -BlockSpoiler = "||" EndOfLine first:(&(! "||") @Paragraph) rest:(&(! "||") @Paragraph)* EndOfLine? "||" { return spoilerBlock([first, ...rest]); } - -/** - * - * Table (GFM) - * e.g: - * | Header 1 | Header 2 | - * | -------- | :------: | - * | Cell 1 | Cell 2 | - * - * v1 requires a leading and trailing pipe on every row. Alignment comes from - * the delimiter row: `:---` left, `:--:` center, `---:` right, `---` none. - * A literal pipe inside a cell must be escaped as `\|`. - */ -Table = header:TableRowLine aligns:TableDelimiterRow body:TableRowLine* { return table(header, aligns, body, [range().start, range().end]); } - -TableRowLine = "|" cells:(@TableCell "|")+ EndOfLine? { return cells; } - -TableCell = items:TableCellItem* { return reducePlainTexts(items); } - -TableCellItem - = "\\|" { return plain('|'); } - / !"|" !EndOfLine @(InlineItemPattern / Any) - -TableDelimiterRow = "|" aligns:(@TableDelimiterCell "|")+ EndOfLine? { return aligns; } - -TableDelimiterCell = [ \t]* left:":"? "-"+ right:":"? [ \t]* { - if (left && right) { return 'center'; } - if (right) { return 'right'; } - if (left) { return 'left'; } - return undefined; - } - -// -// -// -// - -TimestampType = "t" / "T" / "d" / "D" / "f" / "F" / "R" - -Unixtime = $(Digit |10|) - -TimestampHoursMinutesSeconds = hours:$(Digit |2|) ":" minutes:$(Digit |2|) ":" seconds:$(Digit |2|) tz:Timezone? { return timestampFromHours(hours, minutes, seconds, tz); } - -TimestampHoursMinutes = hours:$(Digit |2|) ":" minutes:$(Digit |2|) tz:Timezone? { return timestampFromHours(hours, minutes, undefined, tz); } - - -Timestamp = TimestampHoursMinutesSeconds / TimestampHoursMinutes - -Timezone = offset:('+'/'-') tzHour:$(Digit |2|) ":" tzMinute:$(Digit |2|) { return offset + tzHour + ':' + tzMinute; } - -ISO8601Date = year:$(Digit |4|) "-" month:$(Digit |2|) "-" day:$(Digit |2|) "T" hours:$(Digit |2|) ":" minutes:$(Digit |2|) ":" seconds:$(Digit |2|) "." milliseconds:$(Digit |3|) tz:Timezone? { return timestampFromIsoTime({ year, month, day, hours, minutes, seconds, milliseconds, timezone: tz }); } - -ISO8601DateWithoutMilliseconds = year:$(Digit |4|) "-" month:$(Digit |2|) "-" day:$(Digit |2|) "T" hours:$(Digit |2|) ":" minutes:$(Digit |2|) ":" seconds:$(Digit |2|) tz:Timezone? { return timestampFromIsoTime({ year, month, day, hours, minutes, seconds, timezone: tz }); } - - -TimestampRules = "" { return timestamp(date, format, [range().start, range().end]); } / "" { return timestamp(date, undefined, [range().start, range().end]); } - -/** - * - * Code Chunk - * e.g: - * ```js - * console.log('hello world'); - * ``` - */ -Code = "```" language:CodeLanguage? EndOfLine lines:CodeLine+ EndOfLine "```" { return code(lines, language); } - -CodeLanguage = $[a-zA-Z0-9 \_\-.]+ - -CodeLine - = chunk:CodeChunk { return codeLine(chunk); } - / "\n" chunk:CodeChunk { return codeLine(chunk); } - / "\n" !"```" { return codeLine(plain('')); } - -// Charclass avoids per-char lookahead; never consume start of "```". -// Trailing 1-2 backticks before a line end (or EOF) are content, not a fence. -CodeChunkChar = [^\r\n`] / "`" [^`\r\n] / "`" "`" [^`\r\n] / "`" "`" &("\r" / "\n" / !.) / "`" &("\r" / "\n" / !.) -CodeChunk = text:$(CodeChunkChar)+ { return plain(text); } - -/** - * - * Heading: h1, h2, h3, h4 - * e.g: - * # Heading 1 - * ## Heading 2 - * ### Heading 3 - * #### Heading 4 - * -*/ -Heading = count:HeadingStart [ \t]+ text:HeadingChunk EndOfLine? { return heading(text, count); } - -HeadingStart = value:"#" |1..4| { return value.length; } - -HeadingChunk = items:HeadingInlineItem+ { return reducePlainTexts(items); } - -HeadingInlineItem = InlineItemPattern / !EndOfLine @Any - -/** - * - * Tasks - * e.g: - * - [x] Task One (checked) - * - [ ] Task two - * - [x] Task three (checked) - * - */ -Tasks = items:Task+ { return tasks(items); } - -Task = "- [" flag:TaskFlag "]" [ \t]+ text:Inline { return task(text, flag); } - -TaskFlag = "x" { return true; } / " " { return false; } - -/** - * - * Ordered List - * e.g: - * 1. Item One - * 2. Item Two - * 3. Item Three - * - */ -OrderedList = items:OrderedListItem+ { return orderedList(items); } - -OrderedListItem = number:Digits "." [ \t]+ text:Inline { return listItem(text, parseInt(number, 10)); } - -/** - * - * Unordered List - * e.g: - * - Item One - * - Item Two - * * Item Three - * * Item Four - * - */ -UnorderedList = items:(UnorderedListHyphenItem+ / UnorderedListAsteriskItem+) { return unorderedList(items); } - -UnorderedListHyphenItem = "-" [ \t]+ text:Inline { return listItem(text); } - -UnorderedListAsteriskItem = "*" [ \t]+ text:UnorderedListItemContent { return listItem(text); } - -UnorderedListItemContent = value:UnorderedListItemContentItem+ !"*" EndOfLine? { return reducePlainTexts(value); } - -UnorderedListItemContentItem = InlineItemPattern / !"*" @Any - -/** - * - * KaTex - * e.g: \[ KATEX_HERE \] OR $$ KATEX_HERE $$ - * $$x = \begin{cases} - * a &\text{if } b \\ - * c &\text{if } d - * \end{cases}$$ - * - */ -Katex = KatexStart content:$([^$\\] / !KatexEnd .)* KatexEnd { return katex(content); } - -KatexStart - = & { return options.katex?.parenthesisSyntax; } "\\[" - / & { return options.katex?.dollarSyntax; } "$$" - -KatexEnd - = & { return options.katex?.parenthesisSyntax; } "\\]" - / & { return options.katex?.dollarSyntax; } "$$" - -KatexInline - = KatexInlineStart content:$([^$\\\r\n] / !KatexInlineEnd .)* KatexInlineEnd { - return inlineKatex(content); - } - -KatexInlineStart - = & { return options.katex?.parenthesisSyntax; } "\\(" - / & { return options.katex?.dollarSyntax; } "$" - -KatexInlineEnd - = & { return options.katex?.parenthesisSyntax; } "\\)" - / & { return options.katex?.dollarSyntax; } "$" - -/** - * - * LineBreak - * e.g: \n - * -*/ -LineBreak = Space* EndOfLine { return lineBreak(); } - -/** - * - * Horizontal Rule (thematic break) - * e.g: ---, ---------- - * - * A line made up of 3+ contiguous dashes, with nothing else on the line - * (leading/trailing spaces allowed). Only `-` is accepted: CommonMark also - * allows `*` and `_`, but those collide with emphasis and with censored words - * (bad-words masks a term as a run of `*`), so a bare `***` / `_______` line - * stays as text/emphasis instead of turning into a divider. - * -*/ -HorizontalRule = [ \t]* loc:HorizontalRuleMarkers [ \t]* (EndOfLine / !.) { return horizontalRule(loc); } - -HorizontalRuleMarkers - = "-" |3..| { return [range().start, range().end]; } - -/** - * - * Paragraph - * e.g: This is a paragraph -*/ -Paragraph = value:Inline { return paragraph(value); } - -/** - * - * Inline - * -*/ -Inline = value:(InlineItemPattern / Any)+ EndOfLine? { return reducePlainTexts(value); } - -InlineEmoji = emo:Emoji { return emo; } - -InlineEmoticon = emo:Emoticon & (EmoticonNeighbor / InlineItemPattern) { return emo; } - -// Match "-" only when "-_-" is followed by more (so "-_-italic" → plain+italic); don't match when "-_-" is the full emoticon -PlainRunBeforeEmoticon = "-" &("_" "-" .) { return plain('-'); } - -PlainRun = run:$[^*_~`:\n<\[\]! \t()\\|]+ { return plain(run); } - -EscapedTimestampRules - = "\\" "" { - return plain(``); - } - / "\\" "" { - return plain(``); - } - -// First-character dispatch: skip the full alternative chain by routing -// each character to only the rules that can start with it. -InlineItemPattern - = & [ \t] @Whitespace - / & "\\" @(EscapedTimestampRules / KatexInline / Escaped) - / & "[" @MaybeReferences - / & "<" @(TimestampRules / MaybeReferences / InlineEmoticon) - / & "!" @Image - / & "|" @Spoiler - / & "@" @UserMentionDirect - / & "`" @InlineCode - / & "+" @AutolinkedPhone - / & "$" @KatexInline - / & ":" @(InlineEmoji / InlineEmoticon) - / & "*" @(EmphasisWithWhitespace / Emphasis / InlineEmoticon) - / & "~" @(EmphasisWithWhitespace / Emphasis) - / & "-" @(PlainRunBeforeEmoticon / InlineEmoticon) - / InlineItemSlowPath - -// Non-dispatched chars: emphasis with _, URLs, emails, emoticons, color, plain text -// Preserves original rule ordering to maintain parsing behavior -InlineItemSlowPath - = AutolinkedEmail - / PlainUnderscoreThenDomain - / AutolinkedURL - / EmphasisWithWhitespace - / Emphasis - / ChannelMention - / InlineEmoji - / PlainRunBeforeEmoticon - / InlineEmoticon - / Color - / PlainRun - -// Letters/digits: try email/URL (with cheap guards), then plain text -InlineItemAlphaPath - = AutolinkedEmail - / AutolinkedURL - / PlainRun - -/** - * - * Spoiler - * e.g: ||spoiler||, ||spoiler **bold**|| - * - */ -Spoiler = "||" text:SpoilerContentItems "||" { return spoiler(text); } - -SpoilerContentItems = text:SpoilerContentItem+ { return reducePlainTexts(text); } - -// Ensure we consume at least one character and do not accidentally match the closing "||" -SpoilerContentItem = !"||" @InlineItemPattern / !"||" @Any - -/** - * - * URL - * e.g: - * Reference: [Rocket.Chat Website](https://rocket.chat), [](https://rocket.chat), - * Image: ![](https://rocket.chat/logo.png) - * - */ -References - = "[" title:LinkTitle* "](" href:MarkdownLinkRef ")" { return title.length ? link(href, reducePlainTexts(title)) : link(href); } - / "<" href:LinkRef "|" title:LinkTitle2 ">" { return link(href, [plain(title)]); } - -// Fast-path: bulk consume chars that can't start ]( or ] [ and aren't emphasis markers -LinkTitle - = (Whitespace / Emphasis) - / anyTitle:$[^\]()*_~ \t\r\n]+ { return plain(anyTitle) } - / anyTitle:$(!("](" .) !("] [" [^\]]* "](") .) { return plain(anyTitle) } - -LinkTitle2 = $([\x20-\x3B\x3D\x3F-\x60\x61-\x7B\x7D-\xFF] / NonASCII)+ - -MarkdownLinkRef = MarkdownLinkURL / MarkdownLinkFilePath / p:Phone { return 'tel:' + p.number; } - -// LinkRef is used for non-markdown link contexts (like syntax) where parentheses aren't balanced -LinkRef = URL / FilePath / p:Phone { return 'tel:' + p.number; } - -FilePath = $(URLScheme URLBody+) - -MarkdownLinkFilePath = $(URLScheme MarkdownLinkURLBody+) - -// MarkdownLinkURL allows parentheses in URLs when inside markdown link syntax [title](url) -MarkdownLinkURL - = head:($(URLScheme URLAuthority) / $(URLAuthorityHost)) tail:$(MarkdownLinkURLBody*) { return head + tail; } - -MarkdownLinkURLBody - = ( - !(MarkdownLinkExtra+ (Whitespace / EndOfLine) / Whitespace) - !")" // Don't consume closing paren - (AnyText / [*\[\/\]\^_`{}~] / "(" MarkdownLinkURLBodyParen* ")") - )+ - -// Match content inside parentheses within URL -MarkdownLinkURLBodyParen = !(Whitespace / EndOfLine / ")") (AnyText / [*\[\/\]\^_`{}~(]) - -MarkdownLinkExtra = [.,!%*\"':;=] - -Image = "![" title:Line? "](" href:MarkdownLinkRef ")" { return title ? image(href, title) : image(href); } - -URL - = head:($(URLScheme URLAuthority) / $(URLAuthorityHost)) tail:$(URLBody*) { return head + tail; } - -URLScheme = $([A-Za-z0-9+-] |1..32| ":") - -URLBody - = ( - !(Extra+ (Whitespace / EndOfLine / !.) / Whitespace) - (AnyText / [*\[\/\]\^_`{}~(]) - )+ - -URLAuthority = $("//" URLAuthorityUserInfo? URLAuthorityHost) - -URLAuthorityUserInfo = $(URLAuthorityUser (":" URLAuthorityPassword)? "@") - -URLAuthorityUser = $(AlphaDigit / ![@/] Safe)+ - -URLAuthorityPassword = $(AlphaDigit / ![@/] Safe)+ - -URLAuthorityHost = URLAuthorityHostName (":" URLAuthorityPort)? - -URLAuthorityHostName - = DomainName - / $(Digits |4, "."|) // TODO: IPv4 and IPv6 - -URLAuthorityPort - = Digits // TODO: from "0" to "65535" - -DomainName - = "localhost" - / $(![\x5F] DomainNameLabel ("." DomainChar DomainNameLabel*)+) - -DomainNameLabel = $(DomainChar+ ("-" DomainChar+)*) - -DomainChar = [a-zA-Z0-9] / !Extra ([\__-] / !Safe) !EndOfLine !Space ![\\/|><%`\[\]] . - -/** - * - * Phone - * e.g: 075-63546725 - * - */ -Phone = "+" p:PhoneNumber { return { text: '+' + p.text, number: p.number }; } - -PhoneNumber - = p:PhonePrefix "-" d:Digits { - return { text: p.text + '-' + d, number: p.number + d }; - } - / p:PhonePrefix d1:Digits "-" d2:Digits { - return { text: p.text + d1 + '-' + d2, number: p.number + d1 + d2 }; - } - / p:PhonePrefix d:Digits { - return { text: p.text + d, number: p.number + d }; - } - / d:Digits { return { text: d, number: d }; } - -PhonePrefix - = d:Digits { return { text: d, number: d }; } - / "(" d:Digits ")" { return { text: '(' + d + ')', number: d }; } - -AutolinkedPhone = p:Phone { return phoneChecker(p.text, p.number); } - -/** - * - * Email - * e.g: contact@rocket.chat - * - */ -Email = "mailto:"? @$(LocalPart "@" DomainName) - -LocalPart = $(LocalPartChar+ ("." LocalPartChar+)*) - -LocalPartChar = AlphaNumericOrMarkChar+ LocalPartSpecialChars* - -LocalPartSpecialChars = [!#$%&'*+/=?^_\`{|}~-] - -// Guard: only attempt email parse if @ exists ahead on this line -AutolinkedEmail = &([^ \t\r\n@]* "@") e:Email { return autoEmail(e); } - -/** - * - * Auto Link URL - * e.g: rocket.chat, https://rocket.chat, - * with customDomains options as intranet: protocol://internaltool.intranet - * - */ -// _example.com (underscore + domain without closing _) → plain -PlainUnderscoreThenDomain = "_" d:DomainName &(EndOfLine / !. / [^\x5F]) { return plain('_' + d); } - -// Guard: only attempt URL parse if :// or . exists ahead on this line -AutolinkedURL = &([^ \t\r\n:./@]* ("://" / ".")) u:AutoLinkURL { return autoLink(u, options.customDomains); } - -AutoLinkURL - = head:($(URLScheme URLAuthority) / $(URLAuthorityHost)) tail:$(AutoLinkURLBody*) { return head + tail; } - -AutoLinkURLBody - = [^ \t\r\n.,!%~*\"':;()=~] - / !(Extra* (Whitespace / EndOfLine / !.)) . - -/** - * - * Emphasis - * -*/ -Emphasis = MaybeBold / MaybeItalic / MaybeStrikethrough - -/** - * - * Italic, Bold and Strike - * e.g: __italic__, _italic_, **bold**, __*bold italic*__, ~~strikethrough~~ - * - */ - -// Prevent re-entrant emphasis (infinite recursion); reset on backtrack via second branch -BlockedByJavascript = 'unreachable' - -MaybeBold - = & { if (skipBold) { return false; } skipBold = true; return true; } - @( text:Bold { skipBold = false; return text; } - / & { skipBold = false; return false; } BlockedByJavascript ) - -MaybeStrikethrough - = & { if (skipStrikethrough) { return false; } skipStrikethrough = true; return true; } - @( text:Strikethrough { skipStrikethrough = false; return text; } - / & { skipStrikethrough = false; return false; } BlockedByJavascript ) - -MaybeItalic - = & { if (skipItalic) { return false; } skipItalic = true; return true; } - @( text:Italic { skipItalic = false; return text; } - / & { skipItalic = false; return false; } BlockedByJavascript ) - -MaybeReferences - = & { if (skipReferences) { return false; } skipReferences = true; return true; } - @( text:References { skipReferences = false; return text; } - / & { skipReferences = false; return false; } BlockedByJavascript ) - -/* Italic */ -Italic - = value:$([a-zA-Z0-9]+ [\x5F] [\x5F]?) { return plain(value); } - / [\x5F] [\x5F] i:ItalicContentItems [\x5F] [\x5F] t:$[a-zA-Z0-9]+ { - return reducePlainTexts([plain('__'), ...i, plain('__'), plain(t)]); - } - / [\x5F] i:ItalicContentItems [\x5F] t:$[a-zA-Z]+ { - return reducePlainTexts([plain('_'), ...i, plain('_'), plain(t)]); - } - / [\x5F] [\x5F] @ItalicContent [\x5F] [\x5F] - / [\x5F] @ItalicContent [\x5F] - -ItalicContent = text:ItalicContentItems { return italic(text); } - -ItalicContentItems = text:ItalicContentItem+ { return reducePlainTexts(text); } - -ItalicContentItem = ItalicContentPreferentialItem / ItalicPlainRun / AnyItalic / Line - -ItalicContentPreferentialItem = Whitespace - / InlineCode - / TimestampRules - / MaybeReferences - / UserMention - / ChannelMention - / MaybeBold - / MaybeStrikethrough - / ItalicEmoji - / ItalicEmoticon - -ItalicEmoji = emo:Emoji { return emo; } - -ItalicEmoticon = emo:Emoticon & (EmoticonNeighbor / ItalicContentPreferentialItem / [\x5F]) { return emo; } - -/* Bold */ -Bold = [\x2A] [\x2A] @BoldContent [\x2A] [\x2A] / [\x2A] @BoldContent [\x2A] - -BoldContent = text:BoldContentItem+ { return bold(reducePlainTexts(text)); } - -BoldContentPreferentialItem = Whitespace / InlineCode / TimestampRules / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeStrikethrough / BoldEmoji / BoldEmoticon - -BoldContentItem = BoldContentPreferentialItem / BoldPlainRun / AnyBold / Line - -BoldEmoji = emo:Emoji { return emo; } - -BoldEmoticon = emo:Emoticon & (EmoticonNeighbor / BoldContentPreferentialItem) { return emo; } - -/* Strike */ -Strikethrough = [\x7E] [\x7E] @StrikethroughContent [\x7E] [\x7E] / [\x7E] @StrikethroughContent [\x7E] - -StrikethroughContent = text:(StrikePlainRunFull / EscapedTimestampRules / TimestampRules / Whitespace / InlineCode / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeBold / Emoji / Emoticon / AnyStrike / Line)+ { - return strike(reducePlainTexts(text)); - } - -// Like StrikePlainRun but also excludes chars that start inline rules inside strike -StrikePlainRunFull = run:$[^\x0a\~ *_:`@#\[\]\\" @EmoticonGT - / & "=" @EmoticonEquals - / & "D" @EmoticonD - / & "B" @EmoticonB - / & "8" @Emoticon8 - / & "'" @EmoticonApostrophe - / & "O" @EmoticonO - / & "0" @EmoticonZero - / & "*" @EmoticonAsterisk - / & "X" @EmoticonX - / & "#" @EmoticonHash - / & "%" @EmoticonPercent - / & "(" @EmoticonParen - / & "-" @EmoticonDash - / & "\\" @EmoticonBackslash - -EmoticonLT - = e:$":)" / ">;)" / ">:-)" / ">=)") { return emoticon(e, 'laughing'); } - / e:$">.<" { return emoticon(e, 'persevere'); } - / e:$">:P" { return emoticon(e, 'stuck_out_tongue_winking_eye'); } - / e:$(">:\\" / ">;/") { return emoticon(e, 'confused'); } - / e:$(">:(" / ">:-(") { return emoticon(e, 'angry'); } - / e:$(">:[") { return emoticon(e, 'disappointed'); } - / e:$">:O" { return emoticon(e, 'open_mouth'); } - -EmoticonApostrophe - = e:$("':)" / "':-)" / "'=)" / "':D" / "':-D" / "'=D") { return emoticon(e, 'sweat_smile'); } - / e:$("':(" / "':-(" / "'=(") { return emoticon(e, 'sweat'); } - -EmoticonO - = e:$("O:-)" / "O:)" / "O;-)" / "O=)" / "O:-3" / "O:3") { return emoticon(e, 'innocent'); } - / e:$"O_O" { return emoticon(e, 'open_mouth'); } - -EmoticonZero - = e:$("0:-3" / "0:3" / "0:-)" / "0:)" / "0;^)" / "0;-)") { return emoticon(e, 'innocent'); } - -EmoticonSemicolon - = e:$(";)" / ";-)" / ";-]" / ";]" / ";D" / ";^)") { return emoticon(e, 'wink'); } - / e:$(";(" / ";-(") { return emoticon(e, 'cry'); } - -EmoticonAsterisk - = e:$("*-)" / "*)") { return emoticon(e, 'wink'); } - / e:$("*\\0\/*" / "*\\O\/*") { return emoticon(e, 'person_gesturing_ok'); } - -EmoticonB - = e:$("B-)" / "B)" / "B-D") { return emoticon(e, 'sunglasses'); } - -Emoticon8 - = e:$("8)" / "8-)" / "8-D") { return emoticon(e, 'sunglasses'); } - -EmoticonD - = e:$"D:" { return emoticon(e, 'fearful'); } - -EmoticonX - = e:$"X-P" { return emoticon(e, 'stuck_out_tongue_winking_eye'); } - / e:$("X)" / "X-)") { return emoticon(e, 'dizzy_face'); } - -EmoticonHash - = e:$("#-)" / "#)") { return emoticon(e, 'dizzy_face'); } - -EmoticonPercent - = e:$("%-)" / "%)") { return emoticon(e, 'dizzy_face'); } - -EmoticonParen - = e:$"(y)" { return emoticon(e, 'thumbsup'); } - -EmoticonDash - = e:$("-___-" / "-__-" / "-_-") { return emoticon(e, 'expressionless'); } - -EmoticonBackslash - = e:$("\\0\\/" / "\\O\\/") { return emoticon(e, 'person_gesturing_ok'); } - -/* Unicode emojis */ -UnicodeEmoji - = UnicodeEmojiTagSequence - / UnicodeEmojiKeycapSequence - / $( - (UnicodeEmojiZwjComponent [\u200D])* - UnicodeEmojiZwjComponent - ) - / UnicodeEmojiEmoticon - / UnicodeEmojiTransportAndMapSymbols - / UnicodeEmojiMiscellaneousTechnical - / UnicodeEmojiMiscellaneousSymbols - / UnicodeEmojiDingbats - / UnicodeEmojiGeometricSquares - / UnicodeEmojiEnclosedBadges - / UnicodeEmojiTextPresentation - / UnicodeEmojiFlags - -UnicodeEmojiEmoticon = $([\uD83D] [\uDE00-\uDE4F] [︀-️]?) - -UnicodeEmojiSupplementalSymbolsAndPictographs = $([\uD83E] [\uDD00-\uDFFF]) - -UnicodeEmojiZwjComponent - = ( UnicodeEmojiSupplementalSymbolsAndPictographs - / UnicodeEmojiMiscellaneousSymbolsAndPictographs - / UnicodeEmojiEmoticon - / UnicodeEmojiTransportAndMapSymbols - / UnicodeEmojiDingbats - / UnicodeEmojiMiscellaneousSymbols - / UnicodeEmojiArrows - / UnicodeEmojiGeometricSquares - ) UnicodeEmojiMiscellaneousSymbolsAndPictographsFitzpatrickModifiers? - -/* Emoji tag sequence: Black Flag + tag characters (U+E0020-U+E007E) + Cancel Tag (U+E007F), e.g. England/Scotland/Wales flags */ -UnicodeEmojiTagSequence = $([\uD83C] [\uDFF4] ([\uDB40] [\uDC20-\uDC7E])+ [\uDB40] [\uDC7F]) - -UnicodeEmojiKeycapSequence = $([0-9#*] [️]? [⃣]) - -UnicodeEmojiMiscellaneousSymbolsAndPictographs = $([\uD83C] [\uDF00-\uDFFF] [\uFE00-\uFE0F]?) / $([\uD83D] [\uDC00-\uDDFF] [\uFE00-\uFE0F]?) - -UnicodeEmojiMiscellaneousSymbolsAndPictographsFitzpatrickModifiers = $([\uD83C] [\uDFFB-\uDFFF]) - -UnicodeEmojiTransportAndMapSymbols = $([\uD83D] [\uDE80-\uDEFF] [︀-️]?) - -UnicodeEmojiMiscellaneousTechnical = $([\u2300-\u23FF] [\uFE00-\uFE0F]?) - -UnicodeEmojiMiscellaneousSymbols = $([\u2600-\u26FF] [\uFE00-\uFE0F]?) - -UnicodeEmojiDingbats = $([\u2700-\u27BF] [\uFE00-\uFE0F]?) - -/* U+2194/U+2195 only; kept narrow so bare prose arrows (U+2190..U+2193, U+21D2) aren't matched */ -UnicodeEmojiArrows = $([\u2194-\u2195] [\uFE00-\uFE0F]?) - -UnicodeEmojiGeometricSquares = $([\u2B1B-\u2B1C] [\uFE00-\uFE0F]?) / $([\uD83D] ([\uDFE0-\uDFEB] / [\uDFF0]) [\uFE00-\uFE0F]?) - -/* Tight ranges — these enclosed-alphanumeric/ideographic and playing-card blocks are mostly non-emoji */ -UnicodeEmojiEnclosedBadges = $([\uD83C] ([\uDCCF] / [\uDD8E] / [\uDD91-\uDD9A] / [\uDE01] / [\uDE32-\uDE3A] / [\uDE50-\uDE51]) [︀-️]?) - -/* Default-text chars that are emoji ONLY with a required trailing VS16, so a bare U+00A9/U+2122/U+25B6 in prose stays text */ -UnicodeEmojiTextPresentation - = $([©®‼⁉™ℹ↖-↙↩-↪Ⓜ▪-▫▶◀◻-◾⤴-⤵⬅-⬇⭐⭕〰〽㊗㊙] [️]) - / $([\uD83C] ([\uDC04] / [\uDD70-\uDD71] / [\uDD7E-\uDD7F] / [\uDE02] / [\uDE1A] / [\uDE2F]) [️]) - -/* Two regional indicators combine into a flag (e.g. U + S = US flag); a single one alone is not an emoji. Narrowed from U+1F100-1F1FF so squared badges aren't mis-grouped as flags. */ -UnicodeEmojiFlags = $([\uD83C] [\uDDE6-\uDDFF] [\uD83C] [\uDDE6-\uDDFF]) - -/** - * - * Inline Code - * e.g: `console.log('hello world')` - * - */ -InlineCode = "`" text:$([^`\n]+) "`" { return inlineCode(plain(text)); } - -/** - * - * Colors - * e.g: color:#ff0000 , color:#ff0 - * - */ -Color = & { return options.colors; } "color:#" rgba:ColorRGBATuple !AnyText { - return color(...rgba); - } - -ColorRGBATuple = HexByte|3..4| / HexNible|3..4| - -/** - * - * Macros - * - */ -Whitespace = w:$Space+ { return plain(w); } - -EndOfLine = "\r\n" / "\n" / "\r" - -Space = " " / "\t" - -Escaped = "\\" t:[*_~`#.] { return plain(t); } - -Any = t:[^\r\n] { return plain(t); } - -AnyText = [\x20-\x27\x2B-\x40\x41-\x5A\x61-\x7A] / NonASCII - -Text = text:AnyText { return plain(text); } - -Line = t:LineStructure { return plain(t); } - -LineStructure = head:$Space* text:$AnyText+ tail:$Space* { return head + text + tail; } - -UTF8NamesValidation = $([-_.] / AlphaNumericChar)+ - -NonASCII = [\x80-\uFFFF] - -Unicode = "\\" Digits:$(HexDigit |1..6|) ("\r\n" / [ \t\r\n\f])? { return String.fromCharCode(parseInt(Digits, 16)); } - -Digit = [0-9] - -Digits = $Digit+ - -Safe = [$@&+\__#?-] - -Extra = [.,!%~*\"':;()=~] - -HexDigit = [0-9A-Fa-f] - -HexNible = a:HexDigit { return parseInt(a + a, 16); } - -HexByte = a:HexDigit b:HexDigit { return parseInt(a + b, 16); } - -AlphaDigit = [a-zA-Z0-9] - -AlphaNumericOrMarkChar = AlphaOrMarkChar / DecimalNumberChar - -AlphaOrMarkChar = AlphaChar / EmojiChar / MarkChar - -AlphaNumericChar = AlphaChar / DecimalNumberChar - -AlphaChar = [A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AE\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC] - -DecimalNumberChar = [0-9\u0660-\u0669\u06F0-\u06F9\u07C0-\u07C9\u0966-\u096F\u09E6-\u09EF\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0BE6-\u0BEF\u0C66-\u0C6F\u0CE6-\u0CEF\u0D66-\u0D6F\u0DE6-\u0DEF\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F29\u1040-\u1049\u1090-\u1099\u17E0-\u17E9\u1810-\u1819\u1946-\u194F\u19D0-\u19D9\u1A80-\u1A89\u1A90-\u1A99\u1B50-\u1B59\u1BB0-\u1BB9\u1C40-\u1C49\u1C50-\u1C59\uA620-\uA629\uA8D0-\uA8D9\uA900-\uA909\uA9D0-\uA9D9\uA9F0-\uA9F9\uAA50-\uAA59\uABF0-\uABF9\uFF10-\uFF19] - -EmojiChar = [\u2700-\u27bf\udde6-\uddff\ud800-\udbff\udc00-\udfff\ufe0e\ufe0f\u0300-\u036f\ufe20-\ufe23\u20d0-\u20f0\ud83c\udffb-\udfff\u200d\u3299\u3297\u303d\u3030\u24c2\ud83c\udd70-\udd71\udd7e-\udd7f\udd8e\udd91-\udd9a\udde6-\uddff\ude01-\ude02\ude1a\ude2f\ude32-\ude3a\ude50-\ude51\u203c\u2049\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u00a9\u00ae\u2122\u2139\udc04\u2600-\u26FF\u2b05\u2b06\u2b07\u2b1b\u2b1c\u2b50\u2b55\u231a\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\udccf\u2935\u2934\u2190-\u21ff] - -MarkChar = [\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F] diff --git a/packages/message-parser/src/index.ts b/packages/message-parser/src/index.ts index 1df1ec6554878..b1d3e0d3a44f1 100644 --- a/packages/message-parser/src/index.ts +++ b/packages/message-parser/src/index.ts @@ -1,5 +1,5 @@ import type { Root } from './definitions'; -import * as grammar from './grammar.pegjs'; +import { parse as parseMarkdown } from './parser'; export type * from './definitions'; @@ -17,7 +17,7 @@ export type Options = { customDomains?: string[]; }; -export const parse = (input: string, options?: Options): Root => grammar.parse(input, options); +export const parse = (input: string, options?: Options): Root => parseMarkdown(input, options); export type { Root as MarkdownAST }; export { parse as parser }; diff --git a/packages/message-parser/src/parser.ts b/packages/message-parser/src/parser.ts new file mode 100644 index 0000000000000..0328c34a97f3d --- /dev/null +++ b/packages/message-parser/src/parser.ts @@ -0,0 +1,1816 @@ +import { + isNewline, + isPlainChar, + isSpace, + isAlpha, + isAlphaNum, + isDigit, + EMOTICON_KEYS, + EMOTICONS, + isHexDigit, + isEmojiStart, + isUrlStart, + isEmailStart, +} from './chars'; +import type { + Root, + Inlines, + Bold, + Italic, + Strike, + Spoiler, + Timestamp, + BigEmoji, + Heading, + Code, + Quote, + SpoilerBlock, + UnorderedList, + OrderedList, + KaTeX, + LineBreak, + CodeLine, + Paragraph, + ListItem, + Tasks, + Task, + HorizontalRule, + Table, + TableCellAlignment, + Markup, +} from './definitions'; +import type { Options } from './index'; +import { Scanner } from './scanner'; +import { + paragraph, + plain, + lineBreak, + reducePlainTexts, + inlineCode, + bold, + italic, + strike, + heading, + mentionChannel, + mentionUser, + code, + codeLine, + quote, + spoiler, + spoilerBlock, + link, + unorderedList, + listItem, + orderedList, + katex, + inlineKatex, + autoLink, + autoEmail, + phoneChecker, + timestamp, + timestampFromHours, + timestampFromIsoTime, + bigEmoji, + emoji, + emoticon, + emojiUnicode, + color, + image, + tasks, + task, + horizontalRule, + table, +} from './utils'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const ESCAPABLE = new Set(['*', '_', '~', '`', '#', '.']); +const UNICODE_EMOJI = new RegExp('^\\p{RGI_Emoji}\\uFE0F?', 'v'); +const OPTIONAL_TIMEZONE_OFFSET = '([+-]\\d{2}:\\d{2})?'; // optional "+00:00" style offset +const UNIX_TIMESTAMP = /^\d{10}$/; // exactly 10 digits +const ISO_TIMESTAMP_WITH_MILLISECONDS_REGEX = new RegExp( + `^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})${OPTIONAL_TIMEZONE_OFFSET}$`, +); +const ISO_TIMESTAMP_REGEX = new RegExp(`^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})${OPTIONAL_TIMEZONE_OFFSET}$`); +const TIME_HOURS_MINUTES_SECONDS_REGEX = new RegExp(`^(\\d{2}):(\\d{2}):(\\d{2})${OPTIONAL_TIMEZONE_OFFSET}$`); +const TIME_HOURS_MINUTES_REGEX = new RegExp(`^(\\d{2}):(\\d{2})${OPTIONAL_TIMEZONE_OFFSET}$`); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function consumeEndOfLine(scanner: Scanner): void { + if (scanner.isEnd()) return; + if (scanner.char() === '\r' && scanner.charAt(1) === '\n') { + scanner.consume(2); + } else { + scanner.consume(1); + } +} + +function isShortCodeChar(ch: string): boolean { + return isAlphaNum(ch) || ch === '-' || ch === '_' || ch === '+' || ch === '.'; +} + +/** True when every node is whitespace-only plain text (e.g. `* *` between delimiters). */ +function isWhitespaceOnly(nodes: Inlines[]): boolean { + return nodes.every((n) => n.type === 'PLAIN_TEXT' && n.value.trim() === ''); +} + +export function matchEmoticon(scanner: Scanner): Inlines | null { + for (const key of EMOTICON_KEYS) { + if (scanner.matches(key)) { + scanner.consume(key.length); + return emoticon(key, EMOTICONS[key]); + } + } + return null; +} + +function isAnyText(ch: string): boolean { + if (ch === '') return false; + return ( + (ch >= ' ' && ch <= "'") || // space ! " # $ % & ' + (ch >= '+' && ch <= '@') || // + , - . / 0-9 : ; < = > ? @ + isAlpha(ch) || // A-Z or a-z + ch.charCodeAt(0) > 127 // any non-ASCII character + ); +} + +function isEmailLocalChar(ch: string): boolean { + return isAlphaNum(ch) || ch.charCodeAt(0) > 127 || ch === '.' || ch === '_' || ch === '+' || ch === '-' || ch === "'"; +} + +function isEmailDomainChar(ch: string): boolean { + return isAlphaNum(ch) || ch.charCodeAt(0) > 127 || ch === '.' || ch === '-'; +} + +function isPhoneChar(ch: string): boolean { + return isDigit(ch) || ch === '(' || ch === ')' || ch === '-'; +} + +function isValidUrlStructure(url: string): boolean { + if (url.includes('://')) return /^[A-Za-z0-9+-]{1,32}:\/\/./.test(url); // scheme://host + return !url.includes(':/') && /^[A-Za-z0-9][^/:?#]*\.[^/:?#]+/.test(url); // bare domain +} + +// True when a `]` begins a `] [label](url)` link that follows, marking the end of the current label. +function isReferenceContinuation(scanner: Scanner): boolean { + if (!scanner.matches('] [')) return false; + for (let i = 3; ; i++) { + const c = scanner.charAt(i); + if (c === '' || isNewline(c)) return false; + if (c === ']') return scanner.charAt(i + 1) === '('; + } +} + +// ─── Re-entrancy guards ─────────────────────────────────────────────────── + +let skipBold = false; +let skipItalic = false; +let skipStrike = false; +let skipReferences = false; + +// ─── Entry point ────────────────────────────────────────────────────────── + +export function parse(input: string, options: Options = {}) { + // Clear the skip flags in case an earlier parse crashed before resetting them. + skipBold = false; + skipItalic = false; + skipStrike = false; + skipReferences = false; + + const bigEmojiRoot = tryBigEmoji(input, options); + if (bigEmojiRoot !== null) { + return bigEmojiRoot; + } + + const root: Root = []; + const scanner = new Scanner(input); + + while (!scanner.isEnd()) { + const lineBreakNode: LineBreak | null = tryLineBreak(scanner); + if (lineBreakNode !== null) { + root.push(lineBreakNode); + continue; + } + + const katexBlockNode: KaTeX | null = tryKatexBlock(scanner, options); + if (katexBlockNode !== null) { + root.push(katexBlockNode); + continue; + } + + const codeFenceNode: Code | null = tryCodeFence(scanner); + if (codeFenceNode !== null) { + root.push(codeFenceNode); + continue; + } + + const blockSpoilerNode: SpoilerBlock | null = tryBlockSpoiler(scanner, options); + if (blockSpoilerNode !== null) { + root.push(blockSpoilerNode); + continue; + } + + const blockquoteNode: Quote | null = tryBlockquote(scanner, options); + if (blockquoteNode !== null) { + root.push(blockquoteNode); + continue; + } + + const horizontalRuleNode: HorizontalRule | null = tryHorizontalRule(scanner); + if (horizontalRuleNode !== null) { + root.push(horizontalRuleNode); + continue; + } + + const tableNode: Table | null = tryTable(scanner, options); + if (tableNode !== null) { + root.push(tableNode); + continue; + } + + const tasksNode: Tasks | null = tryTasks(scanner, options); + if (tasksNode !== null) { + root.push(tasksNode); + continue; + } + + const unorderedListNode: UnorderedList | null = tryUnorderedList(scanner, options); + if (unorderedListNode !== null) { + root.push(unorderedListNode); + continue; + } + + const orderedListNode: OrderedList | null = tryOrderedList(scanner, options); + if (orderedListNode !== null) { + root.push(orderedListNode); + continue; + } + + const headingNode: Heading | null = tryHeading(scanner, options); + if (headingNode !== null) { + root.push(headingNode); + continue; + } + + const inlines = parseInline(scanner, options); + if (inlines.length > 0) { + root.push(paragraph(inlines)); + } + + consumeEndOfLine(scanner); // Skip newline characters + } + + return root; +} + +function parseInline(scanner: Scanner, options: Options, stopChar = '') { + const nodes: Inlines[] = []; + let prev = ''; + + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (stopChar && scanner.matches(stopChar)) break; + const ch = scanner.char(); + + // Emoticons + if (options.emoticons) { + const result = tryEmoticon(scanner, prev, stopChar); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // KaTeX inline + if (ch === '$' || (ch === '\\' && scanner.charAt(1) === '(')) { + const result = tryKatexInline(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Escape sequences + if (ch === '\\') { + const next = scanner.charAt(1); + if (next !== '' && ESCAPABLE.has(next)) { + nodes.push(plain(next)); + scanner.consume(2); // consume the backslash and the escaped char + prev = next; + continue; + } + } + + // Inline code + if (ch === '`') { + const result = tryInlineCode(scanner); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Bold + if (ch === '*') { + const result = tryBold(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Strike + if (ch === '~') { + const result = tryStrike(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Italic + if (ch === '_') { + const result = tryItalic(scanner, options, prev); + if (result !== null) { + nodes.push(...result); + prev = scanner.previous(); + continue; + } + } + + // Emoji shortcode (:smile:) + if (ch === ':') { + const result = tryEmojiShortCode(scanner); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Unicode raw emoji + if (isEmojiStart(ch)) { + const result = tryUnicodeEmoji(scanner); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // Color (color:#rgb / rgba / rrggbb / rrggbbaa) + if (scanner.matches('color:#')) { + const result = tryColor(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // Phone (+number) + if (ch === '+') { + const result = tryPhone(scanner, prev); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // User mention + if (ch === '@') { + const mention = tryUserMention(scanner, prev); + if (mention !== null) { + nodes.push(mention); + prev = ch; + continue; + } + } + + // Email (local@domain) + if (isEmailStart(ch)) { + const email = tryEmail(scanner); + if (email !== null) { + nodes.push(email); + prev = ''; + continue; + } + } + + // Mention channel + if (ch === '#') { + const result = tryChannelMention(scanner, prev); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Image + if (ch === '!') { + const result = tryImage(scanner); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // Markdown link + if (ch === '[') { + const result = tryMarkdownLink(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ']'; + continue; + } + } + + // Timestamp + if (ch === '\\' || ch === '<') { + const ts = tryTimestamp(scanner); + if (ts !== null) { + nodes.push(ts); + prev = ''; + continue; + } + } + + // Angle bracket link + if (ch === '<') { + const ts = tryAngleBracketLink(scanner); + if (ts !== null) { + nodes.push(ts); + prev = '>'; + continue; + } + } + + // Inline spoiler + if (ch === '|') { + const result = trySpoiler(scanner, options); + if (result !== null) { + nodes.push(result); + prev = ch; + continue; + } + } + + // Auto link + if (isUrlStart(ch)) { + const result = tryAutoLinkUrl(scanner, options, prev); + if (result !== null) { + nodes.push(result); + prev = ''; + continue; + } + } + + // Plain run + if (isPlainChar(ch)) { + const start = scanner.position(); + while (!scanner.isEnd() && isPlainChar(scanner.char())) { + if (stopChar && scanner.matches(stopChar)) break; + scanner.consume(); + } + + const text = scanner.sliceFrom(start); + nodes.push(plain(text)); + prev = text[text.length - 1] ?? ''; + continue; + } + + // Fallback to plain text + nodes.push(plain(ch)); + prev = ch; + scanner.consume(); + } + + return stopChar ? nodes : reducePlainTexts(nodes); +} + +// ─── Inline methods ────────────────────────────────────────────────────────────── + +function tryLineBreak(scanner: Scanner): LineBreak | null { + if (!isNewline(scanner.char())) return null; + consumeEndOfLine(scanner); // consume the blank line's newline so the caller can `continue` + return lineBreak(); +} + +function tryBold(scanner: Scanner, options: Options): Inlines | null { + if (skipBold) return null; + const start = scanner.position(); + + if (scanner.matches('***')) { + scanner.consume(1); + return plain('*'); + } + const isDouble = scanner.matches('**'); + const delimiter = isDouble ? '**' : '*'; + + scanner.consume(delimiter.length); + if (scanner.isEnd() || isNewline(scanner.char())) { + scanner.backtrack(start); + return null; + } + + let content: Inlines[]; + skipBold = true; + try { + content = parseInline(scanner, options, delimiter); + } finally { + skipBold = false; + } + + if (!scanner.matches(delimiter)) { + scanner.backtrack(start); + return null; + } + + if (content.length === 0) { + scanner.backtrack(start); + return null; + } + + if (isWhitespaceOnly(content)) { + scanner.backtrack(start); + return null; + } + + scanner.consume(delimiter.length); + return bold(reducePlainTexts(content) as Bold['value']); +} + +function tryItalic(scanner: Scanner, options: Options, prevChar: string): Inlines[] | null { + if (skipItalic) return null; + const start = scanner.position(); + + // A word glued to underscores is plain text: `word_`, `word__`. + if (isAlphaNum(prevChar)) { + scanner.consume(1); + if (scanner.matches('_')) scanner.consume(1); + return [plain(scanner.sliceFrom(start))]; + } + + // Content can't start with `_`, so peel one `_` and retry (`___x___` -> _ + __x__ + _). + if (scanner.matches('___')) { + scanner.consume(1); + return [plain('_')]; + } + + const isDouble = scanner.matches('__'); + const delimiter = isDouble ? '__' : '_'; + + scanner.consume(delimiter.length); + if (scanner.isEnd() || isNewline(scanner.char())) { + scanner.backtrack(start); + return null; + } + + let content: Inlines[]; + skipItalic = true; + try { + content = parseInline(scanner, options, delimiter); + } finally { + skipItalic = false; + } + + if (!scanner.matches(delimiter) || content.length === 0 || isWhitespaceOnly(content)) { + scanner.backtrack(start); + return null; + } + scanner.consume(delimiter.length); + + // Followed by a word (`__x__word`, `_x_word`): delimiters are plain, inner nodes kept. + const isTrail = isDouble ? isAlphaNum : isAlpha; + if (isTrail(scanner.char())) { + const trailStart = scanner.position(); + while (isTrail(scanner.char())) scanner.consume(); + const trail = scanner.sliceFrom(trailStart); + return reducePlainTexts([plain(delimiter), ...content, plain(delimiter), plain(trail)]); + } + + // Real italic. + return [italic(reducePlainTexts(content) as Italic['value'])]; +} + +function tryStrike(scanner: Scanner, options: Options): Inlines | null { + if (skipStrike) return null; + const start = scanner.position(); + + if (scanner.matches('~~~')) { + scanner.consume(1); + return plain('~'); + } + + const isDouble = scanner.matches('~~'); + const delimiter = isDouble ? '~~' : '~'; + + scanner.consume(delimiter.length); + if (scanner.isEnd() || isNewline(scanner.char())) { + scanner.backtrack(start); + return null; + } + + let content: Inlines[]; + skipStrike = true; + try { + content = parseInline(scanner, options, delimiter); + } finally { + skipStrike = false; + } + + if (!scanner.matches(delimiter)) { + scanner.backtrack(start); + return null; + } + + if (content.length === 0) { + scanner.backtrack(start); + return null; + } + + if (isWhitespaceOnly(content)) { + scanner.backtrack(start); + return null; + } + + scanner.consume(delimiter.length); + return strike(reducePlainTexts(content) as Strike['value']); +} + +function tryInlineCode(scanner: Scanner): Inlines | null { + const start = scanner.position(); + scanner.consume(); // consume opening backtrack(`) + + const contentStart = scanner.position(); + + while (!scanner.isEnd() && !isNewline(scanner.char()) && scanner.char() !== '`') { + scanner.consume(); + } + + if (scanner.isEnd() || isNewline(scanner.char()) || scanner.char() !== '`') { + scanner.backtrack(start); + return null; + } + + const content = scanner.sliceFrom(contentStart); + if (content.length === 0) { + scanner.backtrack(start); + return null; + } + + scanner.consume(); + return inlineCode(plain(content)); +} + +function tryEmail(scanner: Scanner): Inlines | null { + const start = scanner.position(); + const delimiter = 'mailto:'; + + if (scanner.matches(delimiter)) { + scanner.consume(delimiter.length); + } + + const localStart = scanner.position(); + while (!scanner.isEnd() && isEmailLocalChar(scanner.char())) { + scanner.consume(); + } + const local = scanner.sliceFrom(localStart); + + if (local.length === 0 || scanner.char() !== '@') { + scanner.backtrack(start); + return null; + } + scanner.consume(); // consume '@' + + const domainStart = scanner.position(); + while (!scanner.isEnd() && isEmailDomainChar(scanner.char())) { + scanner.consume(); + } + + // Trim trailing '.' / '-' back out of the domain ("joe.com." → "joe.com") + while (scanner.position() > domainStart && (scanner.charAt(-1) === '.' || scanner.charAt(-1) === '-')) { + scanner.consume(-1); + } + + const domain = scanner.sliceFrom(domainStart); + + // Domain must contain a dot that is not at the very start or end + const dotIdx = domain.indexOf('.'); + if (dotIdx <= 0 || dotIdx === domain.length - 1) { + scanner.backtrack(start); + return null; + } + + return autoEmail(`${local}@${domain}`); +} + +function tryPhone(scanner: Scanner, prev: string): Inlines | null { + if (prev !== '' && !isSpace(prev)) return null; + + const start = scanner.position(); + scanner.consume(); // consume '+' + + while (!scanner.isEnd() && isPhoneChar(scanner.char())) { + scanner.consume(); + } + + const raw = scanner.sliceFrom(start); // includes the leading '+' + + let digits = ''; + for (const ch of raw) { + if (isDigit(ch)) digits += ch; + } + + if (digits.length < 5) { + scanner.backtrack(start); + return null; + } + + return phoneChecker(raw, digits); +} + +function tryTimestamp(scanner: Scanner): Inlines | null { + const start = scanner.position(); + const delimiter = '') { + scanner.consume(); + } + + if (scanner.char() !== '>') { + scanner.backtrack(start); + return null; + } + + const content = scanner.sliceFrom(contentStart); + + let format: Timestamp['value']['format'] | undefined; + let timestampValue = content; + + if (content.length >= 2 && content[content.length - 2] === ':' && 'tTdDfFR'.includes(content[content.length - 1])) { + format = content[content.length - 1] as Timestamp['value']['format']; + timestampValue = content.slice(0, -2); + } + + let parsedTimestamp: string | null = null; + let match: RegExpExecArray | null; + + if (UNIX_TIMESTAMP.test(timestampValue)) { + parsedTimestamp = timestampValue; + } else if ((match = ISO_TIMESTAMP_WITH_MILLISECONDS_REGEX.exec(timestampValue))) { + parsedTimestamp = timestampFromIsoTime({ + year: match[1], + month: match[2], + day: match[3], + hours: match[4], + minutes: match[5], + seconds: match[6], + milliseconds: match[7], + timezone: match[8], + }); + } else if ((match = ISO_TIMESTAMP_REGEX.exec(timestampValue))) { + parsedTimestamp = timestampFromIsoTime({ + year: match[1], + month: match[2], + day: match[3], + hours: match[4], + minutes: match[5], + seconds: match[6], + timezone: match[7], + }); + } else if ((match = TIME_HOURS_MINUTES_SECONDS_REGEX.exec(timestampValue))) { + parsedTimestamp = timestampFromHours(match[1], match[2], match[3], match[4]); + } else if ((match = TIME_HOURS_MINUTES_REGEX.exec(timestampValue))) { + parsedTimestamp = timestampFromHours(match[1], match[2], undefined, match[3]); + } + + if (parsedTimestamp === null) { + scanner.backtrack(start); + return null; + } + + scanner.consume(); // consume '>' + + if (escaped) return plain(scanner.sliceFrom(rawStart)); + + return timestamp(parsedTimestamp, format, [start, scanner.position()]); +} + +export function tryEmoticon(scanner: Scanner, prev: string, stopChar: string): Inlines | null { + if (isAlphaNum(prev)) return null; + + const start = scanner.position(); + + const node = matchEmoticon(scanner); + if (node === null) return null; + + // Must be followed by whitespace, end of text, `*`, or the emphasis closer (stopChar). + const after = scanner.char(); + const beforeCloser = stopChar !== '' && after === stopChar[0]; + if (after === '' || isSpace(after) || isNewline(after) || after === '*' || beforeCloser) { + return node; + } + + scanner.backtrack(start); + return null; +} + +function tryUserMention(scanner: Scanner, prev: string): Inlines | null { + if (isAlphaNum(prev)) return null; + + const start = scanner.position(); + + scanner.consume(); // consume '@' + const nameStart = scanner.position(); + + while (!scanner.isEnd() && !isNewline(scanner.char()) && !isSpace(scanner.char())) { + const ch = scanner.char(); + const code = ch.charCodeAt(0); + + if (isAlphaNum(ch) || '._-:@'.includes(ch) || code > 127) { + scanner.consume(); + } else { + break; + } + } + + const name = scanner.sliceFrom(nameStart); + + if (name.length === 0) { + scanner.backtrack(start); + return null; + } + + return mentionUser(name); +} + +function tryChannelMention(scanner: Scanner, prev: string): Inlines | null { + if (prev !== '' && !isSpace(prev)) return null; + + const start = scanner.position(); + scanner.consume(); + + const nameStart = scanner.position(); + + while (!scanner.isEnd() && !isNewline(scanner.char()) && !isSpace(scanner.char())) { + const c = scanner.char(); + if (!isAlphaNum(c) && !'_-.'.includes(c)) break; + scanner.consume(); + } + + const name = scanner.sliceFrom(nameStart); + if (name.length === 0) { + scanner.backtrack(start); + return null; + } + + return mentionChannel(name); +} + +function trySpoiler(scanner: Scanner, options: Options): Inlines | null { + const start = scanner.position(); + const delimiter = '||'; + + if (!scanner.matches(delimiter)) { + return null; + } + scanner.consume(delimiter.length); // consume opening "||" + + const content = parseInline(scanner, options, delimiter); + + if (!scanner.matches(delimiter)) { + scanner.backtrack(start); + return null; + } + scanner.consume(delimiter.length); // consume closing "||" + + if (content.length === 0) { + scanner.backtrack(start); + return null; + } + + return spoiler(reducePlainTexts(content) as Spoiler['value']); +} + +function parseLinkLabel(scanner: Scanner, options: Options): Inlines[] { + const nodes: Inlines[] = []; + let prev = ''; + + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.matches('](') || isReferenceContinuation(scanner)) break; + + const ch = scanner.char(); + + if (ch === '*') { + const r = tryBold(scanner, options); + if (r !== null) { + nodes.push(r); + prev = ch; + continue; + } + } + if (ch === '~') { + const r = tryStrike(scanner, options); + if (r !== null) { + nodes.push(r); + prev = ch; + continue; + } + } + if (ch === '_') { + const r = tryItalic(scanner, options, prev); + if (r !== null) { + nodes.push(...r); + prev = scanner.previous(); + continue; + } + } + + if (ch === '\\') { + const next = scanner.charAt(1); + if (next !== '' && ESCAPABLE.has(next)) { + nodes.push(plain(next)); + scanner.consume(2); + prev = next; + continue; + } + } + + nodes.push(plain(ch)); + prev = ch; + scanner.consume(); + } + + return reducePlainTexts(nodes); +} + +function tryMarkdownLink(scanner: Scanner, options: Options): Inlines | null { + if (skipReferences) return null; + const start = scanner.position(); + + if (scanner.char() !== '[') { + return null; + } + scanner.consume(); // consume '[' + + skipReferences = true; + const titleNodes = parseLinkLabel(scanner, options); + skipReferences = false; + + if (!scanner.matches('](')) { + scanner.backtrack(start); + return null; + } + scanner.consume(2); // consume '](' + + const urlStart = scanner.position(); + let depth = 1; + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.char() === '(') depth++; + if (scanner.char() === ')') { + depth--; + if (depth === 0) break; + } + scanner.consume(); + } + + if (!scanner.matches(')')) { + scanner.backtrack(start); + return null; + } + + let url = scanner.sliceFrom(urlStart); + scanner.consume(); // consume ')' + + if (url.length === 0) { + scanner.backtrack(start); + return null; + } + + // A phone number in the URL position becomes a tel: link. + if (url[0] === '+') { + let digits = ''; + for (const ch of url) { + if (isDigit(ch)) digits += ch; + } + if (digits.length >= 5) { + url = `tel:${digits}`; + } + } + + // "[text](/foo)" is not a link — a target needs a scheme ("https:") or a domain ("rocket.chat") + const host = url.split('/')[0]; + if (!host.includes(':') && !host.includes('.')) { + scanner.backtrack(start); + return null; + } + + const title = reducePlainTexts(titleNodes); + + if (title.length === 0) { + return link(url); + } + + return link(url, title as Markup[]); +} + +function tryAngleBracketLink(scanner: Scanner): Inlines | null { + const start = scanner.position(); + + if (scanner.char() !== '<') { + return null; + } + scanner.consume(); // consume '<' + + const urlStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.char() === '|' || scanner.char() === '>') break; + scanner.consume(); + } + + const url = scanner.sliceFrom(urlStart); + + if (url.length === 0) { + scanner.backtrack(start); + return null; + } + + if (scanner.char() !== '|') { + scanner.backtrack(start); + return null; + } + scanner.consume(); // consume '|' + + const titleStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char()) && scanner.char() !== '>') { + scanner.consume(); + } + + if (scanner.char() !== '>') { + scanner.backtrack(start); + return null; + } + + const title = scanner.sliceFrom(titleStart); + scanner.consume(); // consume '>' + + return link(url, [plain(title)]); +} + +function tryKatexInline(scanner: Scanner, options: Options): Inlines | null { + const start = scanner.position(); + + let openDelim: string; + let closeDelim: string; + + if (options.katex?.dollarSyntax && scanner.matches('$') && !scanner.matches('$$')) { + openDelim = '$'; + closeDelim = '$'; + } else if (options.katex?.parenthesisSyntax && scanner.matches('\\(')) { + openDelim = '\\('; + closeDelim = '\\)'; + } else { + return null; + } + + scanner.consume(openDelim.length); + + const contentStart = scanner.position(); + + // Inline katex: no newlines allowed inside + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.matches(closeDelim)) break; + scanner.consume(); + } + + if (!scanner.matches(closeDelim)) { + scanner.backtrack(start); + return null; + } + + const content = scanner.sliceFrom(contentStart); + scanner.consume(closeDelim.length); + + return inlineKatex(content); +} + +function tryAutoLinkUrl(scanner: Scanner, options: Options, prev: string): Inlines | null { + if (prev === '_') return null; + + const ch = scanner.char(); + if (!isAlphaNum(ch)) return null; + + const start = scanner.position(); + + const tokenStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char()) && !isSpace(scanner.char())) { + scanner.consume(); + } + + const token = scanner.sliceFrom(tokenStart); + if (token.length === 0) { + scanner.backtrack(start); + return null; + } + + if (!token.includes('://') && !token.includes('.')) { + scanner.backtrack(start); + return null; + } + + // e.g. "rocket.chat." → "rocket.chat" + let url = token; + while (url.length > 0 && '.,!;:)'.includes(url[url.length - 1])) { + url = url.slice(0, -1); + } + + if (url.length === 0 || !isValidUrlStructure(url)) { + scanner.backtrack(start); + return null; + } + + scanner.backtrack(tokenStart); + scanner.consume(url.length); + + const result = autoLink(url, options.customDomains); + + if (result.type === 'PLAIN_TEXT') { + scanner.backtrack(start); + return null; + } + + return result; +} + +function tryEmojiShortCode(scanner: Scanner): Inlines | null { + const start = scanner.position(); + scanner.consume(); // consume opening ':' + + const nameStart = scanner.position(); + while (!scanner.isEnd() && isShortCodeChar(scanner.char())) { + scanner.consume(); + } + + const name = scanner.sliceFrom(nameStart); + if (name.length === 0 || scanner.char() !== ':') { + scanner.backtrack(start); + return null; + } + + scanner.consume(); // consume closing ':' + return emoji(name); +} + +function tryUnicodeEmoji(scanner: Scanner): Inlines | null { + const ch = scanner.char(); + + // fast-reject plain ASCII, but keycap bases (#, *, 0-9) can start an emoji + const isKeycapBase = (ch === '#' || ch === '*' || (ch >= '0' && ch <= '9')) && scanner.charAt(1) === '\uFE0F'; + if (ch.charCodeAt(0) <= 127 && !isKeycapBase) return null; + + let window = ''; + for (let i = 0; i < 32; i++) { + const c = scanner.charAt(i); + if (c === '') break; + window += c; + } + + const m = UNICODE_EMOJI.exec(window); + if (m === null) return null; + + scanner.consume(m[0].length); + return emojiUnicode(m[0]); +} + +function tryColor(scanner: Scanner, options: Options): Inlines | null { + if (!options.colors) return null; + const delimiter = 'color:#'; + + if (!scanner.matches(delimiter)) return null; + + const startPos = scanner.position(); + scanner.consume(delimiter.length); // consume "color:#" + + const hexStart = scanner.position(); + while (!scanner.isEnd() && isHexDigit(scanner.char())) { + scanner.consume(); + } + const hex = scanner.sliceFrom(hexStart); + + let rgba: [number, number, number, number] | null = null; + + if (hex.length === 6 || hex.length === 8) { + // byte pairs: c7 -> 0xc7 + const b: number[] = []; + for (let i = 0; i < hex.length; i += 2) b.push(parseInt(hex.slice(i, i + 2), 16)); + rgba = [b[0], b[1], b[2], b[3] ?? 255]; + } else if (hex.length === 3 || hex.length === 4) { + // single nibbles doubled: c -> cc -> 0xcc + const n: number[] = []; + for (let i = 0; i < hex.length; i++) n.push(parseInt(hex[i] + hex[i], 16)); + rgba = [n[0], n[1], n[2], n[3] ?? 255]; + } + + if (rgba === null || isAnyText(scanner.char())) { + scanner.backtrack(startPos); + return null; + } + + return color(rgba[0], rgba[1], rgba[2], rgba[3]); +} + +function tryImage(scanner: Scanner): Inlines | null { + const start = scanner.position(); + + if (!scanner.matches('![')) return null; + scanner.consume(2); // consume '![' + + const titleStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char()) && scanner.char() !== ']') { + scanner.consume(); + } + const title = scanner.sliceFrom(titleStart); + + if (!scanner.matches('](')) { + scanner.backtrack(start); + return null; + } + scanner.consume(2); // consume '](' + + const urlStart = scanner.position(); + let depth = 1; + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.char() === '(') depth++; + if (scanner.char() === ')') { + depth--; + if (depth === 0) break; + } + scanner.consume(); + } + + if (scanner.char() !== ')') { + scanner.backtrack(start); + return null; + } + const href = scanner.sliceFrom(urlStart); + scanner.consume(); // consume ')' + + if (href.length === 0) { + scanner.backtrack(start); + return null; + } + + return title.length > 0 ? image(href, plain(title)) : image(href); +} + +// ─── Block methods ────────────────────────────────────────────────────────────── + +function tryCodeFence(scanner: Scanner): Code | null { + const start = scanner.position(); + const fence = '```'; + + if (!scanner.matches(fence)) { + return null; + } + scanner.consume(fence.length); + + // Optional language tag + const langStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char())) { + scanner.consume(); + } + const language = scanner.sliceFrom(langStart).trim(); + + // Must be followed by newline + if (scanner.isEnd()) { + scanner.backtrack(start); + return null; + } + + consumeEndOfLine(scanner); // Consume newline after opening ``` + + const lines: CodeLine[] = []; + let closed = false; + + while (!scanner.isEnd()) { + if (scanner.matches(fence)) { + scanner.consume(fence.length); + while (isSpace(scanner.char())) scanner.consume(); // allow trailing spaces; keep other text + closed = true; + break; + } + + const lineStart = scanner.position(); + while (!scanner.isEnd() && !isNewline(scanner.char())) { + scanner.consume(); + } + + const text = scanner.sliceFrom(lineStart); + lines.push(codeLine(plain(text))); + + consumeEndOfLine(scanner); + } + + if (!closed) { + scanner.backtrack(start); + return null; + } + + return code(lines, language || undefined); +} + +function tryHeading(scanner: Scanner, options: Options): Heading | null { + const start = scanner.position(); + let level = 0; // Count # characters (max 4) + + while (level < 4 && scanner.char() === '#') { + scanner.consume(); + level++; + } + + if (level === 0) { + scanner.backtrack(start); + return null; + } + + // Must be followed by at least one space or tab + if (!isSpace(scanner.char())) { + scanner.backtrack(start); + return null; + } + + while (isSpace(scanner.char())) { + scanner.consume(); + } + + if (scanner.isEnd() || isNewline(scanner.char())) { + scanner.backtrack(start); + return null; + } + + const inlines = parseInline(scanner, options); + consumeEndOfLine(scanner); + return heading(inlines, level as 1 | 2 | 3 | 4); +} + +function tryBlockquote(scanner: Scanner, options: Options): Quote | null { + const start = scanner.position(); + + if (scanner.char() !== '>') { + return null; + } + + const paragraphs: Paragraph[] = []; + let hasContent = false; + + while (!scanner.isEnd() && scanner.char() === '>') { + scanner.consume(); // consume '>' + + // Optional space/tab after '>' + if (isSpace(scanner.char())) { + scanner.consume(); + } + + if (scanner.isEnd() || isNewline(scanner.char())) { + paragraphs.push(paragraph([plain('')])); // empty quoted line + } else { + const inlines = parseInline(scanner, options); + paragraphs.push(paragraph(inlines)); + hasContent = true; + } + + consumeEndOfLine(scanner); // Consume newline + } + + if (paragraphs.length === 0 || !hasContent) { + scanner.backtrack(start); + return null; + } + + return quote(paragraphs); +} + +function tryBlockSpoiler(scanner: Scanner, options: Options): SpoilerBlock | null { + const start = scanner.position(); + const spoiler = '||'; + + // Opening line must be exactly "||" + if (!scanner.matches(spoiler)) { + return null; + } + scanner.consume(spoiler.length); + + if (scanner.isEnd() || !isNewline(scanner.char())) { + scanner.backtrack(start); // "||" not alone on its line, or at EOF + return null; + } + consumeEndOfLine(scanner); + + const paragraphs: Paragraph[] = []; + let closed = false; + + while (!scanner.isEnd()) { + if (scanner.matches(spoiler)) { + const closingPos = scanner.position(); + scanner.consume(spoiler.length); + + if (scanner.isEnd() || isNewline(scanner.char())) { + closed = true; + break; + } + scanner.backtrack(closingPos); // not a closing line → treat as content + } + + const inlines = parseInline(scanner, options); + paragraphs.push(paragraph(inlines)); + consumeEndOfLine(scanner); + } + + if (!closed || paragraphs.length === 0) { + scanner.backtrack(start); + return null; + } + + return spoilerBlock(paragraphs); +} + +function tryUnorderedList(scanner: Scanner, options: Options): UnorderedList | null { + const start = scanner.position(); + + const marker = scanner.char(); + if (marker !== '-' && marker !== '*') { + return null; + } + + if (!isSpace(scanner.charAt(1))) { + return null; + } + + const items: ListItem[] = []; + + while (!scanner.isEnd()) { + const ch = scanner.char(); + const itemStart = scanner.position(); + + if (ch !== marker) break; + if (!isSpace(scanner.charAt(1))) break; + + scanner.consume(); // consume marker + + while (isSpace(scanner.char())) { + scanner.consume(); + } + + const inlines = parseInline(scanner, options); + + // '*' is also the bold marker, so "* " or text ending in '*' is bold, not a list + if (marker === '*') { + const last = inlines[inlines.length - 1]; + const isEmpty = inlines.length === 0; + const endsWithStar = last?.type === 'PLAIN_TEXT' && last.value.endsWith('*'); + + if (isEmpty || endsWithStar) { + scanner.backtrack(itemStart); + break; + } + } + + items.push(listItem(inlines)); + + consumeEndOfLine(scanner); + } + + if (items.length === 0) { + scanner.backtrack(start); + return null; + } + + return unorderedList(items); +} + +function tryOrderedList(scanner: Scanner, options: Options): OrderedList | null { + const start = scanner.position(); + + if (!isDigit(scanner.char())) { + return null; + } + + const items: ListItem[] = []; + + while (!scanner.isEnd()) { + if (!isDigit(scanner.char())) break; + + // Collect leading digits + const numStart = scanner.position(); + while (!scanner.isEnd() && isDigit(scanner.char())) { + scanner.consume(); + } + const numStr = scanner.sliceFrom(numStart); + + if (scanner.char() !== '.') { + if (items.length > 0) { + scanner.backtrack(numStart); + break; + } + scanner.backtrack(start); + return null; + } + scanner.consume(); // consume '.' + + if (!isSpace(scanner.char())) { + if (items.length > 0) { + scanner.backtrack(numStart); + break; + } + scanner.backtrack(start); + return null; + } + + while (isSpace(scanner.char())) { + scanner.consume(); + } + + const inlines = parseInline(scanner, options); + items.push(listItem(inlines, parseInt(numStr))); + + consumeEndOfLine(scanner); + } + + if (items.length === 0) { + scanner.backtrack(start); + return null; + } + + return orderedList(items); +} + +function tryKatexBlock(scanner: Scanner, options: Options): KaTeX | null { + const start = scanner.position(); + + let openDelim: string; + let closeDelim: string; + + if (options.katex?.dollarSyntax && scanner.matches('$$')) { + openDelim = '$$'; + closeDelim = '$$'; + } else if (options.katex?.parenthesisSyntax && scanner.matches('\\[')) { + openDelim = '\\['; + closeDelim = '\\]'; + } else { + return null; + } + + scanner.consume(openDelim.length); + + const contentStart = scanner.position(); + while (!scanner.isEnd()) { + if (scanner.matches(closeDelim)) break; + scanner.consume(); + } + + if (!scanner.matches(closeDelim)) { + scanner.backtrack(start); + return null; + } + + const content = scanner.sliceFrom(contentStart); + scanner.consume(closeDelim.length); + + return katex(content); +} + +function tryBigEmoji(input: string, options: Options): [BigEmoji] | null { + const scanner = new Scanner(input); + + const skipWhitespace = (): void => { + while (!scanner.isEnd() && (isSpace(scanner.char()) || isNewline(scanner.char()))) { + scanner.consume(); + } + }; + skipWhitespace(); + const emojis: Inlines[] = []; + while (emojis.length < 3 && !scanner.isEnd()) { + let node: Inlines | null = null; + if (scanner.char() === ':') { + node = tryEmojiShortCode(scanner); + } + if (node === null) { + node = tryUnicodeEmoji(scanner); + } + if (node === null && options.emoticons) { + node = matchEmoticon(scanner); + } + if (node === null) { + return null; + } + emojis.push(node); + skipWhitespace(); + } + if (emojis.length === 0 || !scanner.isEnd()) { + return null; + } + return [bigEmoji(emojis as BigEmoji['value'])]; +} + +function tryTasks(scanner: Scanner, options: Options): Tasks | null { + const start = scanner.position(); + const items: Task[] = []; + const delimiter = '- ['; + + while (scanner.matches(delimiter)) { + const lineStart = scanner.position(); + scanner.consume(delimiter.length); // consume '- [' + + const flag = scanner.char(); + if (flag !== 'x' && flag !== ' ') { + scanner.backtrack(lineStart); + break; + } + scanner.consume(); // consume the flag + + if (scanner.char() !== ']') { + scanner.backtrack(lineStart); + break; + } + scanner.consume(); // consume ']' + + if (!isSpace(scanner.char())) { + scanner.backtrack(lineStart); + break; + } + while (isSpace(scanner.char())) scanner.consume(); + + const inlines = parseInline(scanner, options); + items.push(task(inlines, flag === 'x')); + + consumeEndOfLine(scanner); + } + + if (items.length === 0) { + scanner.backtrack(start); + return null; + } + + return tasks(items); +} + +function tryHorizontalRule(scanner: Scanner): HorizontalRule | null { + const start = scanner.position(); + + while (isSpace(scanner.char())) scanner.consume(); // leading spaces/tabs + + // Need at least three dashes — nothing else counts as a rule. + const dashStart = scanner.position(); + while (scanner.char() === '-') scanner.consume(); + const dashEnd = scanner.position(); + if (dashEnd - dashStart < 3) { + scanner.backtrack(start); + return null; + } + + while (isSpace(scanner.char())) scanner.consume(); // trailing spaces/tabs + + // The rest of the line must be empty. + if (!scanner.isEnd() && !isNewline(scanner.char())) { + scanner.backtrack(start); + return null; + } + + consumeEndOfLine(scanner); + return horizontalRule([dashStart, dashEnd]); +} + +// ------------- Table ----------------------------------------------------------------------- + +function cellAlignment(hasLeftColon: boolean, hasRightColon: boolean): TableCellAlignment { + if (hasLeftColon && hasRightColon) return 'center'; + if (hasRightColon) return 'right'; + if (hasLeftColon) return 'left'; + return undefined; +} + +// One table row "| a | b |" → its cells, or null if not a valid row. +function parseTableRow(scanner: Scanner, options: Options): Inlines[][] | null { + const start = scanner.position(); + if (scanner.char() !== '|') return null; + scanner.consume(); // opening '|' + + const cells: Inlines[][] = []; + while (true) { + // Collect raw cell text up to an unescaped '|' or end of line. + let text = ''; + let closed = false; + while (!scanner.isEnd() && !isNewline(scanner.char())) { + if (scanner.char() === '\\' && scanner.charAt(1) === '|') { + text += '|'; // escaped pipe stays literal + scanner.consume(2); + continue; + } + if (scanner.char() === '|') { + closed = true; + break; + } + text += scanner.char(); + scanner.consume(); + } + + if (!closed) { + scanner.backtrack(start); // no closing '|' -> not a valid row + return null; + } + scanner.consume(); // consume '|' + + cells.push(parseInline(new Scanner(text), options)); + + if (scanner.isEnd() || isNewline(scanner.char())) break; // trailing '|' reached + } + + consumeEndOfLine(scanner); + return cells; +} + +// The separator line "| --- | :--: |" → each column's alignment, or null. +function parseTableDelimiter(scanner: Scanner): TableCellAlignment[] | null { + const start = scanner.position(); + if (scanner.char() !== '|') return null; + scanner.consume(); + + const aligns: TableCellAlignment[] = []; + while (true) { + while (isSpace(scanner.char())) scanner.consume(); + + const left = scanner.char() === ':'; + if (left) scanner.consume(); + + let dashes = 0; + while (scanner.char() === '-') { + scanner.consume(); + dashes++; + } + if (dashes === 0) { + scanner.backtrack(start); + return null; + } + + const right = scanner.char() === ':'; + if (right) scanner.consume(); + + while (isSpace(scanner.char())) scanner.consume(); + if (scanner.char() !== '|') { + scanner.backtrack(start); + return null; + } + scanner.consume(); // consume '|' + + aligns.push(cellAlignment(left, right)); + + if (scanner.isEnd() || isNewline(scanner.char())) break; + } + + consumeEndOfLine(scanner); + return aligns; +} + +function tryTable(scanner: Scanner, options: Options): Table | null { + const start = scanner.position(); + + const header = parseTableRow(scanner, options); + if (header === null) return null; + + const aligns = parseTableDelimiter(scanner); + if (aligns === null) { + scanner.backtrack(start); // a header row with no delimiter row isn't a table + return null; + } + + const rows: Inlines[][][] = []; + while (true) { + const row = parseTableRow(scanner, options); + if (row === null) break; + rows.push(row); + } + + return table(header, aligns, rows, [start, scanner.position()]); +} diff --git a/packages/message-parser/src/scanner.ts b/packages/message-parser/src/scanner.ts new file mode 100644 index 0000000000000..9135655162607 --- /dev/null +++ b/packages/message-parser/src/scanner.ts @@ -0,0 +1,46 @@ +export class Scanner { + private pos: number; + + private readonly input: string; + + constructor(input: string, startPos = 0) { + this.input = input; + this.pos = startPos; + } + + public char(): string { + return this.input[this.pos] ?? ''; + } + + public charAt(offset: number): string { + return this.input[this.pos + offset] ?? ''; + } + + public consume(n: number = 1): void { + this.pos += n; + } + + public isEnd(): boolean { + return this.pos >= this.input.length; + } + + public matches(literal: string): boolean { + return this.input.startsWith(literal, this.pos); + } + + public position(): number { + return this.pos; + } + + public previous(): string { + return this.input[this.pos - 1] ?? ''; + } + + public backtrack(savedPos: number): void { + this.pos = savedPos; + } + + public sliceFrom(savedPos: number): string { + return this.input.slice(savedPos, this.pos); + } +} diff --git a/packages/message-parser/webpack.config.ts b/packages/message-parser/webpack.config.ts index dd5e05742e6ad..288fc6e165ba6 100644 --- a/packages/message-parser/webpack.config.ts +++ b/packages/message-parser/webpack.config.ts @@ -16,14 +16,10 @@ export default [ include: [resolve('./src')], exclude: [resolve('./tests')], }, - { - test: /\.pegjs$/, - use: ['@rocket.chat/peggy-loader'], - }, ], }, resolve: { - extensions: ['.ts', '.js', '.pegjs'], + extensions: ['.ts', '.js'], }, mode: 'production', experiments: { diff --git a/packages/peggy-loader/CHANGELOG.md b/packages/peggy-loader/CHANGELOG.md deleted file mode 100644 index 7226c16c2bff8..0000000000000 --- a/packages/peggy-loader/CHANGELOG.md +++ /dev/null @@ -1,73 +0,0 @@ -# Change Log - -## 0.31.28 - -### Patch Changes - -- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration - -## 0.31.28-rc.0 - -### Patch Changes - -- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration - -## 0.31.27 - -### Patch Changes - -- ([#33227](https://github.com/RocketChat/Rocket.Chat/pull/33227)) Improved the performance of the message parser - -## 0.31.27-rc.0 - -### Patch Changes - -- ([#33227](https://github.com/RocketChat/Rocket.Chat/pull/33227)) Improved the performance of the message parser - -## 0.31.26 - -### Patch Changes - -- ([#33254](https://github.com/RocketChat/Rocket.Chat/pull/33254) by [@dionisio-bot](https://github.com/dionisio-bot)) Improved the performance of the message parser - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.31.0](https://github.com/RocketChat/fuselage/compare/v0.30.1...v0.31.0) (2021-12-28) - -### Features - -- New hooks for element size tracking ([#413](https://github.com/RocketChat/fuselage/issues/413)) ([8ca682c](https://github.com/RocketChat/fuselage/commit/8ca682c636d2e4813f7d346cb881513382be63cf)) - -# [0.30.0](https://github.com/RocketChat/fuselage/compare/v0.29.0...v0.30.0) (2021-10-06) - -### Bug Fixes - -- **jest:** Adjust jest and ts-jest dependencies ([#547](https://github.com/RocketChat/fuselage/issues/547)) ([91a4fa1](https://github.com/RocketChat/fuselage/commit/91a4fa1365394001afe1bd46480bda3bafed5505)) - -# [0.29.0](https://github.com/RocketChat/fuselage/compare/v0.28.0...v0.29.0) (2021-08-31) - -**Note:** Version bump only for package @rocket.chat/peggy-loader - -# [0.28.0](https://github.com/RocketChat/fuselage/compare/v0.27.0...v0.28.0) (2021-07-30) - -### Features - -- **onboarding-ui:** Administrator information form and Organization information form ([#489](https://github.com/RocketChat/fuselage/issues/489)) ([b289f68](https://github.com/RocketChat/fuselage/commit/b289f68676954b91c792d8d97680314178bf2c60)) -- styled API; monorepo grooming ([#482](https://github.com/RocketChat/fuselage/issues/482)) ([1b6b70c](https://github.com/RocketChat/fuselage/commit/1b6b70cf67ec16927b1566adc2350295a8927223)) - -# [0.27.0](https://github.com/RocketChat/fuselage/compare/v0.26.0...v0.27.0) (2021-06-28) - -**Note:** Version bump only for package @rocket.chat/peggy-loader - -# [0.26.0](https://github.com/RocketChat/fuselage/compare/v0.25.0...v0.26.0) (2021-05-28) - -### Bug Fixes - -- Peggy loader options ([#459](https://github.com/RocketChat/fuselage/issues/459)) ([fc91054](https://github.com/RocketChat/fuselage/commit/fc91054abeb340718596b0c8f4ce8e14c87574af)) - -# [0.25.0](https://github.com/RocketChat/fuselage/compare/v0.24.0...v0.25.0) (2021-05-19) - -### Features - -- Peggy loader ([#450](https://github.com/RocketChat/fuselage/issues/450)) ([0496cad](https://github.com/RocketChat/fuselage/commit/0496cad457d76f8a4d6a217209e4a55e315e8365)) diff --git a/packages/peggy-loader/README.md b/packages/peggy-loader/README.md deleted file mode 100644 index 4d2cf606c2df5..0000000000000 --- a/packages/peggy-loader/README.md +++ /dev/null @@ -1,89 +0,0 @@ - - -

- - Rocket.Chat - -

- -# `@rocket.chat/peggy-loader` - -> Peggy loader for webpack - ---- - -[![npm@latest](https://img.shields.io/npm/v/@rocket.chat/peggy-loader/latest?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/peggy-loader/v/latest) [![npm@next](https://img.shields.io/npm/v/@rocket.chat/peggy-loader/next?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/peggy-loader/v/next) ![npm downloads](https://img.shields.io/npm/dw/@rocket.chat/peggy-loader?style=flat-square) ![License: MIT](https://img.shields.io/npm/l/@rocket.chat/peggy-loader?style=flat-square) - -![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/peggy-loader?style=flat-square) ![npm bundle size](https://img.shields.io/bundlephobia/min/@rocket.chat/peggy-loader?style=flat-square) - - - -## Install - - - -Firstly, install the peer dependencies (prerequisites): - -```sh -npm i peggy webpack - -# or, if you are using yarn: - -yarn add peggy webpack -``` - -Add `@rocket.chat/peggy-loader` as a dependency: - -```sh -npm i @rocket.chat/peggy-loader - -# or, if you are using yarn: - -yarn add @rocket.chat/peggy-loader -``` - - - -## Contributing - - - -Contributions, issues, and feature requests are welcome!
-Feel free to check the [issues](https://github.com/RocketChat/fuselage/issues). - - - -### Building - -As this package dependends on others in this monorepo, before anything run the following at the root directory: - - - -```sh -yarn build -``` - - - -### Linting - -To ensure the source is matching our coding style, we perform [linting](). -Before commiting, check if your code fits our style by running: - - - -```sh -yarn lint -``` - - - -Some linter warnings and errors can be automatically fixed: - - - -```sh -yarn lint-and-fix -``` - - diff --git a/packages/peggy-loader/package.json b/packages/peggy-loader/package.json deleted file mode 100644 index b537643d23029..0000000000000 --- a/packages/peggy-loader/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@rocket.chat/peggy-loader", - "version": "0.31.28", - "description": "Peggy loader for webpack", - "keywords": [ - "peggy", - "loader", - "webpack" - ], - "homepage": "https://github.com/RocketChat/fuselage#readme", - "bugs": { - "url": "https://github.com/RocketChat/fuselage/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/RocketChat/fuselage.git", - "directory": "packages/peggy-loader" - }, - "license": "MIT", - "author": { - "name": "Rocket.Chat", - "url": "https://rocket.chat/" - }, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "/dist" - ], - "scripts": { - ".:build:clean": "rimraf dist", - ".:build:tsc": "tsc -p tsconfig.build.json", - "build": "run-s .:build:clean .:build:tsc", - "lint": "eslint ." - }, - "devDependencies": { - "@rocket.chat/prettier-config": "~0.31.25", - "@types/node": "~22.19.21", - "eslint": "~9.39.5", - "npm-run-all": "^4.1.5", - "peggy": "4.1.1", - "prettier": "~3.3.3", - "rimraf": "^6.0.1", - "typescript": "~5.9.3", - "webpack": "~5.104.1" - }, - "peerDependencies": { - "peggy": "*", - "webpack": "*" - }, - "volta": { - "extends": "../../package.json" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/peggy-loader/src/index.ts b/packages/peggy-loader/src/index.ts deleted file mode 100644 index 4227545fc349c..0000000000000 --- a/packages/peggy-loader/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { - BuildOptionsBase, - OutputFormatAmdCommonjsEs, - OutputFormatBare, - OutputFormatGlobals, - OutputFormatUmd, - SourceOptionsBase, -} from 'peggy'; -import peggy from 'peggy'; -import type { LoaderContext } from 'webpack'; - -type Options = BuildOptionsBase & - ( - | Omit, keyof SourceOptionsBase<'source'>> - | Omit, keyof SourceOptionsBase<'source'>> - | Omit, keyof SourceOptionsBase<'source'>> - | Omit, keyof SourceOptionsBase<'source'>> - ); - -function peggyLoader(this: LoaderContext, grammarContent: string): string { - return peggy.generate(grammarContent, { - output: 'source', - format: 'es', - ...this.getOptions(), - }); -} - -export default peggyLoader; diff --git a/packages/peggy-loader/tsconfig.build.json b/packages/peggy-loader/tsconfig.build.json deleted file mode 100644 index b9b6e6d5d2fe2..0000000000000 --- a/packages/peggy-loader/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - } -} diff --git a/packages/peggy-loader/tsconfig.json b/packages/peggy-loader/tsconfig.json deleted file mode 100644 index 1915aa6f3547f..0000000000000 --- a/packages/peggy-loader/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@rocket.chat/tsconfig/server.json", - "compilerOptions": { - "module": "esnext", - }, - "include": ["src"] -} diff --git a/yarn.lock b/yarn.lock index 235be20dff4e1..12efb648e62c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6688,15 +6688,6 @@ __metadata: languageName: node linkType: hard -"@peggyjs/from-mem@npm:1.3.4": - version: 1.3.4 - resolution: "@peggyjs/from-mem@npm:1.3.4" - dependencies: - semver: "npm:7.6.3" - checksum: 10/d32ce47af78eb6ccec3cb5b98f4c592bbdb634b05dd99a0fb107cfa48c17069ea0b5fb284705839de7be3a43638e05b0c9733fa83b30e3e2cf608790e42b5e31 - languageName: node - linkType: hard - "@pinojs/redact@npm:^0.4.0": version: 0.4.0 resolution: "@pinojs/redact@npm:0.4.0" @@ -9744,7 +9735,6 @@ __metadata: resolution: "@rocket.chat/message-parser@workspace:packages/message-parser" dependencies: "@rocket.chat/jest-presets": "workspace:~" - "@rocket.chat/peggy-loader": "workspace:~" "@rocket.chat/prettier-config": "npm:~0.31.25" "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.19.21" @@ -9752,9 +9742,7 @@ __metadata: fast-check: "npm:^4.6.0" jest: "npm:~30.2.0" npm-run-all: "npm:^4.1.5" - peggy: "npm:4.1.1" prettier: "npm:~3.3.3" - prettier-plugin-pegjs: "npm:~0.5.4" rimraf: "npm:^6.0.1" tinybench: "npm:^3.0.7" tldts: "npm:~6.1.86" @@ -10510,25 +10498,6 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/peggy-loader@workspace:packages/peggy-loader, @rocket.chat/peggy-loader@workspace:~": - version: 0.0.0-use.local - resolution: "@rocket.chat/peggy-loader@workspace:packages/peggy-loader" - dependencies: - "@rocket.chat/prettier-config": "npm:~0.31.25" - "@types/node": "npm:~22.19.21" - eslint: "npm:~9.39.5" - npm-run-all: "npm:^4.1.5" - peggy: "npm:4.1.1" - prettier: "npm:~3.3.3" - rimraf: "npm:^6.0.1" - typescript: "npm:~5.9.3" - webpack: "npm:~5.104.1" - peerDependencies: - peggy: "*" - webpack: "*" - languageName: unknown - linkType: soft - "@rocket.chat/poplib@workspace:^, @rocket.chat/poplib@workspace:packages/node-poplib": version: 0.0.0-use.local resolution: "@rocket.chat/poplib@workspace:packages/node-poplib" @@ -30595,19 +30564,6 @@ __metadata: languageName: node linkType: hard -"peggy@npm:4.1.1": - version: 4.1.1 - resolution: "peggy@npm:4.1.1" - dependencies: - "@peggyjs/from-mem": "npm:1.3.4" - commander: "npm:^12.1.0" - source-map-generator: "npm:0.8.0" - bin: - peggy: bin/peggy.js - checksum: 10/a2531d6a2448addd7a38640c5f73ba1a762f5974caa33f44c8f0dff735d7a701c4befe630ad2b0ed8e46ffda1f3ea503c3844e477cb7e55b62759918a365585f - languageName: node - linkType: hard - "pend@npm:~1.2.0": version: 1.2.0 resolution: "pend@npm:1.2.0" @@ -31601,16 +31557,7 @@ __metadata: languageName: node linkType: hard -"prettier-plugin-pegjs@npm:~0.5.4": - version: 0.5.4 - resolution: "prettier-plugin-pegjs@npm:0.5.4" - dependencies: - prettier: "npm:^2.8.4" - checksum: 10/d299a37e4ba03aeea4ba261a8efeec43403dbec345d192c13a9f73e7dbb1625863c4a3e6f4affd1b0b22139619b6534216cead68aa3cec56517e43b831ddacd8 - languageName: node - linkType: hard - -"prettier@npm:^2.7.1, prettier@npm:^2.8.4": +"prettier@npm:^2.7.1": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -34469,13 +34416,6 @@ __metadata: languageName: node linkType: hard -"source-map-generator@npm:0.8.0": - version: 0.8.0 - resolution: "source-map-generator@npm:0.8.0" - checksum: 10/f57df2c1bf33d84fc8a74fa030d4d355b033548569977b63d6930f6b2bf37f575946c88fb89c73b42a54ff6a46bd24253f0ddfa2c2226341e25c22240d168ed1 - languageName: node - linkType: hard - "source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1"