- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
857 lines
24 KiB
TypeScript
857 lines
24 KiB
TypeScript
/*
|
|
* 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;
|
|
}
|