refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,815 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {Parser} from '@fluxer/markdown_parser/src/parser/Parser';
import * as InlineParsers from '@fluxer/markdown_parser/src/parsers/InlineParsers';
import * as ListParsers from '@fluxer/markdown_parser/src/parsers/ListParsers';
import * as TableParsers from '@fluxer/markdown_parser/src/parsers/TableParsers';
import {AlertType, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {
AlertNode,
CodeBlockNode,
HeadingNode,
Node,
SpoilerNode,
SubtextNode,
TextNode,
} from '@fluxer/markdown_parser/src/types/Nodes';
import {flattenChildren} from '@fluxer/markdown_parser/src/utils/AstUtils';
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
interface BlockParseResult {
node: Node | null;
newLineIndex: number;
newNodeCount: number;
extraNodes?: Array<Node>;
}
export interface BlockParserDependencies {
parseInline: (text: string) => Array<Node>;
parseNested: (text: string, parserFlags: number) => Array<Node>;
}
const stringCache = new Map<string, boolean>();
function hasOpenInlineCode(text: string): boolean {
if (!text.includes('`')) return false;
let openLength: number | null = null;
let index = 0;
while (index < text.length) {
if (text[index] !== '`') {
index++;
continue;
}
let runLength = 0;
while (index + runLength < text.length && text[index + runLength] === '`') {
runLength++;
}
if (openLength === null) {
openLength = runLength;
} else if (runLength === openLength) {
openLength = null;
}
index += runLength;
}
return openLength !== null;
}
function cachedStartsWith(str: string, search: string): boolean {
const key = `${str}:${search}:startsWith`;
if (!stringCache.has(key)) {
stringCache.set(key, str.startsWith(search));
}
return stringCache.get(key)!;
}
export function parseBlock(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
_dependencies: BlockParserDependencies,
): BlockParseResult {
if (currentLineIndex >= lines.length) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (cachedStartsWith(trimmed, '>>> ')) {
if (!(parserFlags & ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES)) {
const result = {
node: parseBlockAsText(lines, currentLineIndex, '>>> '),
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
const result = parseMultilineBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
return result;
}
if (cachedStartsWith(trimmed, '>')) {
if (!(parserFlags & ParserFlags.ALLOW_BLOCKQUOTES)) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const result = parseBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
return result;
}
const listMatch = ListParsers.matchListItem(line);
if (listMatch) {
const [isOrdered, indentLevel, _content] = listMatch;
if (parserFlags & ParserFlags.ALLOW_LISTS) {
const result = ListParsers.parseList(
lines,
currentLineIndex,
isOrdered,
indentLevel,
1,
parserFlags,
nodeCount,
(text) => InlineParsers.parseInline(text, parserFlags),
);
const finalResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: result.newNodeCount,
};
return finalResult;
}
const textNode: TextNode = {type: NodeType.Text, content: line};
const result: BlockParseResult = {
node: textNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (trimmed.startsWith('||') && !trimmed.slice(2).includes('||')) {
if (parserFlags & ParserFlags.ALLOW_SPOILERS) {
const result = parseSpoiler(lines, currentLineIndex, parserFlags);
const finalResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: nodeCount + 1,
};
return finalResult;
}
const textNode: TextNode = {type: NodeType.Text, content: line};
const result: BlockParseResult = {
node: textNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) {
const fencePosition = line.indexOf('```');
if (fencePosition !== -1) {
const startsWithFence = cachedStartsWith(trimmed, '```') && fencePosition === line.length - trimmed.length;
if (startsWithFence) {
const result = parseCodeBlock(lines, currentLineIndex);
const finalResult: BlockParseResult = {
node: result.node,
newLineIndex: result.newLineIndex,
newNodeCount: nodeCount + 1,
};
if (result.extraContent) {
finalResult.extraNodes = [{type: NodeType.Text, content: result.extraContent}];
finalResult.newNodeCount = nodeCount + 2;
}
return finalResult;
}
const prefixText = line.slice(0, fencePosition);
if (hasOpenInlineCode(prefixText)) {
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
const inlineNodes = InlineParsers.parseInline(prefixText, parserFlags);
const codeLines = [line.slice(fencePosition), ...lines.slice(currentLineIndex + 1)];
const codeResult = parseCodeBlock(codeLines, 0);
const newLineIndex = currentLineIndex + codeResult.newLineIndex;
const extraNodes: Array<Node> = [];
if (inlineNodes.length > 1) {
extraNodes.push(...inlineNodes.slice(1));
}
extraNodes.push(codeResult.node);
if (codeResult.extraContent) {
extraNodes.push({type: NodeType.Text, content: codeResult.extraContent});
}
const firstNode = inlineNodes[0] ?? codeResult.node;
const newNodeCount = nodeCount + inlineNodes.length + 1 + (codeResult.extraContent ? 1 : 0);
return {
node: firstNode,
extraNodes: extraNodes.length > 0 ? extraNodes : undefined,
newLineIndex,
newNodeCount,
};
}
}
if (!(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) && cachedStartsWith(trimmed, '```')) {
let codeBlockText = lines[currentLineIndex];
let endLineIndex = currentLineIndex + 1;
while (endLineIndex < lines.length) {
const nextLine = lines[endLineIndex];
if (nextLine.trim() === '```') {
codeBlockText += `\n${nextLine}`;
endLineIndex++;
break;
}
codeBlockText += `\n${nextLine}`;
endLineIndex++;
}
return {
node: {type: NodeType.Text, content: codeBlockText} as TextNode,
newLineIndex: endLineIndex,
newNodeCount: nodeCount + 1,
};
}
if (cachedStartsWith(trimmed, '-#')) {
if (parserFlags & ParserFlags.ALLOW_SUBTEXT) {
const subtextNode = parseSubtext(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
if (subtextNode) {
const result = {
node: subtextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
}
const result = {
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
if (cachedStartsWith(trimmed, '#')) {
if (parserFlags & ParserFlags.ALLOW_HEADINGS) {
const headingNode = parseHeading(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
if (headingNode) {
const result = {
node: headingNode,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
return result;
}
}
// Not a heading, treat it as inline text so links/formatting still parse.
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
if (trimmed.includes('|') && parserFlags & ParserFlags.ALLOW_TABLES) {
const startIndex = currentLineIndex;
const tableResult = TableParsers.parseTable(lines, currentLineIndex, parserFlags, (text) =>
InlineParsers.parseInline(text, parserFlags),
);
if (tableResult.node) {
const result = {
node: tableResult.node,
newLineIndex: tableResult.newLineIndex,
newNodeCount: nodeCount + 1,
};
return result;
}
currentLineIndex = startIndex;
}
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
}
function handleLineAsText(lines: Array<string>, currentLineIndex: number): string {
const isLastLine = currentLineIndex === lines.length - 1;
return isLastLine ? lines[currentLineIndex] : `${lines[currentLineIndex]}\n`;
}
function parseBlockAsText(lines: Array<string>, currentLineIndex: number, marker: string): TextNode {
const originalContent = lines[currentLineIndex];
if (marker === '>' || marker === '>>> ') {
return {
type: NodeType.Text,
content: originalContent + (currentLineIndex < lines.length - 1 ? '\n' : ''),
};
}
return {
type: NodeType.Text,
content: originalContent,
};
}
const MAX_HEADING_LEVEL = 4;
export function parseHeading(trimmed: string, parseInline: (text: string) => Array<Node>): HeadingNode | null {
let level = 0;
for (let i = 0; i < trimmed.length && i < MAX_HEADING_LEVEL; i++) {
if (trimmed[i] === '#') level++;
else break;
}
if (level >= 1 && level <= MAX_HEADING_LEVEL && trimmed[level] === ' ') {
const content = trimmed.slice(level + 1);
const inlineNodes = parseInline(content);
const result: HeadingNode = {
type: NodeType.Heading,
level,
children: inlineNodes,
};
return result;
}
return null;
}
function parseSubtext(trimmed: string, parseInline: (text: string) => Array<Node>): SubtextNode | null {
if (trimmed.startsWith('-#')) {
if ((trimmed.length > 2 && trimmed[2] !== ' ') || (trimmed.length > 3 && trimmed[3] === ' ')) {
return null;
}
const content = trimmed.slice(3);
const inlineNodes = parseInline(content);
const result: SubtextNode = {
type: NodeType.Subtext,
children: inlineNodes,
};
return result;
}
return null;
}
function parseBlockquote(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
): BlockParseResult {
let blockquoteContent = '';
const startLine = currentLineIndex;
let newLineIndex = currentLineIndex;
while (newLineIndex < lines.length) {
if (nodeCount > MAX_AST_NODES) break;
const line = lines[newLineIndex];
const trimmed = line.trimStart();
if (trimmed === '> ' || trimmed === '> ') {
if (blockquoteContent.length > 0) blockquoteContent += '\n';
newLineIndex++;
} else if (trimmed.startsWith('> ')) {
const content = trimmed.slice(2);
if (blockquoteContent.length > 0) blockquoteContent += '\n';
blockquoteContent += content;
newLineIndex++;
} else {
break;
}
if (blockquoteContent.length > MAX_LINE_LENGTH * 100) break;
}
if (blockquoteContent === '' && newLineIndex === startLine) {
return {node: null, newLineIndex, newNodeCount: nodeCount};
}
if (parserFlags & ParserFlags.ALLOW_ALERTS) {
const alertNode = parseAlert(blockquoteContent, parserFlags);
if (alertNode) {
return {
node: alertNode,
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
}
const childFlags = parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES;
const childParser = new Parser(blockquoteContent, childFlags);
const {nodes: childNodes} = childParser.parse();
flattenChildren(childNodes, true);
return {
node: {
type: NodeType.Blockquote,
children: childNodes,
},
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
function parseMultilineBlockquote(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
nodeCount: number,
): BlockParseResult {
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (!trimmed.startsWith('>>> ')) {
return {
node: {type: NodeType.Text, content: ''},
newLineIndex: currentLineIndex,
newNodeCount: nodeCount,
};
}
let content = trimmed.slice(4);
let newLineIndex = currentLineIndex + 1;
while (newLineIndex < lines.length) {
const current = lines[newLineIndex];
content += `\n${current}`;
newLineIndex++;
if (content.length > MAX_LINE_LENGTH * 100) break;
}
const childFlags = (parserFlags & ~ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) | ParserFlags.ALLOW_BLOCKQUOTES;
const childParser = new Parser(content, childFlags);
const {nodes: childNodes} = childParser.parse();
return {
node: {
type: NodeType.Blockquote,
children: childNodes,
},
newLineIndex,
newNodeCount: nodeCount + 1,
};
}
export function parseCodeBlock(
lines: Array<string>,
currentLineIndex: number,
): {node: CodeBlockNode; newLineIndex: number; extraContent?: string} {
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
const indentSpaces = line.length - trimmed.length;
const listIndent = indentSpaces > 0 ? ' '.repeat(indentSpaces) : '';
let fenceLength = 0;
for (let i = 0; i < trimmed.length && trimmed[i] === '`'; i++) {
fenceLength++;
}
const languagePart = trimmed.slice(fenceLength);
const closingFence = '`'.repeat(fenceLength);
const closingFenceIndex = languagePart.indexOf(closingFence);
let language: string | undefined;
if (closingFenceIndex !== -1) {
const inlineContent = languagePart.slice(0, closingFenceIndex);
const trailingInline = languagePart.slice(closingFenceIndex + fenceLength);
return {
node: {
type: NodeType.CodeBlock,
language: undefined,
content: inlineContent,
},
newLineIndex: currentLineIndex + 1,
extraContent: trailingInline || undefined,
};
}
language = languagePart.trim() || undefined;
let newLineIndex = currentLineIndex + 1;
let tempIndex = newLineIndex;
let lineCount = 0;
while (tempIndex < lines.length) {
const trimmedLine = lines[tempIndex].trimStart();
if (trimmedLine.startsWith(closingFence)) {
let backtickCount = 0;
for (let i = 0; i < trimmedLine.length && trimmedLine[i] === '`'; i++) {
backtickCount++;
}
const charAfterBackticks = trimmedLine[backtickCount];
if (
backtickCount >= fenceLength &&
(!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`')
) {
break;
}
}
tempIndex++;
lineCount++;
if (lineCount > 1000) break;
}
const contentParts: Array<string> = [];
let contentLength = 0;
while (newLineIndex < lines.length) {
const current = lines[newLineIndex];
const trimmedLine = current.trimStart();
const fenceIndex = trimmedLine.indexOf(closingFence);
if (fenceIndex !== -1) {
let backtickCount = 0;
let idx = fenceIndex;
while (idx < trimmedLine.length && trimmedLine[idx] === '`') {
backtickCount++;
idx++;
}
const charAfterBackticks = trimmedLine[idx];
const onlyWhitespaceAfter =
!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`';
if (backtickCount >= fenceLength && onlyWhitespaceAfter) {
const contentPrefix = current.slice(0, current.indexOf(closingFence));
let contentLine = contentPrefix;
if (indentSpaces > 0 && contentPrefix.startsWith(listIndent)) {
contentLine = contentPrefix.slice(indentSpaces);
}
if (contentLine.length > 0) {
contentParts.push(contentLine);
contentParts.push('\n');
}
let extraContent: string | undefined;
const trailingText = trimmedLine.slice(idx);
if (trailingText) {
extraContent = trailingText;
} else if (backtickCount > fenceLength) {
extraContent = trimmedLine.slice(fenceLength);
}
newLineIndex++;
if (extraContent) {
return {
node: {
type: NodeType.CodeBlock,
language,
content: contentParts.join(''),
},
newLineIndex,
extraContent,
};
}
break;
}
}
let contentLine = current;
if (indentSpaces > 0 && current.startsWith(listIndent)) {
contentLine = current.slice(indentSpaces);
}
contentParts.push(contentLine);
contentParts.push('\n');
contentLength += contentLine.length + 1;
if (contentLength > MAX_LINE_LENGTH * 100) break;
newLineIndex++;
}
return {
node: {
type: NodeType.CodeBlock,
language,
content: contentParts.join(''),
},
newLineIndex,
};
}
function parseSpoiler(
lines: Array<string>,
currentLineIndex: number,
parserFlags: number,
): {node: SpoilerNode | TextNode; newLineIndex: number} {
const startLine = currentLineIndex;
let foundEnd = false;
let blockContent = '';
let newLineIndex = currentLineIndex;
while (newLineIndex < lines.length) {
const line = lines[newLineIndex];
if (newLineIndex === startLine) {
const startIdx = line.indexOf('||');
if (startIdx !== -1) {
blockContent += line.slice(startIdx + 2);
}
} else {
const endIdx = line.indexOf('||');
if (endIdx !== -1) {
blockContent += line.slice(0, endIdx);
foundEnd = true;
newLineIndex++;
break;
}
blockContent += line;
}
blockContent += '\n';
newLineIndex++;
if (blockContent.length > MAX_LINE_LENGTH * 10) break;
}
if (!foundEnd) {
return {
node: {
type: NodeType.Text,
content: `||${blockContent.trimEnd()}`,
},
newLineIndex,
};
}
const childParser = new Parser(blockContent.trim(), parserFlags);
const {nodes: innerNodes} = childParser.parse();
return {
node: {
type: NodeType.Spoiler,
children: innerNodes,
isBlock: true,
},
newLineIndex,
};
}
function parseAlert(blockquoteText: string, parserFlags: number): AlertNode | null {
const alertMatch = blockquoteText.match(ALERT_PATTERN);
if (!alertMatch) {
return null;
}
const alertTypeStr = alertMatch[1].toUpperCase();
let alertType: AlertType;
switch (alertTypeStr) {
case 'NOTE':
alertType = AlertType.Note;
break;
case 'TIP':
alertType = AlertType.Tip;
break;
case 'IMPORTANT':
alertType = AlertType.Important;
break;
case 'WARNING':
alertType = AlertType.Warning;
break;
case 'CAUTION':
alertType = AlertType.Caution;
break;
default:
return null;
}
const content = blockquoteText.slice(alertMatch[0].length);
const childFlags =
(parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES) | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
const lines = content.split('\n');
const processedLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith('-') || /^\d+\./.test(trimmed)) {
return line;
}
return trimmed;
});
const processedContent = processedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const childParser = new Parser(processedContent, childFlags);
const {nodes: childNodes} = childParser.parse();
const mergedNodes: Array<Node> = [];
let currentText = '';
for (const node of childNodes) {
if (node.type === NodeType.Text) {
if (currentText) {
currentText += node.content;
} else {
currentText = node.content;
}
} else {
if (currentText) {
mergedNodes.push({type: NodeType.Text, content: currentText});
currentText = '';
}
mergedNodes.push(node);
}
}
if (currentText) {
mergedNodes.push({type: NodeType.Text, content: currentText});
}
const finalNodes = postProcessAlertNodes(mergedNodes);
return {
type: NodeType.Alert,
alertType,
children: finalNodes,
};
}
function postProcessAlertNodes(nodes: Array<Node>): Array<Node> {
if (nodes.length <= 1) return nodes;
const result: Array<Node> = [];
let i = 0;
while (i < nodes.length) {
const node = nodes[i];
if (node.type === NodeType.Text && i + 1 < nodes.length) {
if (nodes[i + 1].type === NodeType.List) {
const trimmedContent = node.content.replace(/\s+$/, '\n');
if (trimmedContent) {
result.push({type: NodeType.Text, content: trimmedContent});
}
} else {
result.push(node);
}
} else if (node.type === NodeType.List && i + 1 < nodes.length) {
result.push(node);
const nextNode = nodes[i + 1];
if (nextNode.type === NodeType.Text) {
const content = nextNode.content.trim();
if (content) {
result.push({type: NodeType.Text, content: `\n${content}`});
i++;
}
}
} else {
result.push(node);
}
i++;
}
return result;
}

View File

@@ -0,0 +1,364 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {EmojiKind, NodeType} from '@fluxer/markdown_parser/src/types/Enums';
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
export interface UnicodeEmoji {
surrogates: string;
}
export interface EmojiProvider {
getSurrogateName(surrogate: string): string | null;
findEmojiByName(name: string): UnicodeEmoji | null;
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null;
}
export interface EmojiParserConfig {
emojiProvider?: EmojiProvider;
emojiRegex?: RegExp;
skinToneSurrogates?: ReadonlyArray<string>;
convertToCodePoints?: (emoji: string) => string;
}
const VALID_EMOJI_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
const CUSTOM_EMOJI_REGEX = /^<(a)?:([a-zA-Z0-9_-]+):(\d+)>/;
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
const TEXT_PRESENTATION_MAP: Record<string, string> = {
'™️': '™',
'©️': '©',
'®️': '®',
};
const NEEDS_VARIATION_SELECTOR_CACHE = new Map<number, boolean>();
const SPECIAL_SHORTCODES: Record<string, string> = {
tm: '™',
copyright: '©',
registered: '®',
};
const EMOJI_NAME_CACHE = new Map<string, string | null>();
const EMOJI_BY_NAME_CACHE = new Map<string, UnicodeEmoji | null>();
let globalEmojiConfig: EmojiParserConfig | null = null;
export function setEmojiParserConfig(config: EmojiParserConfig): void {
globalEmojiConfig = config;
}
export function getEmojiParserConfig(): EmojiParserConfig | null {
return globalEmojiConfig;
}
function needsVariationSelector(codePoint: number): boolean {
if (NEEDS_VARIATION_SELECTOR_CACHE.has(codePoint)) {
return NEEDS_VARIATION_SELECTOR_CACHE.get(codePoint)!;
}
const result =
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
(codePoint >= 0x2900 && codePoint <= 0x297f);
NEEDS_VARIATION_SELECTOR_CACHE.set(codePoint, result);
return result;
}
function removeVariationSelectors(text: string): string {
if (text.length < 2 || text.indexOf('\uFE0F') === -1) {
return text;
}
let result = '';
let i = 0;
while (i < text.length) {
if (text.charCodeAt(i) === 0x2122 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '™';
i += 2;
} else if (text.charCodeAt(i) === 0xa9 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '©';
i += 2;
} else if (text.charCodeAt(i) === 0xae && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
result += '®';
i += 2;
} else {
result += text.charAt(i);
i++;
}
}
return result;
}
function defaultConvertToCodePoints(emoji: string): string {
const containsZWJ = emoji.includes('\u200D');
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
return Array.from(processedEmoji)
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
.join('-');
}
export function parseStandardEmoji(text: string, start: number): ParserResult | null {
if (!globalEmojiConfig?.emojiProvider || !globalEmojiConfig?.emojiRegex) {
return null;
}
if (!text || start >= text.length || text.length - start < 1) {
return null;
}
const firstChar = text.charAt(start);
if (PLAINTEXT_SYMBOLS.has(firstChar)) {
return null;
}
const firstCharCode = text.charCodeAt(start);
if (
firstCharCode < 0x80 &&
firstCharCode !== 0x23 &&
firstCharCode !== 0x2a &&
firstCharCode !== 0x30 &&
firstCharCode !== 0x31 &&
(firstCharCode < 0x32 || firstCharCode > 0x39)
) {
return null;
}
const emojiRegex = globalEmojiConfig.emojiRegex;
emojiRegex.lastIndex = 0;
const match = emojiRegex.exec(text.slice(start));
if (match && match.index === 0) {
const candidate = match[0];
if (PLAINTEXT_SYMBOLS.has(candidate)) {
return null;
}
if (PLAINTEXT_SYMBOLS.has(candidate)) {
const textPresentation = TEXT_PRESENTATION_MAP[candidate];
if (textPresentation) {
return {
node: {type: NodeType.Text, content: textPresentation},
advance: candidate.length,
};
}
return {
node: {type: NodeType.Text, content: candidate},
advance: candidate.length,
};
}
const hasVariationSelector = candidate.indexOf('\uFE0F') !== -1;
const codePoint = candidate.codePointAt(0) || 0;
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) {
return null;
}
let name = EMOJI_NAME_CACHE.get(candidate);
if (name === undefined) {
name = globalEmojiConfig.emojiProvider.getSurrogateName(candidate);
EMOJI_NAME_CACHE.set(candidate, name);
}
if (!name) {
return null;
}
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
const codepoints = convertToCodePoints(candidate);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: candidate,
codepoints,
name,
},
},
advance: candidate.length,
};
}
return null;
}
const SKIN_TONE_SUFFIX_REGEX = /^:skin-tone-([1-5]):/;
export function parseEmojiShortcode(text: string): ParserResult | null {
if (!text.startsWith(':') || text.length < 3) {
return null;
}
const endPos = text.indexOf(':', 1);
if (endPos === -1 || endPos === 1) {
return null;
}
const baseName = text.substring(1, endPos);
const specialSymbol = SPECIAL_SHORTCODES[baseName];
if (specialSymbol) {
return {
node: {type: NodeType.Text, content: specialSymbol},
advance: endPos + 1,
};
}
if (!globalEmojiConfig?.emojiProvider) {
return null;
}
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
return null;
}
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
if (emoji === undefined) {
emoji = globalEmojiConfig.emojiProvider.findEmojiByName(baseName);
EMOJI_BY_NAME_CACHE.set(baseName, emoji);
}
if (!emoji) {
return null;
}
const emojiSurrogate = emoji.surrogates;
if (PLAINTEXT_SYMBOLS.has(emojiSurrogate)) {
const textPresentation = TEXT_PRESENTATION_MAP[emojiSurrogate];
if (textPresentation) {
return {
node: {type: NodeType.Text, content: textPresentation},
advance: endPos + 1,
};
}
return {
node: {type: NodeType.Text, content: emojiSurrogate},
advance: endPos + 1,
};
}
let finalEmoji = emoji;
let totalAdvance = endPos + 1;
const afterEmoji = text.slice(endPos + 1);
const skinToneMatch = SKIN_TONE_SUFFIX_REGEX.exec(afterEmoji);
if (skinToneMatch && globalEmojiConfig.skinToneSurrogates) {
const skinTone = Number.parseInt(skinToneMatch[1], 10);
const skinToneKey = `${baseName}:tone-${skinTone}`;
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
if (skinToneEmoji === undefined) {
const skinToneSurrogate = globalEmojiConfig.skinToneSurrogates[skinTone - 1];
skinToneEmoji = globalEmojiConfig.emojiProvider.findEmojiWithSkinTone(baseName, skinToneSurrogate);
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
}
if (skinToneEmoji) {
finalEmoji = skinToneEmoji;
totalAdvance += skinToneMatch[0].length;
}
}
if (!finalEmoji) {
return null;
}
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
const codepoints = convertToCodePoints(finalEmoji.surrogates);
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Standard,
raw: finalEmoji.surrogates,
codepoints,
name: baseName,
},
},
advance: totalAdvance,
};
}
export function parseCustomEmoji(text: string): ParserResult | null {
if (!(text.startsWith('<:') || text.startsWith('<a:'))) {
return null;
}
const lastIdx = text.indexOf('>');
if (lastIdx === -1 || lastIdx < 4) {
return null;
}
const match = CUSTOM_EMOJI_REGEX.exec(text);
if (!match) {
return null;
}
const animated = Boolean(match[1]);
const name = match[2];
const id = match[3];
const advance = match[0].length;
if (!name || !id || id.length === 0) {
return null;
}
for (let i = 0; i < id.length; i++) {
const charCode = id.charCodeAt(i);
if (charCode < 48 || charCode > 57) {
return null;
}
}
return {
node: {
type: NodeType.Emoji,
kind: {
kind: EmojiKind.Custom,
name,
id,
animated,
},
},
advance,
};
}
export function applyTextPresentation(node: Node): void {
if (node && node.type === NodeType.Text && typeof node.content === 'string') {
if (node.content.indexOf('\uFE0F') !== -1) {
node.content = removeVariationSelectors(node.content);
}
} else if (node && 'children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
applyTextPresentation(child);
}
}
}

View File

@@ -0,0 +1,856 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string, Array<Node>>();
const formattingMarkerCache = new Map<string, ReturnType<typeof getFormattingMarkerInfo>>();
const MAX_CACHE_SIZE = 500;
const cacheHitCount = new Map<string, number>();
export function parseInline(text: string, parserFlags: number): Array<Node> {
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<Node> {
if (!text) {
return [];
}
const nodes: Array<Node> = [];
let accumulatedText = '';
let position = 0;
const textLength = text.length;
let characters: Array<string> | 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('<id:') &&
parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS
) {
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;
}
}
}
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('<id:')) {
if (parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) {
const mentionResult = MentionParsers.parseMention(text, parserFlags);
if (mentionResult) return mentionResult;
}
} else if (nextCharCode === PLUS_SIGN && parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
const phoneResult = LinkParsers.parsePhoneLink(text, parserFlags);
if (phoneResult) return phoneResult;
} else if (
nextCharCode === LETTER_S &&
text.length > 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<Node, {children: Array<Node>}>;
type FormattingNodeType = NodeWithChildren['type'];
function createFormattingNode(
nodeType: NodeType,
innerContent: string,
marker: string,
isBlock: boolean,
parseInlineWithContext: (text: string, context: FormattingContext) => Array<Node>,
): 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;
}

View File

@@ -0,0 +1,513 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_LINK_URL_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils';
import * as URLUtils from '@fluxer/markdown_parser/src/utils/UrlUtils';
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const OPEN_BRACKET = 91;
const CLOSE_BRACKET = 93;
const OPEN_PAREN = 40;
const CLOSE_PAREN = 41;
const BACKSLASH = 92;
const LESS_THAN = 60;
const GREATER_THAN = 62;
const DOUBLE_QUOTE = 34;
const SINGLE_QUOTE = 39;
const PLUS_SIGN = 43;
function containsLinkSyntax(text: string): boolean {
const bracketIndex = text.indexOf('[');
if (bracketIndex === -1) return false;
const closeBracketIndex = text.indexOf(']', bracketIndex);
if (closeBracketIndex === -1) return false;
if (closeBracketIndex + 1 < text.length && text[closeBracketIndex + 1] === '(') {
return true;
}
return containsLinkSyntax(text.substring(closeBracketIndex + 1));
}
export function parseLink(
text: string,
_parserFlags: number,
parseInline: (text: string) => Array<Node>,
): ParserResult | null {
if (text.charCodeAt(0) !== OPEN_BRACKET) return null;
const linkParts = extractLinkParts(text);
if (!linkParts) {
if (SPOOFED_LINK_PATTERN.test(text)) {
return {
node: {type: NodeType.Text, content: text},
advance: text.length,
};
}
const bracketResult = findClosingBracket(text);
if (bracketResult) {
const {bracketPosition, linkText} = bracketResult;
if (containsLinkSyntax(linkText)) {
return {
node: {type: NodeType.Text, content: text},
advance: text.length,
};
}
return {
node: {type: NodeType.Text, content: text.slice(0, bracketPosition + 1)},
advance: bracketPosition + 1,
};
}
return null;
}
try {
const normalizedUrl = URLUtils.normalizeUrl(linkParts.url);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (linkParts.url.startsWith('/') && !linkParts.url.startsWith('//')) {
return {
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
advance: linkParts.advanceBy,
};
}
let finalUrl = normalizedUrl;
if (finalUrl.startsWith('tel:') || finalUrl.startsWith('sms:')) {
const protocol = finalUrl.substring(0, finalUrl.indexOf(':') + 1);
const phoneNumber = finalUrl.substring(finalUrl.indexOf(':') + 1);
if (phoneNumber.startsWith('+')) {
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
finalUrl = protocol + normalizedPhone;
}
} else {
finalUrl = URLUtils.convertToAsciiUrl(finalUrl);
}
const inlineNodes = parseInline(linkParts.linkText);
return {
node: {
type: NodeType.Link,
text: inlineNodes.length === 1 ? inlineNodes[0] : {type: NodeType.Sequence, children: inlineNodes},
url: finalUrl,
escaped: linkParts.isEscaped,
},
advance: linkParts.advanceBy,
};
}
} catch {
return {
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
advance: linkParts.advanceBy,
};
}
return null;
}
function extractLinkParts(text: string): {linkText: string; url: string; isEscaped: boolean; advanceBy: number} | null {
const bracketResult = findClosingBracket(text);
if (!bracketResult) return null;
const {bracketPosition, linkText} = bracketResult;
if (bracketPosition + 1 >= text.length || text.charCodeAt(bracketPosition + 1) !== OPEN_PAREN) return null;
const trimmedLinkText = linkText.trim();
if (containsLinkSyntax(trimmedLinkText)) {
return null;
}
const isEmailSpoofing = EMAIL_PATTERN.test(trimmedLinkText);
if (isEmailSpoofing) {
return null;
}
const urlInfo = extractUrl(text, bracketPosition + 2);
if (!urlInfo) return null;
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
return null;
}
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
if (isLinkTextUrlWithProtocol) {
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
return null;
}
}
return {
linkText,
...urlInfo,
};
}
function findClosingBracket(text: string): {bracketPosition: number; linkText: string} | null {
let position = 1;
let nestedBrackets = 0;
const textLength = text.length;
while (position < textLength) {
const currentChar = text.charCodeAt(position);
if (currentChar === OPEN_BRACKET) {
nestedBrackets++;
position++;
} else if (currentChar === CLOSE_BRACKET) {
if (nestedBrackets > 0) {
nestedBrackets--;
position++;
} else {
return {
bracketPosition: position,
linkText: text.slice(1, position),
};
}
} else if (currentChar === BACKSLASH && position + 1 < textLength) {
position += 2;
} else {
position++;
}
if (position > MAX_LINK_URL_LENGTH) break;
}
return null;
}
function extractUrl(text: string, startPos: number): {url: string; isEscaped: boolean; advanceBy: number} | null {
if (startPos >= text.length) return null;
return text.charCodeAt(startPos) === LESS_THAN
? extractEscapedUrl(text, startPos + 1)
: extractUnescapedUrl(text, startPos);
}
function extractEscapedUrl(
text: string,
urlStart: number,
): {url: string; isEscaped: boolean; advanceBy: number} | null {
const textLength = text.length;
let currentPos = urlStart;
while (currentPos < textLength) {
if (text.charCodeAt(currentPos) === GREATER_THAN) {
const url = text.slice(urlStart, currentPos);
currentPos++;
while (currentPos < textLength && text.charCodeAt(currentPos) !== CLOSE_PAREN) {
currentPos++;
}
return {
url,
isEscaped: true,
advanceBy: currentPos + 1,
};
}
currentPos++;
}
return null;
}
function extractUnescapedUrl(
text: string,
urlStart: number,
): {url: string; isEscaped: boolean; advanceBy: number} | null {
const textLength = text.length;
let currentPos = urlStart;
let nestedParens = 0;
while (currentPos < textLength) {
const currentChar = text.charCodeAt(currentPos);
if (currentChar === OPEN_PAREN) {
nestedParens++;
currentPos++;
} else if (currentChar === CLOSE_PAREN) {
if (nestedParens > 0) {
nestedParens--;
currentPos++;
} else {
const url = text.slice(urlStart, currentPos);
return {
url,
isEscaped: false,
advanceBy: currentPos + 1,
};
}
} else {
currentPos++;
}
}
return null;
}
export function extractUrlSegment(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
let prefixLength = 0;
if (text.startsWith('https://')) {
prefixLength = 8;
} else if (text.startsWith('http://')) {
prefixLength = 7;
} else {
return null;
}
let end = prefixLength;
const textLength = text.length;
let parenthesesDepth = 0;
while (end < textLength) {
const currentChar = text[end];
if (currentChar === '(') {
parenthesesDepth++;
end++;
} else if (currentChar === ')') {
if (parenthesesDepth > 0) {
parenthesesDepth--;
end++;
} else {
break;
}
} else if (StringUtils.isUrlTerminationChar(currentChar)) {
break;
} else {
end++;
}
if (end - prefixLength > MAX_LINK_URL_LENGTH) {
end = prefixLength + MAX_LINK_URL_LENGTH;
break;
}
}
let urlString = text.slice(0, end);
const punctuation = '.,;:!?';
while (
urlString.length > 0 &&
punctuation.includes(urlString[urlString.length - 1]) &&
!urlString.match(/\.[a-zA-Z]{2,}$/)
) {
urlString = urlString.slice(0, -1);
end--;
}
const isInQuotes =
text.charAt(0) === '"' ||
text.charAt(0) === "'" ||
(end < textLength && (text.charAt(end) === '"' || text.charAt(end) === "'"));
try {
const normalizedUrl = URLUtils.normalizeUrl(urlString);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
return null;
}
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
return {
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: isInQuotes},
advance: urlString.length,
};
}
} catch (_e) {}
return null;
}
export function parseAutolink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
if (text.length > 1 && (text.charCodeAt(1) === DOUBLE_QUOTE || text.charCodeAt(1) === SINGLE_QUOTE)) {
return null;
}
if (!StringUtils.startsWithUrl(text.slice(1))) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const urlString = text.slice(1, end);
if (urlString.length > MAX_LINK_URL_LENGTH) return null;
try {
const normalizedUrl = URLUtils.normalizeUrl(urlString);
const isValid = URLUtils.isValidUrl(normalizedUrl);
if (isValid) {
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
return null;
}
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
return {
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: true},
advance: end + 1,
};
}
} catch (_e) {}
return null;
}
export function parseEmailLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
if (content.startsWith('http://') || content.startsWith('https://')) return null;
if (content.charCodeAt(0) === PLUS_SIGN) return null;
if (content.indexOf('@') === -1) return null;
const isValid = URLUtils.isValidEmail(content);
if (isValid) {
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: content},
url: `mailto:${content}`,
escaped: true,
},
advance: end + 1,
};
}
return null;
}
export function parsePhoneLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
if (content.charCodeAt(0) !== PLUS_SIGN) return null;
const isValid = URLUtils.isValidPhoneNumber(content);
if (isValid) {
const normalizedPhone = URLUtils.normalizePhoneNumber(content);
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: content},
url: `tel:${normalizedPhone}`,
escaped: true,
},
advance: end + 1,
};
}
return null;
}
export function parseSmsLink(text: string, parserFlags: number): ParserResult | null {
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
if (text.charCodeAt(0) !== LESS_THAN) return null;
if (!text.startsWith('<sms:')) return null;
const end = text.indexOf('>', 1);
if (end === -1) return null;
const content = text.slice(1, end);
const phoneNumber = content.slice(4);
if (phoneNumber.charCodeAt(0) !== PLUS_SIGN || !URLUtils.isValidPhoneNumber(phoneNumber)) {
return null;
}
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
return {
node: {
type: NodeType.Link,
text: {type: NodeType.Text, content: phoneNumber},
url: `sms:${normalizedPhone}`,
escaped: true,
},
advance: end + 1,
};
}
function shouldTreatAsMaskedLink(trimmedLinkText: string, url: string): boolean {
const normalizedText = trimmedLinkText.trim();
try {
const normalizedUrl = URLUtils.normalizeUrl(url);
const urlObj = new URL(normalizedUrl);
const textUrl = new URL(normalizedText);
if (
urlObj.origin === textUrl.origin &&
urlObj.pathname === textUrl.pathname &&
urlObj.search === textUrl.search &&
urlObj.hash === textUrl.hash
) {
return false;
}
} catch {}
return true;
}

View File

@@ -0,0 +1,452 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {parseCodeBlock} from '@fluxer/markdown_parser/src/parsers/BlockParsers';
import {NodeType} from '@fluxer/markdown_parser/src/types/Enums';
import {MAX_AST_NODES} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
import type {ListItem, ListNode, Node} from '@fluxer/markdown_parser/src/types/Nodes';
interface ListParseResult {
node: ListNode;
newLineIndex: number;
newNodeCount: number;
}
export function parseList(
lines: Array<string>,
currentLineIndex: number,
isOrdered: boolean,
indentLevel: number,
depth: number,
parserFlags: number,
nodeCount: number,
parseInline: (text: string) => Array<Node>,
): ListParseResult {
const items: Array<ListItem> = [];
const startLine = currentLineIndex;
const firstLineContent = lines[startLine];
let newLineIndex = currentLineIndex;
let newNodeCount = nodeCount;
while (newLineIndex < lines.length) {
if (newNodeCount > MAX_AST_NODES) break;
const currentLine = lines[newLineIndex];
const trimmed = currentLine.trimStart();
if (isBlockBreak(trimmed)) break;
const listMatch = matchListItem(currentLine);
if (listMatch) {
const [itemOrdered, itemIndent, content, ordinal] = listMatch;
const normalisedOrdinal = getNormalisedOrdinal(items, ordinal, isOrdered);
if (itemIndent < indentLevel) break;
if (itemIndent === indentLevel) {
if (itemOrdered !== isOrdered) {
if (newLineIndex === startLine) {
const simpleList = createSimpleList(firstLineContent);
return {
node: simpleList,
newLineIndex: newLineIndex + 1,
newNodeCount: newNodeCount + 1,
};
}
break;
}
const result = handleSameIndentLevel(
items,
content,
indentLevel,
depth,
parseInline,
(parentIndent, depth) => {
const tryResult = tryParseNestedContent(
lines,
newLineIndex + 1,
parentIndent,
depth,
(isOrdered, indentLevel, depth) =>
parseList(
lines,
newLineIndex + 1,
isOrdered,
indentLevel,
depth,
parserFlags,
newNodeCount,
parseInline,
),
);
return tryResult;
},
newLineIndex,
normalisedOrdinal,
);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else if (itemIndent === indentLevel + 1) {
const result = handleNestedIndentLevel(
items,
currentLine,
itemOrdered,
itemIndent,
depth,
(isOrdered, indentLevel, depth) =>
parseList(lines, newLineIndex, isOrdered, indentLevel, depth, parserFlags, newNodeCount, parseInline),
newLineIndex,
newNodeCount,
);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else {
break;
}
} else if (isBulletPointText(currentLine)) {
const result = handleBulletPointText(items, currentLine, newLineIndex, newNodeCount);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else if (isListContinuation(currentLine, indentLevel)) {
const result = handleListContinuation(items, currentLine, newLineIndex, newNodeCount, parseInline);
newLineIndex = result.newLineIndex;
newNodeCount = result.newNodeCount;
} else {
break;
}
if (items.length > MAX_AST_NODES) break;
}
if (items.length === 0 && newLineIndex === startLine) {
const simpleList = createSimpleList(firstLineContent);
return {
node: simpleList,
newLineIndex: newLineIndex + 1,
newNodeCount: newNodeCount + 1,
};
}
return {
node: {
type: NodeType.List,
ordered: isOrdered,
items,
},
newLineIndex,
newNodeCount,
};
}
function isBlockBreak(trimmed: string): boolean {
return trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('>>> ');
}
function createSimpleList(content: string): ListNode {
return {
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content}]}],
};
}
function handleSameIndentLevel(
items: Array<ListItem>,
content: string,
indentLevel: number,
depth: number,
parseInline: (text: string) => Array<Node>,
tryParseNestedContent: (parentIndent: number, depth: number) => {node: Node | null; newLineIndex: number},
currentLineIndex: number,
ordinal?: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
const itemNodes: Array<Node> = [];
let newNodeCount = 0;
let newLineIndex = currentLineIndex + 1;
const contentListMatch = matchListItem(content);
if (contentListMatch) {
const nestedContent = tryParseNestedContent(indentLevel, depth);
const [isInlineOrdered, _, inlineItemContent] = contentListMatch;
const inlineItemNodes = parseInline(inlineItemContent);
const nestedListItems: Array<ListItem> = [{children: inlineItemNodes}];
if (nestedContent.node && nestedContent.node.type === NodeType.List) {
const nestedList = nestedContent.node as ListNode;
nestedListItems.push(...nestedList.items);
newLineIndex = nestedContent.newLineIndex;
}
const nestedList: ListNode = {
type: NodeType.List,
ordered: isInlineOrdered,
items: nestedListItems,
};
itemNodes.push(nestedList);
newNodeCount++;
} else {
const parsedNodes = parseInline(content);
itemNodes.push(...parsedNodes);
newNodeCount = itemNodes.length;
const nestedContent = tryParseNestedContent(indentLevel, depth);
if (nestedContent.node) {
itemNodes.push(nestedContent.node);
newNodeCount++;
newLineIndex = nestedContent.newLineIndex;
}
}
items.push({
children: itemNodes,
...(ordinal !== undefined ? {ordinal} : {}),
});
return {
newItems: items,
newLineIndex,
newNodeCount,
};
}
function handleNestedIndentLevel(
items: Array<ListItem>,
currentLine: string,
isOrdered: boolean,
indentLevel: number,
depth: number,
parseList: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
currentLineIndex: number,
nodeCount: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (depth >= 9) {
if (items.length > 0) {
items[items.length - 1].children.push({
type: NodeType.Text,
content: currentLine.trim(),
});
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
const nested = parseList(isOrdered, indentLevel, depth + 1);
if (items.length > 0) {
items[items.length - 1].children.push(nested.node);
}
return {
newItems: items,
newLineIndex: nested.newLineIndex,
newNodeCount: nested.newNodeCount,
};
}
function handleBulletPointText(
items: Array<ListItem>,
currentLine: string,
currentLineIndex: number,
nodeCount: number,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (items.length > 0) {
items[items.length - 1].children.push({
type: NodeType.Text,
content: currentLine.trim(),
});
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + 1,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
function handleListContinuation(
items: Array<ListItem>,
currentLine: string,
currentLineIndex: number,
nodeCount: number,
parseInline: (text: string) => Array<Node>,
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
if (items.length > 0) {
const content = currentLine.trimStart();
const parsedNodes = parseInline(content);
items[items.length - 1].children.push(...parsedNodes);
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount + parsedNodes.length,
};
}
return {
newItems: items,
newLineIndex: currentLineIndex + 1,
newNodeCount: nodeCount,
};
}
function tryParseNestedContent(
lines: Array<string>,
currentLineIndex: number,
parentIndent: number,
depth: number,
parseListFactory: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
): {node: Node | null; newLineIndex: number} {
if (currentLineIndex >= lines.length) return {node: null, newLineIndex: currentLineIndex};
const line = lines[currentLineIndex];
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
const result = parseCodeBlock(lines, currentLineIndex);
return {
node: result.node,
newLineIndex: result.newLineIndex,
};
}
const listMatch = matchListItem(line);
if (listMatch) {
const [isOrdered, indent, _] = listMatch;
if (indent > parentIndent && depth < 9) {
const result = parseListFactory(isOrdered, indent, depth + 1);
return {
node: result.node,
newLineIndex: result.newLineIndex,
};
}
}
return {node: null, newLineIndex: currentLineIndex};
}
function isListContinuation(line: string, indentLevel: number): boolean {
let spaceCount = 0;
for (let i = 0; i < line.length; i++) {
if (line[i] === ' ') spaceCount++;
else break;
}
return spaceCount > indentLevel * 2;
}
function isBulletPointText(text: string): boolean {
const listMatch = matchListItem(text);
if (listMatch) return false;
const trimmed = text.trimStart();
return trimmed.startsWith('- ') && !text.startsWith(' ');
}
function getNormalisedOrdinal(
items: Array<ListItem>,
ordinal: number | undefined,
isOrdered: boolean,
): number | undefined {
if (!isOrdered) return undefined;
if (items.length === 0) return ordinal ?? 1;
const startOrdinal = items[0]?.ordinal ?? ordinal ?? 1;
return startOrdinal + items.length;
}
export function matchListItem(line: string): [boolean, number, string, number?] | null {
let indent = 0;
let pos = 0;
while (pos < line.length && line[pos] === ' ') {
indent++;
pos++;
}
if (indent > 0 && indent < 2) return null;
const indentLevel = Math.floor(indent / 2);
if (pos >= line.length) return null;
const marker = line[pos];
if (marker === '*' || marker === '-') {
return handleUnorderedListMarker(line, pos, indentLevel);
}
if (/[0-9]/.test(marker)) {
return handleOrderedListMarker(line, pos, indentLevel);
}
return null;
}
function handleUnorderedListMarker(
line: string,
pos: number,
indentLevel: number,
): [boolean, number, string, undefined] | null {
if (line[pos + 1] === ' ') {
return [false, indentLevel, line.slice(pos + 2), undefined];
}
return null;
}
function handleOrderedListMarker(
line: string,
pos: number,
indentLevel: number,
): [boolean, number, string, number] | null {
let currentPos = pos;
let ordinalStr = '';
while (currentPos < line.length && /[0-9]/.test(line[currentPos])) {
ordinalStr += line[currentPos];
currentPos++;
}
if (line[currentPos] === '.' && line[currentPos + 1] === ' ') {
const ordinal = Number.parseInt(ordinalStr, 10);
return [true, indentLevel, line.slice(currentPos + 2), ordinal];
}
return null;
}

View File

@@ -0,0 +1,206 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
import type {MentionNode, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
const LESS_THAN = 60;
const AT_SIGN = 64;
const HASH = 35;
const AMPERSAND = 38;
const SLASH = 47;
const LETTER_I = 105;
const LETTER_D = 100;
const COLON = 58;
const DIGIT_ZERO = 48;
const DIGIT_NINE = 57;
export function parseMention(text: string, parserFlags: number): ParserResult | null {
if (text.length < 2 || text.charCodeAt(0) !== LESS_THAN) {
return null;
}
const end = text.indexOf('>');
if (end === -1) {
return null;
}
const secondCharCode = text.charCodeAt(1);
let mentionNode: MentionNode | null = null;
if (secondCharCode === AT_SIGN) {
mentionNode = parseUserOrRoleMention(text.slice(1, end), parserFlags);
} else if (secondCharCode === HASH) {
mentionNode = parseChannelMention(text.slice(1, end), parserFlags);
} else if (secondCharCode === SLASH) {
mentionNode = parseCommandMention(text.slice(1, end), parserFlags);
} else if (
secondCharCode === LETTER_I &&
text.length > 3 &&
text.charCodeAt(2) === LETTER_D &&
text.charCodeAt(3) === COLON
) {
mentionNode = parseGuildNavigation(text.slice(1, end), parserFlags);
}
return mentionNode ? {node: mentionNode, advance: end + 1} : null;
}
function isDigitOnly(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < DIGIT_ZERO || charCode > DIGIT_NINE) {
return false;
}
}
return text.length > 0;
}
function parseUserOrRoleMention(inner: string, parserFlags: number): MentionNode | null {
if (inner.length < 2 || inner.charCodeAt(0) !== AT_SIGN) {
return null;
}
if (inner.length > 2 && inner.charCodeAt(1) === AMPERSAND) {
const roleId = inner.slice(2);
if (isDigitOnly(roleId) && parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.Role, id: roleId},
};
}
} else {
const userId = inner.startsWith('@!') ? inner.slice(2) : inner.slice(1);
if (isDigitOnly(userId) && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.User, id: userId},
};
}
}
return null;
}
function parseChannelMention(inner: string, parserFlags: number): MentionNode | null {
if (inner.length < 2 || inner.charCodeAt(0) !== HASH || !(parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS)) {
return null;
}
const channelId = inner.slice(1);
if (isDigitOnly(channelId)) {
return {
type: NodeType.Mention,
kind: {kind: MentionKind.Channel, id: channelId},
};
}
return null;
}
function parseCommandMention(inner: string, parserFlags: number): MentionNode | null {
if (!(parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) || inner.length < 2 || inner.charCodeAt(0) !== SLASH) {
return null;
}
const colonIndex = inner.indexOf(':');
if (colonIndex === -1) return null;
const commandPart = inner.slice(0, colonIndex);
const idPart = inner.slice(colonIndex + 1);
if (!idPart || !isDigitOnly(idPart)) return null;
const segments = commandPart.slice(1).trim().split(' ');
if (segments.length === 0) return null;
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.Command,
name: segments[0],
subcommandGroup: segments.length === 3 ? segments[1] : undefined,
subcommand: segments.length >= 2 ? segments[segments.length - 1] : undefined,
id: idPart,
},
};
}
function parseGuildNavigation(inner: string, parserFlags: number): MentionNode | null {
if (!(parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) || inner.length < 5) {
return null;
}
if (inner.charCodeAt(0) !== LETTER_I || inner.charCodeAt(1) !== LETTER_D || inner.charCodeAt(2) !== COLON) {
return null;
}
const parts = inner.split(':');
if (parts.length < 2 || parts.length > 3) return null;
const [idLabel, navType, navId] = parts;
if (idLabel !== 'id') return null;
const navigationType = getNavigationType(navType);
if (!navigationType) return null;
if (navigationType === GuildNavKind.LinkedRoles) {
return createLinkedRolesNavigation(parts.length === 3 ? navId : undefined);
}
if (parts.length !== 2) return null;
return createBasicNavigation(navigationType);
}
function getNavigationType(navTypeLower: string): GuildNavKind | null {
switch (navTypeLower) {
case 'customize':
return GuildNavKind.Customize;
case 'browse':
return GuildNavKind.Browse;
case 'guide':
return GuildNavKind.Guide;
case 'linked-roles':
return GuildNavKind.LinkedRoles;
default:
return null;
}
}
function createLinkedRolesNavigation(id?: string): MentionNode {
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.GuildNavigation,
navigationType: GuildNavKind.LinkedRoles,
id,
},
};
}
function createBasicNavigation(navigationType: GuildNavKind): MentionNode {
return {
type: NodeType.Mention,
kind: {
kind: MentionKind.GuildNavigation,
navigationType,
},
};
}

View File

@@ -0,0 +1,329 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {NodeType, TableAlignment} from '@fluxer/markdown_parser/src/types/Enums';
import type {Node, TableCellNode, TableNode, TableRowNode} from '@fluxer/markdown_parser/src/types/Nodes';
interface TableParseResult {
node: TableNode | null;
newLineIndex: number;
}
const PIPE = 124;
const SPACE = 32;
const BACKSLASH = 92;
const DASH = 45;
const COLON = 58;
const HASH = 35;
const GREATER_THAN = 62;
const ASTERISK = 42;
const DIGIT_0 = 48;
const DIGIT_9 = 57;
const PERIOD = 46;
const MAX_CACHE_SIZE = 1000;
const inlineContentCache = new Map<string, Array<Node>>();
export function parseTable(
lines: Array<string>,
currentLineIndex: number,
_parserFlags: number,
parseInline: (text: string) => Array<Node>,
): TableParseResult {
const startIndex = currentLineIndex;
if (startIndex + 2 >= lines.length) {
return {node: null, newLineIndex: currentLineIndex};
}
const headerLine = lines[currentLineIndex];
const alignmentLine = lines[currentLineIndex + 1];
if (!containsPipe(headerLine) || !containsPipe(alignmentLine)) {
return {node: null, newLineIndex: currentLineIndex};
}
try {
const headerCells = fastSplitTableCells(headerLine.trim());
if (headerCells.length === 0 || !hasContent(headerCells)) {
return {node: null, newLineIndex: currentLineIndex};
}
const headerRow = createTableRow(headerCells, parseInline);
const columnCount = headerRow.cells.length;
currentLineIndex++;
const alignmentCells = fastSplitTableCells(alignmentLine.trim());
if (!validateAlignmentRow(alignmentCells)) {
return {node: null, newLineIndex: startIndex};
}
const alignments = parseAlignments(alignmentCells);
if (!alignments || headerRow.cells.length !== alignments.length) {
return {node: null, newLineIndex: startIndex};
}
currentLineIndex++;
const rows: Array<TableRowNode> = [];
while (currentLineIndex < lines.length) {
const line = lines[currentLineIndex];
if (!containsPipe(line)) break;
const trimmed = line.trim();
if (isBlockBreakFast(trimmed)) break;
const cellContents = fastSplitTableCells(trimmed);
if (cellContents.length !== columnCount) {
normalizeColumnCount(cellContents, columnCount);
}
const row = createTableRow(cellContents, parseInline);
rows.push(row);
currentLineIndex++;
}
if (rows.length === 0) {
return {node: null, newLineIndex: startIndex};
}
let hasAnyContent = hasRowContent(headerRow);
if (!hasAnyContent) {
for (const row of rows) {
if (hasRowContent(row)) {
hasAnyContent = true;
break;
}
}
}
if (!hasAnyContent) {
return {node: null, newLineIndex: startIndex};
}
if (inlineContentCache.size > MAX_CACHE_SIZE) {
inlineContentCache.clear();
}
return {
node: {
type: NodeType.Table,
header: headerRow,
alignments: alignments,
rows,
},
newLineIndex: currentLineIndex,
};
} catch (_err) {
return {node: null, newLineIndex: startIndex};
}
}
function containsPipe(text: string): boolean {
return text.indexOf('|') !== -1;
}
function hasContent(cells: Array<string>): boolean {
for (const cell of cells) {
if (cell.trim().length > 0) {
return true;
}
}
return false;
}
function hasRowContent(row: TableRowNode): boolean {
for (const cell of row.cells) {
if (
cell.children.length > 0 &&
!(cell.children.length === 1 && cell.children[0].type === NodeType.Text && cell.children[0].content.trim() === '')
) {
return true;
}
}
return false;
}
function validateAlignmentRow(cells: Array<string>): boolean {
if (cells.length === 0) return false;
for (const cell of cells) {
const trimmed = cell.trim();
if (trimmed.length === 0 || trimmed.indexOf('-') === -1) {
return false;
}
for (let i = 0; i < trimmed.length; i++) {
const charCode = trimmed.charCodeAt(i);
if (charCode !== SPACE && charCode !== COLON && charCode !== DASH && charCode !== PIPE) {
return false;
}
}
}
return true;
}
function fastSplitTableCells(line: string): Array<string> {
let start = 0;
let end = line.length;
if (line.length > 0 && line.charCodeAt(0) === PIPE) {
start = 1;
}
if (line.length > 0 && end > start && line.charCodeAt(end - 1) === PIPE) {
end--;
}
if (start >= end) {
return [];
}
const content = line.substring(start, end);
const cells: Array<string> = [];
let currentCell = '';
let i = 0;
while (i < content.length) {
if (content.charCodeAt(i) === BACKSLASH && i + 1 < content.length && content.charCodeAt(i + 1) === PIPE) {
currentCell += '|';
i += 2;
continue;
}
if (content.charCodeAt(i) === PIPE) {
cells.push(currentCell);
currentCell = '';
i++;
continue;
}
currentCell += content[i];
i++;
}
cells.push(currentCell);
return cells;
}
function parseAlignments(cells: Array<string>): Array<TableAlignment> | null {
if (cells.length === 0) return null;
const alignments: Array<TableAlignment> = [];
for (const cell of cells) {
const trimmed = cell.trim();
if (!trimmed || trimmed.indexOf('-') === -1) return null;
const left = trimmed.charCodeAt(0) === COLON;
const right = trimmed.charCodeAt(trimmed.length - 1) === COLON;
if (left && right) {
alignments.push(TableAlignment.Center);
} else if (left) {
alignments.push(TableAlignment.Left);
} else if (right) {
alignments.push(TableAlignment.Right);
} else {
alignments.push(TableAlignment.None);
}
}
return alignments;
}
function createTableRow(cellContents: Array<string>, parseInline: (text: string) => Array<Node>): TableRowNode {
const cells: Array<TableCellNode> = [];
for (const cellContent of cellContents) {
const trimmed = cellContent.trim();
let inlineNodes: Array<Node>;
if (inlineContentCache.has(trimmed)) {
inlineNodes = inlineContentCache.get(trimmed)!;
} else {
inlineNodes = parseInline(trimmed);
inlineContentCache.set(trimmed, inlineNodes);
}
cells.push({
type: NodeType.TableCell,
children: inlineNodes.length > 0 ? inlineNodes : [{type: NodeType.Text, content: trimmed}],
});
}
return {type: NodeType.TableRow, cells};
}
function normalizeColumnCount(cells: Array<string>, expectedColumns: number): void {
if (cells.length > expectedColumns) {
const lastCellIndex = expectedColumns - 1;
cells[lastCellIndex] = `${cells[lastCellIndex]}|${cells.slice(expectedColumns).join('|')}`;
cells.length = expectedColumns;
} else {
while (cells.length < expectedColumns) {
cells.push('');
}
}
}
function isBlockBreakFast(text: string): boolean {
if (!text || text.length === 0) return false;
const firstChar = text.charCodeAt(0);
if (firstChar === HASH || firstChar === GREATER_THAN || firstChar === DASH || firstChar === ASTERISK) {
return true;
}
if (
text.length >= 4 &&
text.charCodeAt(0) === GREATER_THAN &&
text.charCodeAt(1) === GREATER_THAN &&
text.charCodeAt(2) === GREATER_THAN &&
text.charCodeAt(3) === SPACE
) {
return true;
}
if (text.length >= 2 && text.charCodeAt(0) === DASH && text.charCodeAt(1) === HASH) {
return true;
}
if (firstChar >= DIGIT_0 && firstChar <= DIGIT_9) {
for (let i = 1; i < Math.min(text.length, 4); i++) {
if (text.charCodeAt(i) === PERIOD) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,113 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {NodeType, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
import type {ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
const LESS_THAN = 60;
const LETTER_T = 116;
const COLON = 58;
export function parseTimestamp(text: string): ParserResult | null {
if (
text.length < 4 ||
text.charCodeAt(0) !== LESS_THAN ||
text.charCodeAt(1) !== LETTER_T ||
text.charCodeAt(2) !== COLON
) {
return null;
}
const end = text.indexOf('>');
if (end === -1) {
return null;
}
const inner = text.slice(3, end);
const allParts = inner.split(':');
if (allParts.length > 2) {
return null;
}
const [timestampPart, stylePart] = allParts;
if (!/^\d+$/.test(timestampPart)) {
return null;
}
const timestamp = Number(timestampPart);
if (timestamp === 0) {
return null;
}
let style: TimestampStyle;
if (stylePart !== undefined) {
if (stylePart === '') {
return null;
}
const styleChar = stylePart[0];
const parsedStyle = getTimestampStyle(styleChar);
if (!parsedStyle) {
return null;
}
style = parsedStyle;
} else {
style = TimestampStyle.ShortDateTime;
}
return {
node: {
type: NodeType.Timestamp,
timestamp,
style,
},
advance: end + 1,
};
}
function getTimestampStyle(char: string): TimestampStyle | null {
switch (char) {
case 't':
return TimestampStyle.ShortTime;
case 'T':
return TimestampStyle.LongTime;
case 'd':
return TimestampStyle.ShortDate;
case 'D':
return TimestampStyle.LongDate;
case 'f':
return TimestampStyle.ShortDateTime;
case 'F':
return TimestampStyle.LongDateTime;
case 's':
return TimestampStyle.ShortDateShortTime;
case 'S':
return TimestampStyle.ShortDateMediumTime;
case 'R':
return TimestampStyle.RelativeTime;
default:
return null;
}
}