/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {FormattingContext} from '@fluxer/markdown_parser/src/parser/FormattingContext'; import * as EmojiParsers from '@fluxer/markdown_parser/src/parsers/EmojiParsers'; import * as LinkParsers from '@fluxer/markdown_parser/src/parsers/LinkParsers'; import * as MentionParsers from '@fluxer/markdown_parser/src/parsers/MentionParsers'; import * as TimestampParsers from '@fluxer/markdown_parser/src/parsers/TimestampParsers'; import {MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums'; import {MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants'; import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes'; import * as ASTUtils from '@fluxer/markdown_parser/src/utils/AstUtils'; import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils'; const BACKSLASH = 92; const UNDERSCORE = 95; const ASTERISK = 42; const TILDE = 126; const PIPE = 124; const BACKTICK = 96; const LESS_THAN = 60; const AT_SIGN = 64; const HASH = 35; const SLASH = 47; const OPEN_BRACKET = 91; const COLON = 58; const LETTER_A = 97; const LETTER_I = 105; const LETTER_M = 109; const LETTER_S = 115; const LETTER_T = 116; const PLUS_SIGN = 43; const FORMATTING_CHARS = new Set([ASTERISK, UNDERSCORE, TILDE, PIPE, BACKTICK]); const parseInlineCache = new Map>(); const formattingMarkerCache = new Map>(); const MAX_CACHE_SIZE = 500; const cacheHitCount = new Map(); export function parseInline(text: string, parserFlags: number): Array { if (!text || text.length === 0) { return []; } const cacheKey = `${text}:${parserFlags}`; if (parseInlineCache.has(cacheKey)) { const cachedResult = parseInlineCache.get(cacheKey)!; const hitCount = cacheHitCount.get(cacheKey) || 0; cacheHitCount.set(cacheKey, hitCount + 1); return [...cachedResult]; } const context = new FormattingContext(); const nodes = parseInlineWithContext(text, context, parserFlags); ASTUtils.flattenAST(nodes); if (text.length < 1000) { parseInlineCache.set(cacheKey, [...nodes]); cacheHitCount.set(cacheKey, 1); if (parseInlineCache.size > MAX_CACHE_SIZE) { const entries = Array.from(cacheHitCount.entries()) .sort((a, b) => a[1] - b[1]) .slice(0, 100); for (const [key] of entries) { parseInlineCache.delete(key); cacheHitCount.delete(key); } } } return nodes; } function parseInlineWithContext(text: string, context: FormattingContext, parserFlags: number): Array { if (!text) { return []; } const nodes: Array = []; let accumulatedText = ''; let position = 0; const textLength = text.length; let characters: Array | null = null; while (position < textLength) { const currentChar = text.charAt(position); const currentCharCode = text.charCodeAt(position); if (currentCharCode === BACKSLASH && position + 1 < textLength) { const nextChar = text.charAt(position + 1); if (nextChar === '_' && position > 0 && text.charAt(position - 1) === '¯') { accumulatedText += `\\${nextChar}`; position += 2; continue; } if (StringUtils.isEscapableCharacter(nextChar)) { accumulatedText += nextChar; position += 2; continue; } } const remainingText = text.slice(position); const insideQuotedAngleBracket = accumulatedText.endsWith('<"') || accumulatedText.endsWith("<'"); if ( !insideQuotedAngleBracket && parserFlags & ParserFlags.ALLOW_AUTOLINKS && StringUtils.startsWithUrl(remainingText) ) { const urlResult = LinkParsers.extractUrlSegment(remainingText, parserFlags); if (urlResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(urlResult.node); position += urlResult.advance; continue; } } if (currentCharCode === UNDERSCORE) { if (characters == null) { characters = [...text]; } const isDoubleUnderscore = position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE; if (!isDoubleUnderscore) { const isWordUnderscore = StringUtils.isWordUnderscore(characters, position); if (isWordUnderscore) { accumulatedText += '_'; position += 1; continue; } } } const emojiResult = EmojiParsers.parseStandardEmoji(text, position); if (emojiResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(emojiResult.node); position += emojiResult.advance; continue; } if (currentCharCode === LESS_THAN && position + 2 < textLength) { const nextCharCode = text.charCodeAt(position + 1); const thirdCharCode = position + 2 < textLength ? text.charCodeAt(position + 2) : 0; if (nextCharCode === COLON || (nextCharCode === LETTER_A && thirdCharCode === COLON)) { const customEmojiResult = EmojiParsers.parseCustomEmoji(remainingText); if (customEmojiResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(customEmojiResult.node); position += customEmojiResult.advance; continue; } } } if ( currentCharCode === LESS_THAN && position + 3 < textLength && text.charCodeAt(position + 1) === LETTER_T && text.charCodeAt(position + 2) === COLON ) { const timestampResult = TimestampParsers.parseTimestamp(remainingText); if (timestampResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(timestampResult.node); position += timestampResult.advance; continue; } } if (currentCharCode === COLON) { const shortcodeEmojiResult = EmojiParsers.parseEmojiShortcode(remainingText); if (shortcodeEmojiResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(shortcodeEmojiResult.node); position += shortcodeEmojiResult.advance; continue; } } if ( currentCharCode === LESS_THAN && position + 1 < textLength && text.charCodeAt(position + 1) === PLUS_SIGN && parserFlags & ParserFlags.ALLOW_AUTOLINKS ) { const phoneResult = LinkParsers.parsePhoneLink(remainingText, parserFlags); if (phoneResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(phoneResult.node); position += phoneResult.advance; continue; } } if ( currentCharCode === LESS_THAN && position + 4 < textLength && text.charCodeAt(position + 1) === LETTER_S && text.charCodeAt(position + 2) === LETTER_M && text.charCodeAt(position + 3) === LETTER_S && text.charCodeAt(position + 4) === COLON && parserFlags & ParserFlags.ALLOW_AUTOLINKS ) { const smsResult = LinkParsers.parseSmsLink(remainingText, parserFlags); if (smsResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(smsResult.node); position += smsResult.advance; continue; } } if (currentCharCode === LESS_THAN && position + 1 < textLength) { const nextCharCode = text.charCodeAt(position + 1); if (nextCharCode === AT_SIGN || nextCharCode === HASH || nextCharCode === SLASH || nextCharCode === LETTER_I) { if ( nextCharCode === AT_SIGN && position + 2 < textLength && text.charCodeAt(position + 2) === 38 && parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS ) { const mentionResult = MentionParsers.parseMention(remainingText, parserFlags); if (mentionResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(mentionResult.node); position += mentionResult.advance; continue; } } else if (nextCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) { const mentionResult = MentionParsers.parseMention(remainingText, parserFlags); if (mentionResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(mentionResult.node); position += mentionResult.advance; continue; } } else if (nextCharCode === HASH && parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS) { const mentionResult = MentionParsers.parseMention(remainingText, parserFlags); if (mentionResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(mentionResult.node); position += mentionResult.advance; continue; } } else if (nextCharCode === SLASH && parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) { const mentionResult = MentionParsers.parseMention(remainingText, parserFlags); if (mentionResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(mentionResult.node); position += mentionResult.advance; continue; } } else if ( nextCharCode === LETTER_I && remainingText.startsWith(' 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(mentionResult.node); position += mentionResult.advance; continue; } } } if (parserFlags & ParserFlags.ALLOW_AUTOLINKS) { const autolinkResult = LinkParsers.parseAutolink(remainingText, parserFlags); if (autolinkResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(autolinkResult.node); position += autolinkResult.advance; continue; } const emailLinkResult = LinkParsers.parseEmailLink(remainingText, parserFlags); if (emailLinkResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(emailLinkResult.node); position += emailLinkResult.advance; continue; } } } if (currentCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) { const isEscaped = position > 0 && text.charCodeAt(position - 1) === BACKSLASH; if (!isEscaped && remainingText.startsWith('@everyone')) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push({ type: NodeType.Mention, kind: {kind: MentionKind.Everyone}, }); position += 9; continue; } if (!isEscaped && remainingText.startsWith('@here')) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push({ type: NodeType.Mention, kind: {kind: MentionKind.Here}, }); position += 5; continue; } } const isDoubleUnderscore = currentCharCode === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE; if ( (FORMATTING_CHARS.has(currentCharCode) || currentCharCode === OPEN_BRACKET) && (isDoubleUnderscore || !( currentCharCode === UNDERSCORE && accumulatedText.length > 0 && StringUtils.isAlphaNumeric(accumulatedText.charCodeAt(accumulatedText.length - 1)) )) ) { context.setCurrentText(accumulatedText); const specialResult = parseSpecialSequence(remainingText, context, parserFlags); if (specialResult) { if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; } nodes.push(specialResult.node); position += specialResult.advance; continue; } } accumulatedText += currentChar; position += 1; if (accumulatedText.length > MAX_LINE_LENGTH) { ASTUtils.addTextNode(nodes, accumulatedText); accumulatedText = ''; break; } } if (accumulatedText.length > 0) { ASTUtils.addTextNode(nodes, accumulatedText); } const result = ASTUtils.mergeTextNodes(nodes); return result; } function parseSpecialSequence(text: string, context: FormattingContext, parserFlags: number): ParserResult | null { if (text.length === 0) return null; const firstCharCode = text.charCodeAt(0); switch (firstCharCode) { case LESS_THAN: if (text.length > 1) { const nextCharCode = text.charCodeAt(1); if (nextCharCode === SLASH) { if (parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) { const mentionResult = MentionParsers.parseMention(text, parserFlags); if (mentionResult) return mentionResult; } } else if (nextCharCode === LETTER_I && text.startsWith(' 4 && text.charCodeAt(2) === LETTER_S && text.charCodeAt(3) === COLON && parserFlags & ParserFlags.ALLOW_AUTOLINKS ) { const smsResult = LinkParsers.parseSmsLink(text, parserFlags); if (smsResult) return smsResult; } } break; case ASTERISK: case UNDERSCORE: case TILDE: case PIPE: case BACKTICK: { const formattingResult = parseFormatting(text, context, parserFlags); if (formattingResult) return formattingResult; break; } case AT_SIGN: if (parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) { if (text.startsWith('@everyone')) { return { node: { type: NodeType.Mention, kind: {kind: MentionKind.Everyone}, }, advance: 9, }; } if (text.startsWith('@here')) { return { node: { type: NodeType.Mention, kind: {kind: MentionKind.Here}, }, advance: 5, }; } } break; case OPEN_BRACKET: { const timestampResult = TimestampParsers.parseTimestamp(text); if (timestampResult) return timestampResult; if (parserFlags & ParserFlags.ALLOW_MASKED_LINKS) { const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags)); if (linkResult) return linkResult; } break; } } if (firstCharCode !== OPEN_BRACKET) { const timestampResult = TimestampParsers.parseTimestamp(text); if (timestampResult) return timestampResult; } if (firstCharCode !== LESS_THAN && firstCharCode !== OPEN_BRACKET && parserFlags & ParserFlags.ALLOW_MASKED_LINKS) { const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags)); if (linkResult) return linkResult; } return null; } function parseFormatting(text: string, context: FormattingContext, parserFlags: number): ParserResult | null { if (text.length < 2) { return null; } let markerInfo: FormattingMarkerInfo | null | undefined; const prefix = text.slice(0, Math.min(3, text.length)); if (formattingMarkerCache.has(prefix)) { markerInfo = formattingMarkerCache.get(prefix); const hitCount = cacheHitCount.get(prefix) || 0; cacheHitCount.set(prefix, hitCount + 1); } else { markerInfo = getFormattingMarkerInfo(text); formattingMarkerCache.set(prefix, markerInfo); cacheHitCount.set(prefix, 1); if (formattingMarkerCache.size > MAX_CACHE_SIZE) { const entries = Array.from(cacheHitCount.entries()) .filter(([key]) => formattingMarkerCache.has(key)) .sort((a, b) => a[1] - b[1]) .slice(0, 50); for (const [key] of entries) { formattingMarkerCache.delete(key); cacheHitCount.delete(key); } } } if (!markerInfo) return null; const {marker, nodeType, markerLength} = markerInfo; if (nodeType === NodeType.Spoiler && !(parserFlags & ParserFlags.ALLOW_SPOILERS)) { return null; } if (!context.canEnterFormatting(marker[0], marker.length > 1)) return null; const endResult = findFormattingEnd(text, marker, markerLength, nodeType); if (!endResult) return null; const {endPosition, innerContent} = endResult; const isBlock = context.isFormattingActive(marker[0], marker.length > 1); const formattingNode = createFormattingNode( nodeType, innerContent, marker, isBlock, (text: string, ctx: FormattingContext) => parseInlineWithContext(text, ctx, parserFlags), ); return {node: formattingNode, advance: endPosition + markerLength}; } interface FormattingMarkerInfo { marker: string; nodeType: NodeType; markerLength: number; } function getFormattingMarkerInfo(text: string): FormattingMarkerInfo | null { if (!text || text.length === 0) return null; const firstCharCode = text.charCodeAt(0); if (!FORMATTING_CHARS.has(firstCharCode)) return null; const secondCharCode = text.length > 1 ? text.charCodeAt(1) : 0; const thirdCharCode = text.length > 2 ? text.charCodeAt(2) : 0; if (firstCharCode === ASTERISK && secondCharCode === ASTERISK && thirdCharCode === ASTERISK) { return {marker: '***', nodeType: NodeType.Emphasis, markerLength: 3}; } if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE && thirdCharCode === UNDERSCORE) { return {marker: '___', nodeType: NodeType.Emphasis, markerLength: 3}; } if (firstCharCode === PIPE && secondCharCode === PIPE) { return {marker: '||', nodeType: NodeType.Spoiler, markerLength: 2}; } if (firstCharCode === TILDE && secondCharCode === TILDE) { return {marker: '~~', nodeType: NodeType.Strikethrough, markerLength: 2}; } if (firstCharCode === ASTERISK && secondCharCode === ASTERISK) { return {marker: '**', nodeType: NodeType.Strong, markerLength: 2}; } if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE) { return {marker: '__', nodeType: NodeType.Underline, markerLength: 2}; } if (firstCharCode === BACKTICK) { let backtickCount = 1; while (backtickCount < text.length && text.charCodeAt(backtickCount) === BACKTICK) { backtickCount++; } return {marker: '`'.repeat(backtickCount), nodeType: NodeType.InlineCode, markerLength: backtickCount}; } if (firstCharCode === ASTERISK) { return {marker: '*', nodeType: NodeType.Emphasis, markerLength: 1}; } if (firstCharCode === UNDERSCORE) { return {marker: '_', nodeType: NodeType.Emphasis, markerLength: 1}; } return null; } function findFormattingEnd( text: string, marker: string, markerLength: number, nodeType: NodeType, ): {endPosition: number; innerContent: string} | null { let position = markerLength; let nestedLevel = 0; let endPosition: number | null = null; const textLength = text.length; if (textLength < markerLength * 2) return null; if (nodeType === NodeType.InlineCode && markerLength > 1) { while (position < textLength) { if (text.charCodeAt(position) === BACKTICK) { let backtickCount = 0; let checkPos = position; while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) { backtickCount++; checkPos++; } if (backtickCount === markerLength) { endPosition = position; break; } position = checkPos; continue; } position++; if (position > MAX_LINE_LENGTH) break; } if (endPosition == null) return null; return { endPosition, innerContent: text.slice(markerLength, endPosition), }; } if (markerLength === 1 && (nodeType === NodeType.Emphasis || nodeType === NodeType.InlineCode)) { const markerChar = marker.charCodeAt(0); while (position < textLength) { const currentChar = text.charCodeAt(position); if (currentChar === BACKSLASH && position + 1 < textLength) { position += 2; continue; } if (currentChar === markerChar) { if (markerChar === BACKTICK && position + 1 < textLength && text.charCodeAt(position + 1) === BACKTICK) { let checkPos = position; while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) { checkPos++; } position = checkPos; continue; } if (markerChar === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE) { position += 2; continue; } endPosition = position; break; } position++; if (position > MAX_LINE_LENGTH) break; } if (endPosition == null) return null; return { endPosition, innerContent: text.slice(markerLength, endPosition), }; } if (nodeType === NodeType.InlineCode) { while (position < textLength) { if (text.charCodeAt(position) === BACKTICK) { endPosition = position; break; } position++; if (position > MAX_LINE_LENGTH) break; } } else { const firstMarkerChar = marker.charCodeAt(0); const isDoubleMarker = marker.length > 1; while (position < textLength) { if (text.charCodeAt(position) === BACKSLASH && position + 1 < textLength) { position += 2; continue; } let isClosingMarker = true; if (position + marker.length <= textLength) { for (let i = 0; i < marker.length; i++) { if (text.charCodeAt(position + i) !== marker.charCodeAt(i)) { isClosingMarker = false; break; } } } else { isClosingMarker = false; } if (isClosingMarker) { if (nestedLevel === 0) { if (nodeType === NodeType.Spoiler && position === markerLength && position + marker.length < textLength) { position += 1; continue; } endPosition = position; break; } nestedLevel--; position += marker.length; continue; } if ( isDoubleMarker && position + 1 < textLength && text.charCodeAt(position) === firstMarkerChar && text.charCodeAt(position + 1) === firstMarkerChar ) { nestedLevel++; } position++; if (position > MAX_LINE_LENGTH) break; } } if (endPosition == null) return null; const innerContent = text.slice(markerLength, endPosition); return {endPosition, innerContent}; } type NodeWithChildren = Extract}>; type FormattingNodeType = NodeWithChildren['type']; function createFormattingNode( nodeType: NodeType, innerContent: string, marker: string, isBlock: boolean, parseInlineWithContext: (text: string, context: FormattingContext) => Array, ): Node { if (nodeType === NodeType.InlineCode) { return {type: NodeType.InlineCode, content: innerContent}; } if (innerContent.length === 0) { return { type: nodeType as FormattingNodeType, children: [], ...(isBlock ? {isBlock} : {}), } as NodeWithChildren; } const newContext = new FormattingContext(); newContext.pushFormatting(marker[0], marker.length > 1); if (marker === '***' || marker === '___') { const emphasisContext = new FormattingContext(); emphasisContext.pushFormatting('*', true); const innerNodes = parseInlineWithContext(innerContent, emphasisContext); return { type: NodeType.Emphasis, children: [{type: NodeType.Strong, children: innerNodes}], }; } const innerNodes = parseInlineWithContext(innerContent, newContext); return { type: nodeType as FormattingNodeType, children: innerNodes, ...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}), } as NodeWithChildren; }