initial commit
This commit is contained in:
21
fluxer_app/src/lib/markdown/config.ts
Normal file
21
fluxer_app/src/lib/markdown/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export const TABLE_PARSING_ENABLED = false;
|
||||
export const TABLE_PARSING_FLAG = 0;
|
||||
90
fluxer_app/src/lib/markdown/index.tsx
Normal file
90
fluxer_app/src/lib/markdown/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {Parser} from './parser/parser/parser';
|
||||
import {
|
||||
getParserFlagsForContext,
|
||||
MarkdownContext,
|
||||
type MarkdownParseOptions,
|
||||
render,
|
||||
wrapRenderedContent,
|
||||
} from './renderers';
|
||||
|
||||
const MarkdownErrorBoundary = class MarkdownErrorBoundary extends React.Component<
|
||||
{children: React.ReactNode},
|
||||
{hasError: boolean; error: Error | null}
|
||||
> {
|
||||
constructor(props: {children: React.ReactNode}) {
|
||||
super(props);
|
||||
this.state = {hasError: false, error: null};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return {hasError: true, error};
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('Error rendering markdown:', error, info);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<span className={markupStyles.error}>
|
||||
<Trans>Error rendering content</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
};
|
||||
|
||||
function parseMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
|
||||
): React.ReactNode {
|
||||
try {
|
||||
const flags = getParserFlagsForContext(options.context);
|
||||
|
||||
const parser = new Parser(content, flags);
|
||||
const {nodes} = parser.parse();
|
||||
|
||||
const renderedContent = render(nodes, options);
|
||||
|
||||
return wrapRenderedContent(renderedContent, options.context);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing markdown (${options.context}):`, error);
|
||||
return <span>{content}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export const SafeMarkdown = observer(function SafeMarkdown({
|
||||
content,
|
||||
options = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
|
||||
}: {
|
||||
content: string;
|
||||
options?: MarkdownParseOptions;
|
||||
}): React.ReactElement {
|
||||
return <MarkdownErrorBoundary>{parseMarkdown(content, options)}</MarkdownErrorBoundary>;
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 {beforeEach, describe, expect, test} from 'vitest';
|
||||
import {FormattingContext} from './formatting-context';
|
||||
|
||||
describe('FormattingContext', () => {
|
||||
let context: FormattingContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new FormattingContext();
|
||||
});
|
||||
|
||||
describe('pushFormatting and popFormatting', () => {
|
||||
test('should push and pop formatting markers correctly', () => {
|
||||
context.pushFormatting('*', true);
|
||||
context.pushFormatting('_', false);
|
||||
|
||||
expect(context.popFormatting()).toEqual(['_', false]);
|
||||
expect(context.popFormatting()).toEqual(['*', true]);
|
||||
});
|
||||
|
||||
test('should handle empty stack when popping', () => {
|
||||
expect(context.popFormatting()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should update active formatting types when pushing', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
});
|
||||
|
||||
test('should remove active formatting types when popping', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', true)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle multiple formatting markers of same type', () => {
|
||||
context.pushFormatting('*', true);
|
||||
context.pushFormatting('*', false);
|
||||
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFormattingActive', () => {
|
||||
test('should detect active formatting correctly', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
expect(context.isFormattingActive('_', true)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle different marker and emphasis combinations', () => {
|
||||
context.pushFormatting('_', true);
|
||||
context.pushFormatting('*', false);
|
||||
context.pushFormatting('~', true);
|
||||
|
||||
expect(context.isFormattingActive('_', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(true);
|
||||
expect(context.isFormattingActive('~', true)).toBe(true);
|
||||
expect(context.isFormattingActive('_', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canEnterFormatting', () => {
|
||||
test('should allow formatting when not active', () => {
|
||||
expect(context.canEnterFormatting('*', true)).toBe(true);
|
||||
expect(context.canEnterFormatting('_', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('should prevent entering active formatting', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.canEnterFormatting('*', true)).toBe(false);
|
||||
expect(context.canEnterFormatting('*', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle underscore special cases', () => {
|
||||
context.setCurrentText('test_with_underscores');
|
||||
context.pushFormatting('_', false);
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentText', () => {
|
||||
test('should detect underscores in text', () => {
|
||||
context.setCurrentText('test_with_underscores');
|
||||
context.pushFormatting('_', false);
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle text without underscores', () => {
|
||||
context.setCurrentText('test without underscores');
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 {MAX_INLINE_DEPTH} from '../types/constants';
|
||||
|
||||
export class FormattingContext {
|
||||
private readonly activeFormattingTypes = new Map<string, boolean>();
|
||||
private readonly formattingStack: Array<[string, boolean]> = [];
|
||||
private currentDepth = 0;
|
||||
|
||||
canEnterFormatting(delimiter: string, isDouble: boolean): boolean {
|
||||
const key = this.getFormattingKey(delimiter, isDouble);
|
||||
if (this.activeFormattingTypes.has(key)) return false;
|
||||
return this.currentDepth < MAX_INLINE_DEPTH;
|
||||
}
|
||||
|
||||
isFormattingActive(delimiter: string, isDouble: boolean): boolean {
|
||||
return this.activeFormattingTypes.has(this.getFormattingKey(delimiter, isDouble));
|
||||
}
|
||||
|
||||
pushFormatting(delimiter: string, isDouble: boolean): void {
|
||||
this.formattingStack.push([delimiter, isDouble]);
|
||||
this.activeFormattingTypes.set(this.getFormattingKey(delimiter, isDouble), true);
|
||||
this.currentDepth++;
|
||||
}
|
||||
|
||||
popFormatting(): [string, boolean] | undefined {
|
||||
const removed = this.formattingStack.pop();
|
||||
if (removed) {
|
||||
this.activeFormattingTypes.delete(this.getFormattingKey(removed[0], removed[1]));
|
||||
this.currentDepth--;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
setCurrentText(_text: string): void {}
|
||||
|
||||
private getFormattingKey(delimiter: string, isDouble: boolean): string {
|
||||
return `${delimiter}${isDouble ? '2' : '1'}`;
|
||||
}
|
||||
}
|
||||
185
fluxer_app/src/lib/markdown/parser/parser/parser.test.ts
Normal file
185
fluxer_app/src/lib/markdown/parser/parser/parser.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
import {Parser} from './parser';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('empty input', () => {
|
||||
const input = '';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
expect(ast).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('multiple consecutive newlines', () => {
|
||||
const input = 'First paragraph.\n\n\n\nSecond paragraph.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First paragraph.\n'},
|
||||
{type: NodeType.Text, content: '\n\n\n'},
|
||||
{type: NodeType.Text, content: 'Second paragraph.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multiple newlines between blocks', () => {
|
||||
const input = '# Heading\n\n\n\nParagraph.';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Text, content: 'Paragraph.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserve consecutive newlines between paragraphs', () => {
|
||||
const input = 'First paragraph\n\n\n\nSecond paragraph';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First paragraph\n'},
|
||||
{type: NodeType.Text, content: '\n\n\n'},
|
||||
{type: NodeType.Text, content: 'Second paragraph'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('flags disabling spoilers', () => {
|
||||
const input = 'This is a ||secret|| message';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This is a ||secret|| message'}]);
|
||||
});
|
||||
|
||||
test('flags disabling headings', () => {
|
||||
const input = '# Heading 1';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '# Heading 1'}]);
|
||||
});
|
||||
|
||||
test('flags disabling code blocks', () => {
|
||||
const input = '```rust\nfn main() {}\n```';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '```rust\nfn main() {}\n```'}]);
|
||||
});
|
||||
|
||||
test('flags disabling custom links', () => {
|
||||
const input = '[Rust](https://www.rust-lang.org)';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '[Rust](https://www.rust-lang.org)'}]);
|
||||
});
|
||||
|
||||
test('flags disabling command mentions', () => {
|
||||
const input = 'Use </airhorn:816437322781949972>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Use </airhorn:816437322781949972>'}]);
|
||||
});
|
||||
|
||||
test('flags disabling guild navigations', () => {
|
||||
const input = 'Go to <id:customize> now!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Go to <id:customize> now!'}]);
|
||||
});
|
||||
|
||||
test('flags partial', () => {
|
||||
const input = '# Heading\nThis is a ||secret|| message with a [link](https://example.com).';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Text, content: 'This is a '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
|
||||
{type: NodeType.Text, content: ' message with a [link](https://example.com).'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('flags all enabled', () => {
|
||||
const input = '# Heading\n||Spoiler|| with a [link](https://example.com) and a </command:12345>.';
|
||||
const flags =
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_SPOILERS |
|
||||
ParserFlags.ALLOW_MASKED_LINKS |
|
||||
ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'Spoiler'}]},
|
||||
{type: NodeType.Text, content: ' with a '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'link'},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: ' and a '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'command',
|
||||
subcommandGroup: undefined,
|
||||
subcommand: undefined,
|
||||
id: '12345',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
195
fluxer_app/src/lib/markdown/parser/parser/parser.ts
Normal file
195
fluxer_app/src/lib/markdown/parser/parser/parser.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 * as BlockParsers from '../parsers/block-parsers';
|
||||
import {applyTextPresentation} from '../parsers/emoji-parsers';
|
||||
import * as InlineParsers from '../parsers/inline-parsers';
|
||||
import * as ListParsers from '../parsers/list-parsers';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH, MAX_LINES} from '../types/constants';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node} from '../types/nodes';
|
||||
import * as ASTUtils from '../utils/ast-utils';
|
||||
|
||||
export class Parser {
|
||||
private readonly lines: Array<string>;
|
||||
private currentLineIndex: number;
|
||||
private readonly totalLineCount: number;
|
||||
private readonly parserFlags: number;
|
||||
private nodeCount: number;
|
||||
|
||||
constructor(input: string, flags: number) {
|
||||
if (!input || input === '') {
|
||||
this.lines = [];
|
||||
this.currentLineIndex = 0;
|
||||
this.totalLineCount = 0;
|
||||
this.parserFlags = flags;
|
||||
this.nodeCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = input.split('\n');
|
||||
if (lines.length > MAX_LINES) {
|
||||
lines.length = MAX_LINES;
|
||||
}
|
||||
|
||||
if (lines.length === 1 && lines[0] === '') {
|
||||
this.lines = [];
|
||||
} else {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
this.currentLineIndex = 0;
|
||||
this.totalLineCount = this.lines.length;
|
||||
this.parserFlags = flags;
|
||||
this.nodeCount = 0;
|
||||
}
|
||||
|
||||
parse(): {nodes: Array<Node>} {
|
||||
const ast: Array<Node> = [];
|
||||
if (this.totalLineCount === 0) {
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
while (this.currentLineIndex < this.totalLineCount && this.nodeCount <= MAX_AST_NODES) {
|
||||
const line = this.lines[this.currentLineIndex];
|
||||
let lineLength = line.length;
|
||||
if (lineLength > MAX_LINE_LENGTH) {
|
||||
this.lines[this.currentLineIndex] = line.slice(0, MAX_LINE_LENGTH);
|
||||
lineLength = MAX_LINE_LENGTH;
|
||||
}
|
||||
|
||||
const trimmedLine = line.trimStart();
|
||||
if (trimmedLine === '') {
|
||||
const blankLineCount = this.countBlankLines(this.currentLineIndex);
|
||||
if (ast.length > 0 && this.currentLineIndex + blankLineCount < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + blankLineCount];
|
||||
const nextTrimmed = nextLine.trimStart();
|
||||
|
||||
const isNextHeading = nextTrimmed
|
||||
? BlockParsers.parseHeading(nextTrimmed, (text) => InlineParsers.parseInline(text, this.parserFlags)) !==
|
||||
null
|
||||
: false;
|
||||
|
||||
const isPreviousHeading = ast[ast.length - 1]?.type === NodeType.Heading;
|
||||
|
||||
if (!isNextHeading && !isPreviousHeading) {
|
||||
const newlines = '\n'.repeat(blankLineCount);
|
||||
ast.push({type: NodeType.Text, content: newlines});
|
||||
this.nodeCount++;
|
||||
}
|
||||
}
|
||||
this.currentLineIndex += blankLineCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockResult = BlockParsers.parseBlock(
|
||||
this,
|
||||
this.lines,
|
||||
this.currentLineIndex,
|
||||
this.parserFlags,
|
||||
this.nodeCount,
|
||||
);
|
||||
|
||||
if (blockResult.node) {
|
||||
ast.push(blockResult.node);
|
||||
if (blockResult.extraNodes) {
|
||||
for (const extraNode of blockResult.extraNodes) {
|
||||
ast.push(extraNode);
|
||||
}
|
||||
}
|
||||
this.currentLineIndex = blockResult.newLineIndex;
|
||||
this.nodeCount = blockResult.newNodeCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.parseInlineLine(ast);
|
||||
this.currentLineIndex++;
|
||||
}
|
||||
|
||||
ASTUtils.flattenAST(ast);
|
||||
|
||||
for (const node of ast) {
|
||||
applyTextPresentation(node);
|
||||
}
|
||||
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
private countBlankLines(startLine: number): number {
|
||||
let count = 0;
|
||||
let current = startLine;
|
||||
while (current < this.totalLineCount && this.lines[current].trim() === '') {
|
||||
count++;
|
||||
current++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private parseInlineLine(ast: Array<Node>): void {
|
||||
let text = this.lines[this.currentLineIndex];
|
||||
let linesConsumed = 1;
|
||||
|
||||
while (this.currentLineIndex + linesConsumed < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
|
||||
if (this.isBlockStart(trimmedNext)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (trimmedNext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
text += `\n${nextLine}`;
|
||||
linesConsumed++;
|
||||
}
|
||||
|
||||
if (this.currentLineIndex + linesConsumed < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
const isNextLineHeading = trimmedNext.startsWith('#') && !trimmedNext.startsWith('-#');
|
||||
const isNextLineBlockquote = trimmedNext.startsWith('>');
|
||||
if (trimmedNext === '' || (!isNextLineHeading && !isNextLineBlockquote)) {
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
const inlineNodes = InlineParsers.parseInline(text, this.parserFlags);
|
||||
|
||||
for (const node of inlineNodes) {
|
||||
ast.push(node);
|
||||
this.nodeCount++;
|
||||
if (this.nodeCount > MAX_AST_NODES) break;
|
||||
}
|
||||
|
||||
this.currentLineIndex += linesConsumed - 1;
|
||||
}
|
||||
|
||||
private isBlockStart(line: string): boolean {
|
||||
return !!(
|
||||
line.startsWith('#') ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_SUBTEXT && line.startsWith('-#')) ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_CODE_BLOCKS && line.startsWith('```')) ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_LISTS && ListParsers.matchListItem(line) != null) ||
|
||||
(this.parserFlags & (ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) &&
|
||||
(line.startsWith('>') || line.startsWith('>>> ')))
|
||||
);
|
||||
}
|
||||
}
|
||||
1279
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.test.ts
Normal file
1279
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
806
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.ts
Normal file
806
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
/*
|
||||
* 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 '../parser/parser';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '../types/constants';
|
||||
import {AlertType, NodeType, ParserFlags} from '../types/enums';
|
||||
import type {AlertNode, CodeBlockNode, HeadingNode, Node, SpoilerNode, SubtextNode, TextNode} from '../types/nodes';
|
||||
import {flattenChildren} from '../utils/ast-utils';
|
||||
import * as InlineParsers from './inline-parsers';
|
||||
import * as ListParsers from './list-parsers';
|
||||
import * as TableParsers from './table-parsers';
|
||||
|
||||
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
|
||||
|
||||
interface BlockParseResult {
|
||||
node: Node | null;
|
||||
newLineIndex: number;
|
||||
newNodeCount: number;
|
||||
extraNodes?: 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(
|
||||
_parser: Parser,
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): 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 result = {
|
||||
node: {type: NodeType.Text, content: line} as 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 result = {
|
||||
node: {type: NodeType.Text, content: line} as 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;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
666
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.test.ts
Normal file
666
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.test.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {EmojiKind, NodeType, ParserFlags} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('standard emoji', () => {
|
||||
const input = 'Hello 🦶 World!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🦶',
|
||||
codepoints: '1f9b6',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' World!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('custom emoji static', () => {
|
||||
const input = 'Check this <:mmLol:216154654256398347> emoji!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check this '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'mmLol',
|
||||
id: '216154654256398347',
|
||||
animated: false,
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' emoji!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('custom emoji animated', () => {
|
||||
const input = 'Animated: <a:b1nzy:392938283556143104>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Animated: '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'b1nzy',
|
||||
id: '392938283556143104',
|
||||
animated: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('generate codepoints with vs16 and zwj', () => {
|
||||
const input = '👨👩👧👦❤️😊';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '👨👩👧👦',
|
||||
codepoints: '1f468-200d-1f469-200d-1f467-200d-1f466',
|
||||
name: 'family_mwgb',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '❤️',
|
||||
codepoints: '2764',
|
||||
name: 'heart',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😊',
|
||||
codepoints: '1f60a',
|
||||
name: 'blush',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multiple consecutive emojis', () => {
|
||||
const input = '😀😃😄😁';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😀',
|
||||
codepoints: '1f600',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😃',
|
||||
codepoints: '1f603',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😁',
|
||||
codepoints: '1f601',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special plaintext symbols should be rendered as text', () => {
|
||||
const input = '™ ™️ © ©️ ® ®️';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™ ™ © © ® ®'}]);
|
||||
});
|
||||
|
||||
test('copyright shortcode converts to text symbol', () => {
|
||||
const input = ':copyright: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '© normal text'}]);
|
||||
});
|
||||
|
||||
test('trademark shortcode converts to text symbol', () => {
|
||||
const input = ':tm: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™ normal text'}]);
|
||||
});
|
||||
|
||||
test('registered shortcode converts to text symbol', () => {
|
||||
const input = ':registered: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '® normal text'}]);
|
||||
});
|
||||
|
||||
test('mixed shortcodes with regular emojis', () => {
|
||||
const input = ':copyright: and :smile: with :registered:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '© and '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: 'smile',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' with ®'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('shortcodes in formatted text', () => {
|
||||
const input = '**Bold :tm:** *Italic :copyright:* __:registered:__';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'Bold ™'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'Italic ©'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: '®'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emoji data loaded', () => {
|
||||
const input = ':smile: :wave: :heart:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
expect(ast.some((node) => node.type === NodeType.Emoji)).toBe(true);
|
||||
});
|
||||
|
||||
test('emoji cache initialization', () => {
|
||||
const input = ':smile: :face_holding_back_tears: :face-holding-back-tears:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: 'smile',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' ',
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🥹',
|
||||
codepoints: '1f979',
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' :face-holding-back-tears:',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('case sensitive emoji lookup', () => {
|
||||
const validVariants = [':smile:', ':face_holding_back_tears:'];
|
||||
|
||||
for (const emoji of validVariants) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: expect.any(String),
|
||||
codepoints: expect.any(String),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid case emoji lookup', () => {
|
||||
const invalidVariants = [
|
||||
':SMILE:',
|
||||
':Smile:',
|
||||
':FACE_HOLDING_BACK_TEARS:',
|
||||
':Face_Holding_Back_Tears:',
|
||||
':face-holding-back-tears:',
|
||||
];
|
||||
|
||||
for (const emoji of invalidVariants) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: emoji}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('separator variants', () => {
|
||||
const input = ':face_holding_back_tears: :face-holding-back-tears:';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🥹',
|
||||
codepoints: '1f979',
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' :face-holding-back-tears:',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('basic emoji shortcode', () => {
|
||||
const input = 'Hello :face_holding_back_tears: world!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: expect.any(String),
|
||||
codepoints: expect.any(String),
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' world!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emoji shortcode in code', () => {
|
||||
const input = "`print(':face_holding_back_tears:')`";
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.InlineCode, content: "print(':face_holding_back_tears:')"}]);
|
||||
});
|
||||
|
||||
test('emoji shortcode in code block', () => {
|
||||
const input = '```\n:face_holding_back_tears:\n```';
|
||||
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: ':face_holding_back_tears:\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('distinguishes between plaintext and emoji versions with variation selectors', () => {
|
||||
const inputs = [
|
||||
{text: '↩', shouldBeEmoji: false},
|
||||
{text: '↩️', shouldBeEmoji: true},
|
||||
{text: '↪', shouldBeEmoji: false},
|
||||
{text: '↪️', shouldBeEmoji: true},
|
||||
{text: '⤴', shouldBeEmoji: false},
|
||||
{text: '⤴️', shouldBeEmoji: true},
|
||||
];
|
||||
|
||||
for (const {text, shouldBeEmoji} of inputs) {
|
||||
const parser = new Parser(text, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
if (shouldBeEmoji) {
|
||||
expect(ast[0].type).toBe(NodeType.Emoji);
|
||||
expect((ast[0] as any).kind.kind).toBe(EmojiKind.Standard);
|
||||
expect((ast[0] as any).kind.name).not.toBe('');
|
||||
} else {
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
expect((ast[0] as any).content).toBe(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('renders mixed text with both plaintext and emoji versions', () => {
|
||||
const input = '↩ is plaintext, ↩️ is emoji';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '↩ is plaintext, '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '↩️',
|
||||
codepoints: '21a9',
|
||||
name: 'leftwards_arrow_with_hook',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' is emoji'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('correctly parses dingbat emojis', () => {
|
||||
const inputs = [
|
||||
{emoji: '✅', name: 'white_check_mark', codepoint: '2705'},
|
||||
{emoji: '❌', name: 'x', codepoint: '274c'},
|
||||
];
|
||||
|
||||
for (const {emoji, name, codepoint} of inputs) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: emoji,
|
||||
codepoints: codepoint,
|
||||
name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('dingbat emojis in text context', () => {
|
||||
const input = 'Task complete ✅ but error ❌';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Task complete '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '✅',
|
||||
codepoints: '2705',
|
||||
name: 'white_check_mark',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' but error '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '❌',
|
||||
codepoints: '274c',
|
||||
name: 'x',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('malformed custom emoji edge cases', () => {
|
||||
const malformedCases = [
|
||||
'<:ab>',
|
||||
'<:abc>',
|
||||
'<:name:>',
|
||||
'<:name:abc>',
|
||||
'<:name:123abc>',
|
||||
'<:name:12ab34>',
|
||||
'<::123>',
|
||||
];
|
||||
|
||||
for (const input of malformedCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty custom emoji cases', () => {
|
||||
const emptyCases = ['<::>', '<:name:>', '<::123>'];
|
||||
|
||||
for (const input of emptyCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom emoji with invalid ID characters', () => {
|
||||
const invalidIdCases = ['<:test:123a>', '<:name:12b34>', '<:emoji:abc123>', '<:custom:123-456>', '<:sample:12_34>'];
|
||||
|
||||
for (const input of invalidIdCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom emoji with edge case names and IDs', () => {
|
||||
const edgeCases = ['<::123>', '<: :123>'];
|
||||
|
||||
for (const input of edgeCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Special Symbols Plaintext Rendering', () => {
|
||||
test('trademark symbol should render as text without variation selector', () => {
|
||||
const inputs = ['™', '™️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('copyright symbol should render as text without variation selector', () => {
|
||||
const inputs = ['©', '©️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '©'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('registered symbol should render as text without variation selector', () => {
|
||||
const inputs = ['®', '®️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '®'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed emoji and special symbols', () => {
|
||||
const input = '™️ ©️ ®️ 👍 ❤️';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '™ © ® '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: 'Standard',
|
||||
raw: '👍',
|
||||
codepoints: '1f44d',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: 'Standard',
|
||||
raw: '❤️',
|
||||
codepoints: '2764',
|
||||
name: 'heart',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special symbols in formatted text', () => {
|
||||
const input = '**™️** *©️* __®️__';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: '™'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: '©'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: '®'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special symbols interspersed with text', () => {
|
||||
const input = 'This product™️ is copyright©️ and registered®️';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This product™ is copyright© and registered®'}]);
|
||||
});
|
||||
});
|
||||
336
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.ts
Normal file
336
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* 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 emojiRegex from 'emoji-regex';
|
||||
import {SKIN_TONE_SURROGATES} from '~/Constants';
|
||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import {EmojiKind, NodeType} from '../types/enums';
|
||||
import type {ParserResult} from '../types/nodes';
|
||||
|
||||
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, import('~/lib/UnicodeEmojis').UnicodeEmoji | null>();
|
||||
|
||||
const EMOJI_REGEXP = emojiRegex();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function parseStandardEmoji(text: string, start: number): ParserResult | 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;
|
||||
}
|
||||
|
||||
EMOJI_REGEXP.lastIndex = 0;
|
||||
const match = EMOJI_REGEXP.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 = UnicodeEmojis.getSurrogateName(candidate);
|
||||
|
||||
EMOJI_NAME_CACHE.set(candidate, name);
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoints = EmojiUtils.convertToCodePoints(candidate);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: candidate,
|
||||
codepoints,
|
||||
name,
|
||||
},
|
||||
},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 fullName = text.substring(1, endPos);
|
||||
|
||||
const specialSymbol = SPECIAL_SHORTCODES[fullName];
|
||||
if (specialSymbol) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: specialSymbol},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
let baseName: string;
|
||||
let skinTone: number | undefined;
|
||||
|
||||
const skinToneIndex = fullName.indexOf('::skin-tone-');
|
||||
if (skinToneIndex !== -1 && skinToneIndex + 12 < fullName.length) {
|
||||
const toneChar = fullName.charAt(skinToneIndex + 12);
|
||||
const tone = Number.parseInt(toneChar, 10);
|
||||
if (tone >= 1 && tone <= 5) {
|
||||
baseName = fullName.substring(0, skinToneIndex);
|
||||
skinTone = tone;
|
||||
} else {
|
||||
baseName = fullName;
|
||||
}
|
||||
} else {
|
||||
baseName = fullName;
|
||||
}
|
||||
|
||||
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
|
||||
if (emoji === undefined) {
|
||||
emoji = UnicodeEmojis.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;
|
||||
if (skinTone !== undefined) {
|
||||
const skinToneKey = `${baseName}:tone-${skinTone}`;
|
||||
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
|
||||
|
||||
if (skinToneEmoji === undefined) {
|
||||
const skinToneSurrogate = SKIN_TONE_SURROGATES[skinTone - 1];
|
||||
skinToneEmoji = UnicodeEmojis.findEmojiWithSkinTone(baseName, skinToneSurrogate);
|
||||
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
|
||||
}
|
||||
|
||||
if (skinToneEmoji) {
|
||||
finalEmoji = skinToneEmoji;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoints = EmojiUtils.convertToCodePoints(finalEmoji.surrogates);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: finalEmoji.surrogates,
|
||||
codepoints,
|
||||
name: baseName,
|
||||
},
|
||||
},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
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: any): 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 && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
applyTextPresentation(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('inline nodes', () => {
|
||||
const input =
|
||||
'This is **strong**, *emphasis*, __underline__, ~~strikethrough~~, `inline code`, and a [link](https://example.com). Also, visit https://rust-lang.org.';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_AUTOLINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Underline, children: [{type: NodeType.Text, content: 'underline'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strikethrough'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.InlineCode, content: 'inline code'},
|
||||
{type: NodeType.Text, content: ', and a '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'link'},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '. Also, visit '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: undefined,
|
||||
url: 'https://rust-lang.org/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('incomplete formatting', () => {
|
||||
const input = '**incomplete strong *incomplete emphasis `incomplete code';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '*'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'incomplete strong '}]},
|
||||
{type: NodeType.Text, content: 'incomplete emphasis `incomplete code'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('underscore emphasis', () => {
|
||||
const input = 'This is _emphasized_ and *also emphasized*';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'also emphasized'}]},
|
||||
]);
|
||||
});
|
||||
|
||||
test('alternate delimiters', () => {
|
||||
const input = '__underscore *asterisk* mix__ and _single *with* under_';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'underscore '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'asterisk'}]},
|
||||
{type: NodeType.Text, content: ' mix'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'single with under'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline spoiler', () => {
|
||||
const input = 'This is a ||secret|| message';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is a '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
|
||||
{type: NodeType.Text, content: ' message'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('formatted spoiler', () => {
|
||||
const input = '||This is *emphasized* and **strong**||';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('adjacent spoilers', () => {
|
||||
const input = '||a|| ||b|| ||c||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'a'}]},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'b'}]},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'c'}]},
|
||||
]);
|
||||
});
|
||||
|
||||
test('unclosed spoiler', () => {
|
||||
const input = '||This spoiler never ends';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '||This spoiler never ends'}]);
|
||||
});
|
||||
|
||||
test('consecutive pipes should create spoilers with pipe content', () => {
|
||||
const input = '|||||||||||||||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
]);
|
||||
|
||||
const fivePipes = '|||||';
|
||||
const fiveParser = new Parser(fivePipes, ParserFlags.ALLOW_SPOILERS);
|
||||
const {nodes: fiveAst} = fiveParser.parse();
|
||||
expect(fiveAst).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
]);
|
||||
|
||||
const sixPipes = '||||||';
|
||||
const sixParser = new Parser(sixPipes, ParserFlags.ALLOW_SPOILERS);
|
||||
const {nodes: sixAst} = sixParser.parse();
|
||||
expect(sixAst).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Text, content: '|'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('bold italics', () => {
|
||||
const input = '***bolditalics***';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'bolditalics'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complex nested formatting combinations', () => {
|
||||
const input =
|
||||
'***__bold italic underline__***\n**_bold and italic_**\n__***underline, bold, italic***__\n**_nested __underline inside italic__ text_**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'bold italic underline'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'bold and italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'underline, bold, italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'underline inside italic'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' text'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('spoiler with various formatting combinations', () => {
|
||||
const input =
|
||||
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'spoiler bold'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'bold spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'italic spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.InlineCode, content: '||spoiler code||'},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('spoiler with broken nesting and mixed formatting', () => {
|
||||
const input =
|
||||
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||\n||_**mixed || nesting madness**_||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'spoiler bold'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'bold spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'italic spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.InlineCode, content: '||spoiler code||'},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '_**mixed '}]},
|
||||
{type: NodeType.Text, content: ' nesting madness**_||'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('text node splitting', () => {
|
||||
const input = 'This is a link: [Rust](https://www.rust-lang.org).';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is a link: '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'Rust'},
|
||||
url: 'https://www.rust-lang.org/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('newline text handling', () => {
|
||||
const input = 'First line.\nSecond line with `code`.\nThird line.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First line.\nSecond line with '},
|
||||
{type: NodeType.InlineCode, content: 'code'},
|
||||
{type: NodeType.Text, content: '.\nThird line.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('underscore emphasis with constants', () => {
|
||||
const input = 'THIS_IS_A_CONSTANT THIS _IS_ A_CONSTANT THIS _IS_. A CONSTANT';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'THIS_IS_A_CONSTANT THIS '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
|
||||
{type: NodeType.Text, content: ' A_CONSTANT THIS '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
|
||||
{type: NodeType.Text, content: '. A CONSTANT'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('incomplete formatting in code', () => {
|
||||
const input = '`function() { /* ** {{ __unclosed__ }} ** */ }`';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.InlineCode, content: 'function() { /* ** {{ __unclosed__ }} ** */ }'}]);
|
||||
});
|
||||
|
||||
test('link with inline code', () => {
|
||||
const input = '[`f38932b`](https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13)';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.InlineCode, content: 'f38932b'},
|
||||
url: 'https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13',
|
||||
escaped: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('link with all inline formatting', () => {
|
||||
const input = '[**Bold**, *Italic*, ~~Strikethrough~~, and `Code`](https://example.com)';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {
|
||||
type: NodeType.Sequence,
|
||||
children: [
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'Bold'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'Italic'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'Strikethrough'}]},
|
||||
{type: NodeType.Text, content: ', and '},
|
||||
{type: NodeType.InlineCode, content: 'Code'},
|
||||
],
|
||||
},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('shrug emoticon should preserve backslash before underscore', () => {
|
||||
const input = 'Check out this shrug: ¯\\_(ツ)_/¯';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check out this shrug: ¯\\_(ツ)_/¯'}]);
|
||||
|
||||
expect((ast[0] as TextNode).content).toContain('¯\\_(');
|
||||
});
|
||||
|
||||
test('regular escaped underscore should be handled correctly', () => {
|
||||
const input = 'This is not \\_emphasized\\_ text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This is not _emphasized_ text'}]);
|
||||
});
|
||||
|
||||
describe('Edge cases and complex nesting', () => {
|
||||
test('double-space line breaks in formatted text', () => {
|
||||
const input = '**bold \nacross \nmultiple lines**\n__underline \nwith breaks__';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
expect(ast[1]).toEqual({type: NodeType.Text, content: '\n'});
|
||||
expect(ast[2].type).toBe(NodeType.Underline);
|
||||
|
||||
// TODO: Check for line break nodes within the formatted sections
|
||||
});
|
||||
|
||||
test('escaped characters in formatting', () => {
|
||||
const input = '**bold \\*with\\* escaped**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('nested formatting behavior', () => {
|
||||
const input = '**outer **inner** content**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('empty double markers', () => {
|
||||
const input = '****';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '****'}]);
|
||||
});
|
||||
|
||||
test('incomplete formatting marker', () => {
|
||||
const input = '**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '**'}]);
|
||||
});
|
||||
|
||||
test('complex nested formatting behavior', () => {
|
||||
const input = '**outer *middle **inner** content* end**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('escaped backslash handling', () => {
|
||||
const input = '**test \\\\escaped backslash**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('extremely complex nested formatting with weird edge cases', () => {
|
||||
const input =
|
||||
'***bold _italic __underline bold italic__ italic_ bold***\n**_nested *italic inside bold inside italic*_**\n__**_underline bold italic **still going_**__\n**_this ends weirdly__**\n***bold *italic `code` inside***';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'italic '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'underline bold italic'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' italic'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: ' bold'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'italic inside bold inside italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_underline bold italic '}]},
|
||||
{type: NodeType.Text, content: 'still going_**'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_this ends weirdly__'}]},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold *italic '},
|
||||
{type: NodeType.InlineCode, content: 'code'},
|
||||
{type: NodeType.Text, content: ' inside'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complex mismatched formatting markers', () => {
|
||||
const input =
|
||||
'**bold *italic*\n__underline **bold__\n~~strike *italic~~ text*\n**__mixed but only one end__\n_italics __underline_';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'italic'}]},
|
||||
{type: NodeType.Text, content: '\n__underline '},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'bold'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strike *italic'}]},
|
||||
{type: NodeType.Text, content: ' text'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: '\n'}]},
|
||||
{type: NodeType.Text, content: '*'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'mixed but only one end'},
|
||||
{type: NodeType.Underline, children: [{type: NodeType.Text, content: '\n_italics '}]},
|
||||
{type: NodeType.Text, content: 'underline_'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
863
fluxer_app/src/lib/markdown/parser/parsers/inline-parsers.ts
Normal file
863
fluxer_app/src/lib/markdown/parser/parsers/inline-parsers.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
/*
|
||||
* 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 '../parser/formatting-context';
|
||||
import {MAX_LINE_LENGTH} from '../types/constants';
|
||||
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node, ParserResult} from '../types/nodes';
|
||||
import * as ASTUtils from '../utils/ast-utils';
|
||||
import * as StringUtils from '../utils/string-utils';
|
||||
import * as EmojiParsers from './emoji-parsers';
|
||||
import * as LinkParsers from './link-parsers';
|
||||
import * as MentionParsers from './mention-parsers';
|
||||
import * as TimestampParsers from './timestamp-parsers';
|
||||
|
||||
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 AMPERSAND = 38;
|
||||
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 emojiResult = EmojiParsers.parseEmojiShortcode(remainingText);
|
||||
|
||||
if (emojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emojiResult.node);
|
||||
position += emojiResult.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 === AMPERSAND ||
|
||||
nextCharCode === SLASH ||
|
||||
nextCharCode === LETTER_I
|
||||
) {
|
||||
if (
|
||||
nextCharCode === AT_SIGN &&
|
||||
position + 2 < textLength &&
|
||||
text.charCodeAt(position + 2) === AMPERSAND &&
|
||||
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;
|
||||
}
|
||||
|
||||
// Try email links: <user@example.com>
|
||||
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 _backtickCount = 0;
|
||||
let checkPos = position;
|
||||
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
|
||||
_backtickCount++;
|
||||
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};
|
||||
}
|
||||
|
||||
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 any,
|
||||
children: [],
|
||||
...(isBlock ? {isBlock} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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 any,
|
||||
children: innerNodes,
|
||||
...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}),
|
||||
};
|
||||
}
|
||||
1526
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.test.ts
Normal file
1526
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
540
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.ts
Normal file
540
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/*
|
||||
* 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 {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
import {MAX_LINK_URL_LENGTH} from '../types/constants';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node, ParserResult} from '../types/nodes';
|
||||
import * as StringUtils from '../utils/string-utils';
|
||||
import * as URLUtils from '../utils/url-utils';
|
||||
|
||||
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const URL_DOMAIN_PATTERN =
|
||||
/^(?:https?:\/\/)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\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);
|
||||
const isSpoofedLink = SPOOFED_LINK_PATTERN.test(text);
|
||||
|
||||
if (isEmailSpoofing || isSpoofedLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlInfo = extractUrl(text, bracketPosition + 2);
|
||||
if (!urlInfo) return null;
|
||||
|
||||
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUrlOrDomainLike = URL_DOMAIN_PATTERN.test(trimmedLinkText);
|
||||
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
|
||||
|
||||
if (isLinkTextUrlWithProtocol && isUrlOrDomainLike) {
|
||||
try {
|
||||
const textDomain = extractDomainFromString(trimmedLinkText);
|
||||
const urlDomain = extractDomainFromString(urlInfo.url);
|
||||
|
||||
if (!textDomain || (urlDomain && textDomain !== urlDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
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 if (text.startsWith(APP_PROTOCOL_PREFIX)) {
|
||||
prefixLength = APP_PROTOCOL_PREFIX.length;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = prefixLength;
|
||||
const textLength = text.length;
|
||||
|
||||
while (end < textLength && !StringUtils.isUrlTerminationChar(text[end])) {
|
||||
end++;
|
||||
if (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 extractDomainFromString(input: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
try {
|
||||
const normalized = input.normalize('NFKC').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let urlCandidate = normalized;
|
||||
if (!normalized.includes('://')) {
|
||||
urlCandidate = normalized.startsWith('//') ? `https:${normalized}` : `https://${normalized}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlCandidate);
|
||||
return url.hostname.toLowerCase();
|
||||
} catch {
|
||||
const match = urlCandidate.match(/^(?:https?:\/\/)([^/?#]+)/i);
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
658
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.test.ts
Normal file
658
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.test.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {CodeBlockNode, ListNode, TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser - Lists', () => {
|
||||
describe('Basic list functionality', () => {
|
||||
test('unordered list', () => {
|
||||
const input = '- Item 1\n- Item 2\n- Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}]},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('ordered list', () => {
|
||||
const input = '1. Item 1\n2. Item 2\n3. Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}], ordinal: 2},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}], ordinal: 3},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('mixed list types should create separate lists', () => {
|
||||
const input = '1. First\n- Unordered\n2. Second';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [{children: [{type: NodeType.Text, content: 'First'}], ordinal: 1}],
|
||||
});
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[1]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [{children: [{type: NodeType.Text, content: 'Unordered'}]}],
|
||||
});
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
expect(ast[2]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [{children: [{type: NodeType.Text, content: 'Second'}], ordinal: 2}],
|
||||
});
|
||||
});
|
||||
|
||||
test('list with asterisks', () => {
|
||||
const input = '* Item 1\n* Item 2\n* Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}]},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom ordering in lists', () => {
|
||||
test('custom ordered list numbering', () => {
|
||||
const input = '1. First item\n3. Third item\n5. Fifth item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 3},
|
||||
{children: [{type: NodeType.Text, content: 'Fifth item'}], ordinal: 5},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('list with all same number', () => {
|
||||
const input = '1. Item one\n1. Item two\n1. Item three';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item one'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item two'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item three'}], ordinal: 1},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('list starting with non-1', () => {
|
||||
const input = '5. First item\n6. Second item\n7. Third item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 5},
|
||||
{children: [{type: NodeType.Text, content: 'Second item'}], ordinal: 6},
|
||||
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 7},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('mixed pattern ordered list', () => {
|
||||
const input = '1. a\n1. b\n3. c\n4. d';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'a'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'b'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'c'}], ordinal: 3},
|
||||
{children: [{type: NodeType.Text, content: 'd'}], ordinal: 4},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested lists', () => {
|
||||
test('simple nested list', () => {
|
||||
const input = '- Parent 1\n - Child 1\n - Child 2\n- Parent 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
});
|
||||
|
||||
test('ordered list with nested unordered list', () => {
|
||||
const input = '1. First item\n - Nested unordered 1\n - Nested unordered 2\n2. Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(true);
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.ordered).toBe(false);
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
});
|
||||
|
||||
test('unordered list with nested ordered list', () => {
|
||||
const input = '- First item\n 1. Nested ordered 1\n 2. Nested ordered 2\n- Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(false);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.ordered).toBe(true);
|
||||
expect(nestedList.items[0].ordinal).toBe(1);
|
||||
expect(nestedList.items[1].ordinal).toBe(2);
|
||||
});
|
||||
|
||||
test('multi-level nesting', () => {
|
||||
const input =
|
||||
'1. Level 1\n - Level 2\n - Level 3\n 1. Level 4\n - Back to level 2\n2. Back to level 1';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
|
||||
const level2List = listNode.items[0].children[1] as ListNode;
|
||||
expect(level2List.ordered).toBe(false);
|
||||
|
||||
const level3List = level2List.items[0].children[1] as ListNode;
|
||||
expect(level3List.ordered).toBe(false);
|
||||
|
||||
const level4List = level3List.items[0].children[1] as ListNode;
|
||||
expect(level4List.ordered).toBe(true);
|
||||
expect(level4List.items[0].ordinal).toBe(1);
|
||||
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lists with other content', () => {
|
||||
test('list with formatted text', () => {
|
||||
const input = '- **Bold item**\n- *Italic item*\n- `Code item`';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
|
||||
expect(listNode.items[2].children[0].type).toBe(NodeType.InlineCode);
|
||||
});
|
||||
|
||||
test('list with blank lines between paragraphs', () => {
|
||||
const input = '1. First paragraph\n\n Second paragraph\n2. Another item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
const firstList = ast[0] as ListNode;
|
||||
expect(firstList.items.length).toBe(1);
|
||||
expect(firstList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((firstList.items[0].children[0] as TextNode).content).toBe('First paragraph');
|
||||
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
const secondList = ast[2] as ListNode;
|
||||
expect(secondList.items.length).toBe(1);
|
||||
expect(secondList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((secondList.items[0].children[0] as TextNode).content).toBe('Another item');
|
||||
});
|
||||
|
||||
test('list before and after paragraph', () => {
|
||||
const input = '- List item 1\n- List item 2\n\nParagraph text\n\n- List item 3\n- List item 4';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
|
||||
expect((ast[1] as TextNode).content).toBe('\nParagraph text\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lists with code blocks', () => {
|
||||
test('list with code block', () => {
|
||||
const input =
|
||||
'1. Item with code block:\n' +
|
||||
' ```\n' +
|
||||
' function example() {\n' +
|
||||
' return "test";\n' +
|
||||
' }\n' +
|
||||
' ```\n' +
|
||||
'2. Next item';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
|
||||
|
||||
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
expect(codeBlock.content).toContain('function example()');
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
});
|
||||
|
||||
test('code block with language specified', () => {
|
||||
const input =
|
||||
'- Item with JavaScript:\n' + ' ```javascript\n' + ' const x = 42;\n' + ' console.log(x);\n' + ' ```';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
|
||||
expect(codeBlock.language).toBe('javascript');
|
||||
expect(codeBlock.content).toContain('const x = 42;');
|
||||
});
|
||||
|
||||
test('list with multiple code blocks', () => {
|
||||
const input =
|
||||
'1. First code block:\n' +
|
||||
' ```\n' +
|
||||
' Block 1\n' +
|
||||
' ```\n' +
|
||||
'2. Second code block:\n' +
|
||||
' ```\n' +
|
||||
' Block 2\n' +
|
||||
' ```';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
|
||||
expect(listNode.items.length).toBe(2);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
|
||||
expect(listNode.items[1].children[1].type).toBe(NodeType.CodeBlock);
|
||||
|
||||
const firstCodeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
const secondCodeBlock = listNode.items[1].children[1] as CodeBlockNode;
|
||||
|
||||
expect(firstCodeBlock.content).toContain('Block 1');
|
||||
expect(secondCodeBlock.content).toContain('Block 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and special scenarios', () => {
|
||||
test('deeply nested list beyond max depth (9 levels)', () => {
|
||||
const input =
|
||||
'* fdf\n * dffsdf\n * dfsdfs\n * fdfsf\n * test\n * test2\n * test3\n * test4\n * test5';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(false);
|
||||
expect(listNode.items.length).toBe(1);
|
||||
|
||||
let currentLevel: ListNode = listNode;
|
||||
const expectedContents = ['fdf', 'dffsdf', 'dfsdfs', 'fdfsf', 'test', 'test2', 'test3', 'test4', 'test5'];
|
||||
|
||||
for (let i = 0; i < expectedContents.length; i++) {
|
||||
expect(currentLevel.items.length).toBeGreaterThan(0);
|
||||
const item = currentLevel.items[0];
|
||||
expect(item.children.length).toBeGreaterThan(0);
|
||||
|
||||
const textNode = item.children[0] as TextNode;
|
||||
expect(textNode.type).toBe(NodeType.Text);
|
||||
expect(textNode.content).toBe(expectedContents[i]);
|
||||
|
||||
if (i < expectedContents.length - 1) {
|
||||
expect(item.children.length).toBe(2);
|
||||
expect(item.children[1].type).toBe(NodeType.List);
|
||||
currentLevel = item.children[1] as ListNode;
|
||||
} else {
|
||||
expect(item.children.length).toBe(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('list with invalid indentation', () => {
|
||||
const input = '1. First\n - Invalid subitem with 1 space\n2. Second';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.Text);
|
||||
expect((listNode.items[0].children[1] as TextNode).content).toContain('Invalid subitem');
|
||||
});
|
||||
|
||||
test('empty list items', () => {
|
||||
const input = '- \n- Item 2\n- ';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(3);
|
||||
|
||||
expect(listNode.items[1].children.length).toBeGreaterThan(0);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
expect((listNode.items[1].children[0] as TextNode).content).toBe('Item 2');
|
||||
});
|
||||
|
||||
test('list with very large numbers', () => {
|
||||
const input = '9999999. First item\n10000000. Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].ordinal).toBe(9999999);
|
||||
expect(listNode.items[1].ordinal).toBe(10000000);
|
||||
});
|
||||
|
||||
test('lists disabled by parser flags', () => {
|
||||
const input = '- Item 1\n- Item 2';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
|
||||
expect((ast[0] as TextNode).content).toBe('- Item 1- Item 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction with other block elements', () => {
|
||||
test('list adjacent to heading', () => {
|
||||
const input = '# Heading\n- List item\n## Next heading';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Heading);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[2].type).toBe(NodeType.Heading);
|
||||
});
|
||||
|
||||
test('list adjacent to blockquote', () => {
|
||||
const input = '> Blockquote\n- List item\n> Another blockquote';
|
||||
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Blockquote);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[2].type).toBe(NodeType.Blockquote);
|
||||
});
|
||||
|
||||
test('nested list with blank lines between items', () => {
|
||||
const input = '- Parent item\n\n - Child item 1\n\n - Child item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('empty list continuation handling', () => {
|
||||
const input = '- Item 1\n Continuation text\n- Item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items).toHaveLength(2);
|
||||
expect(listNode.items[0].children).toHaveLength(2);
|
||||
expect(listNode.items[0].children[1]).toEqual({
|
||||
type: NodeType.Text,
|
||||
content: 'Continuation text',
|
||||
});
|
||||
});
|
||||
|
||||
test('list with multiple continuation lines', () => {
|
||||
const input = '- Item 1\n Line 2\n Line 3\n- Item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('deeply nested list structure', () => {
|
||||
const input = '- Level 1\n - Level 2\n - Level 3\n Text continuation';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('list continuation with empty items array', () => {
|
||||
const input = 'Some text\nContinuation line\n- Actual list item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(2);
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('invalid list marker patterns', () => {
|
||||
const invalidPatterns = [
|
||||
'1 Not a list item',
|
||||
'1.Not a list item',
|
||||
'- ',
|
||||
'1. ',
|
||||
' -Not a list',
|
||||
' 1.Not a list',
|
||||
];
|
||||
|
||||
for (const pattern of invalidPatterns) {
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(pattern, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
if (pattern.trim() === '-' || pattern.trim() === '1.') {
|
||||
continue;
|
||||
}
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
}
|
||||
});
|
||||
|
||||
test('complex list with nested bullets and formatting', () => {
|
||||
const input =
|
||||
'1. **Bold list**\n2. *Italic list*\n3. - Nested bullet\n * **Bold nested**\n4. Text with\n line breaks \n **and bold**';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(true);
|
||||
expect(listNode.items.length).toBe(4);
|
||||
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
expect(listNode.items[0].children.length).toBe(1);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
|
||||
expect(listNode.items[2].ordinal).toBe(3);
|
||||
expect(listNode.items[2].children.length).toBe(1);
|
||||
expect(listNode.items[2].children[0].type).toBe(NodeType.List);
|
||||
|
||||
const nestedList = listNode.items[2].children[0] as ListNode;
|
||||
expect(nestedList.ordered).toBe(false);
|
||||
expect(nestedList.items.length).toBe(2);
|
||||
|
||||
expect(nestedList.items[0].children.length).toBe(1);
|
||||
expect(nestedList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((nestedList.items[0].children[0] as TextNode).content).toBe('Nested bullet');
|
||||
|
||||
expect(nestedList.items[1].children.length).toBe(1);
|
||||
expect(nestedList.items[1].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[3].ordinal).toBe(4);
|
||||
expect(listNode.items[3].children.length).toBeGreaterThan(1);
|
||||
|
||||
const item4Children = listNode.items[3].children;
|
||||
expect(item4Children.some((child) => child.type === NodeType.Text)).toBe(true);
|
||||
expect(item4Children.some((child) => child.type === NodeType.Strong)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
439
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.ts
Normal file
439
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* 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 {MAX_AST_NODES} from '../types/constants';
|
||||
import {NodeType} from '../types/enums';
|
||||
import type {ListItem, ListNode, Node} from '../types/nodes';
|
||||
import {parseCodeBlock} from './block-parsers';
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('user mentions', () => {
|
||||
const input = 'Hello <@1234567890> and <@!9876543210>';
|
||||
const flags = ParserFlags.ALLOW_USER_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '1234567890'}},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '9876543210'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('channel mention', () => {
|
||||
const input = 'Please check <#103735883630395392>';
|
||||
const flags = ParserFlags.ALLOW_CHANNEL_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Please check '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Channel, id: '103735883630395392'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('role mention', () => {
|
||||
const input = 'This is for <@&165511591545143296>';
|
||||
const flags = ParserFlags.ALLOW_ROLE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is for '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Role, id: '165511591545143296'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('slash command mention', () => {
|
||||
const input = 'Use </airhorn:816437322781949972>';
|
||||
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Use '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'airhorn',
|
||||
subcommandGroup: undefined,
|
||||
subcommand: undefined,
|
||||
id: '816437322781949972',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('slash command with subcommands', () => {
|
||||
const input = 'Try </app group sub:1234567890>';
|
||||
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Try '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'app',
|
||||
subcommandGroup: 'group',
|
||||
subcommand: 'sub',
|
||||
id: '1234567890',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('guild nav customize', () => {
|
||||
const input = 'Go to <id:customize> now!';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Go to '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.Customize}},
|
||||
{type: NodeType.Text, content: ' now!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('guild nav linked roles', () => {
|
||||
const input = 'Check <id:linked-roles:123456> settings';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType: GuildNavKind.LinkedRoles,
|
||||
id: '123456',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' settings'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('invalid guild nav', () => {
|
||||
const input = 'Invalid <12345:customize>';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Invalid <12345:customize>'}]);
|
||||
});
|
||||
|
||||
test('everyone and here mentions', () => {
|
||||
const input = '@everyone and @here are both important.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: ' are both important.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('escaped everyone and here mentions', () => {
|
||||
const input = '\\@everyone and \\@here should not be parsed.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
|
||||
});
|
||||
|
||||
test('mentions inside inline code', () => {
|
||||
const input = '`@everyone` and `@here` should remain unchanged.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.InlineCode, content: '@everyone'},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.InlineCode, content: '@here'},
|
||||
{type: NodeType.Text, content: ' should remain unchanged.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions inside code block', () => {
|
||||
const input = '```\n@everyone\n@here\n```';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: '@everyone\n@here\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions with flags disabled', () => {
|
||||
const input = '@everyone and @here should not be parsed.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
|
||||
});
|
||||
|
||||
test('mentions followed by punctuation', () => {
|
||||
const input = 'Hello @everyone! Are you there, @here?';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Text, content: '! Are you there, '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: '?'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions adjacent to other symbols', () => {
|
||||
const input = 'Check this out:@everyone@here!';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check this out:'},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: '!'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
206
fluxer_app/src/lib/markdown/parser/parsers/mention-parsers.ts
Normal file
206
fluxer_app/src/lib/markdown/parser/parsers/mention-parsers.ts
Normal 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 '../types/enums';
|
||||
import type {MentionNode, ParserResult} from '../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,
|
||||
},
|
||||
};
|
||||
}
|
||||
306
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.test.ts
Normal file
306
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags, TableAlignment} from '../types/enums';
|
||||
import type {InlineCodeNode, TableNode, TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
describe('Table Parser', () => {
|
||||
test('basic table', () => {
|
||||
const input = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| Cell 3 | Cell 4 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.header.cells.length).toBe(2);
|
||||
expect(tableNode.rows.length).toBe(2);
|
||||
|
||||
expect(tableNode.header.cells[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((tableNode.header.cells[0].children[0] as TextNode).content).toBe('Header 1');
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('Cell 1');
|
||||
});
|
||||
|
||||
test('table with alignments', () => {
|
||||
const input = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| 1 | 2 | 3 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.alignments).toEqual([TableAlignment.Left, TableAlignment.Center, TableAlignment.Right]);
|
||||
});
|
||||
|
||||
test('table with escaped pipes', () => {
|
||||
const input = `| Function | Integral |
|
||||
|----------|----------|
|
||||
| 1/x | ln\\|x\\| + C |
|
||||
| tan x | -ln\\|cos x\\| + C |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(2);
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[1].children[0] as TextNode;
|
||||
const cell2 = tableNode.rows[1].cells[1].children[0] as TextNode;
|
||||
|
||||
expect(cell1.content).toBe('ln|x| + C');
|
||||
expect(cell2.content).toBe('-ln|cos x| + C');
|
||||
});
|
||||
|
||||
test('table with formatted content', () => {
|
||||
const input = `| Formatting | Example |
|
||||
|------------|---------|
|
||||
| **Bold** | *Italic* |
|
||||
| ~~Strike~~ | \`Code\` |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Strong);
|
||||
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strikethrough);
|
||||
expect(tableNode.rows[1].cells[1].children[0].type).toBe(NodeType.InlineCode);
|
||||
});
|
||||
|
||||
test('multi-row integral table with escaped pipes', () => {
|
||||
const input = `| Funktion | Integral |
|
||||
|----------|----------|
|
||||
| x^n (n ≠ -1) | x^(n+1)/(n+1) + C |
|
||||
| 1/x | ln\\|x\\| + C |
|
||||
| e^x | e^x + C |
|
||||
| a^x | a^x/ln a + C |
|
||||
| sin x | -cos x + C |
|
||||
| cos x | sin x + C |
|
||||
| tan x | -ln\\|cos x\\| + C |
|
||||
| sec²x | tan x + C |
|
||||
| 1/√(1-x²) | arcsin x + C |
|
||||
| 1/(1+x²) | arctan x + C |`;
|
||||
|
||||
const flags = ParserFlags.ALLOW_TABLES;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(10);
|
||||
|
||||
const row3Cell = tableNode.rows[1].cells[1].children[0] as TextNode;
|
||||
const row7Cell = tableNode.rows[6].cells[1].children[0] as TextNode;
|
||||
|
||||
expect(row3Cell.content).toBe('ln|x| + C');
|
||||
expect(row7Cell.content).toBe('-ln|cos x| + C');
|
||||
});
|
||||
|
||||
test('absolute value notation with escaped pipes', () => {
|
||||
const input = `| Expression | Value |
|
||||
|------------|-------|
|
||||
| \\|x\\| | Absolute value of x |
|
||||
| \\|\\|x\\|\\| | Double absolute value |
|
||||
| \\|x + y\\| | Absolute value of sum |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(3);
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[0].children[0] as TextNode;
|
||||
const cell2 = tableNode.rows[1].cells[0].children[0] as TextNode;
|
||||
const cell3 = tableNode.rows[2].cells[0].children[0] as TextNode;
|
||||
|
||||
expect(cell1.content).toBe('|x|');
|
||||
expect(cell2.content).toBe('||x||');
|
||||
expect(cell3.content).toBe('|x + y|');
|
||||
});
|
||||
|
||||
test('mixed mathematical notations with escaped pipes', () => {
|
||||
const input = `| Function | Example |
|
||||
|----------|---------|
|
||||
| log | log\\|x\\| |
|
||||
| ln | ln\\|x\\| |
|
||||
| sin | sin(\\|x\\|) |
|
||||
| abs | \\|f(x)\\| |
|
||||
| complex | \\|x\\|² + \\|y\\|² |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(5);
|
||||
|
||||
expect((tableNode.rows[0].cells[1].children[0] as TextNode).content).toBe('log|x|');
|
||||
expect((tableNode.rows[1].cells[1].children[0] as TextNode).content).toBe('ln|x|');
|
||||
expect((tableNode.rows[2].cells[1].children[0] as TextNode).content).toBe('sin(|x|)');
|
||||
expect((tableNode.rows[3].cells[1].children[0] as TextNode).content).toBe('|f(x)|');
|
||||
expect((tableNode.rows[4].cells[1].children[0] as TextNode).content).toBe('|x|² + |y|²');
|
||||
});
|
||||
|
||||
test('set notation with escaped pipes', () => {
|
||||
const input = `| Notation | Meaning |
|
||||
|----------|---------|
|
||||
| {x \\| x > 0} | Set of positive numbers |
|
||||
| \\|{x \\| x > 0}\\| | Cardinality of positive numbers |
|
||||
| A ∩ {x \\| \\|x\\| < 1} | Intersection with unit ball |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(3);
|
||||
|
||||
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('{x | x > 0}');
|
||||
expect((tableNode.rows[1].cells[0].children[0] as TextNode).content).toBe('|{x | x > 0}|');
|
||||
expect((tableNode.rows[2].cells[0].children[0] as TextNode).content).toBe('A ∩ {x | |x| < 1}');
|
||||
});
|
||||
|
||||
test('table with links and code', () => {
|
||||
const input = `| Description | Example |
|
||||
|-------------|---------|
|
||||
| [Link](https://example.com) | \`code\` |
|
||||
| **[Bold Link](https://example.org)** | *\`inline code\`* |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_MASKED_LINKS);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Link);
|
||||
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.InlineCode);
|
||||
|
||||
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('invalid table formats', () => {
|
||||
const inputs = ['| Header |\n| Cell |', '| H1 |\n|====|\n| C1 |', '| |\n|--|\n| |'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const isTable = ast.some((node) => node.type === NodeType.Table);
|
||||
expect(isTable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('table with empty cells', () => {
|
||||
const input = `| H1 | | H3 |
|
||||
|----|----|----|
|
||||
| C1 | | C3 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
const emptyCell = tableNode.rows[0].cells[1].children[0] as TextNode;
|
||||
expect(emptyCell.type).toBe(NodeType.Text);
|
||||
expect(emptyCell.content).toBe('');
|
||||
});
|
||||
|
||||
test('table with inconsistent column count', () => {
|
||||
const input = `| A | B | C |
|
||||
|---|---|---|
|
||||
| 1 | 2 |
|
||||
| 3 | 4 | 5 | 6 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells.length).toBe(3);
|
||||
expect(tableNode.rows[1].cells.length).toBe(3);
|
||||
|
||||
const emptyCell = tableNode.rows[0].cells[2].children[0] as TextNode;
|
||||
expect(emptyCell.content).toBe('');
|
||||
|
||||
const mergedCell = tableNode.rows[1].cells[2].children[0] as TextNode;
|
||||
expect(mergedCell.content.includes('5')).toBe(true);
|
||||
});
|
||||
|
||||
test('table with pipes in code examples', () => {
|
||||
const input = `| Language | Pipe Example |
|
||||
|----------|-------------|
|
||||
| Bash | \`echo "hello" \\| grep "h"\` |
|
||||
| JavaScript | \`const x = condition \\|\\| defaultValue;\` |
|
||||
| C++ | \`if (a \\|\\| b && c) {...}\` |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[1].children[0] as InlineCodeNode;
|
||||
const cell2 = tableNode.rows[1].cells[1].children[0] as InlineCodeNode;
|
||||
const cell3 = tableNode.rows[2].cells[1].children[0] as InlineCodeNode;
|
||||
|
||||
expect(cell1.type).toBe(NodeType.InlineCode);
|
||||
expect(cell2.type).toBe(NodeType.InlineCode);
|
||||
expect(cell3.type).toBe(NodeType.InlineCode);
|
||||
|
||||
expect(cell1.content).toContain('echo "hello" | grep "h"');
|
||||
expect(cell2.content).toContain('condition || defaultValue');
|
||||
expect(cell3.content).toContain('if (a || b && c)');
|
||||
});
|
||||
|
||||
test('tables disabled', () => {
|
||||
const input = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |`;
|
||||
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.every((node) => node.type === NodeType.Text)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
329
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.ts
Normal file
329
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.ts
Normal 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 '../types/enums';
|
||||
import type {Node, TableCellNode, TableNode, TableRowNode} from '../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;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {EmojiKind, NodeType, ParserFlags, TimestampStyle} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('timestamp default', () => {
|
||||
const input = 'Current time: <t:1618953630>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Current time: '},
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with style', () => {
|
||||
const input = '<t:1618953630:d> is the date.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDate,
|
||||
},
|
||||
{type: NodeType.Text, content: ' is the date.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with short date & short time style', () => {
|
||||
const input = '<t:1618953630:s>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateShortTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with short date & medium time style', () => {
|
||||
const input = '<t:1618953630:S>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateMediumTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp invalid style', () => {
|
||||
const input = 'Check this <t:1618953630:z> time.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check this <t:1618953630:z> time.'}]);
|
||||
});
|
||||
|
||||
test('timestamp non numeric', () => {
|
||||
const input = 'Check <t:abc123> time.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check <t:abc123> time.'}]);
|
||||
});
|
||||
|
||||
test('timestamp with milliseconds should not parse', () => {
|
||||
const input = 'Time: <t:1618953630.123>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Time: <t:1618953630.123>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with partial milliseconds should not parse', () => {
|
||||
const input = '<t:1618953630.1>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.1>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with excess millisecond precision should not parse', () => {
|
||||
const input = '<t:1618953630.123456>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123456>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with milliseconds and style should not parse', () => {
|
||||
const input = '<t:1618953630.123:R>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123:R>'}]);
|
||||
});
|
||||
|
||||
test('timestamp in mixed content', () => {
|
||||
const input = 'Hello <a:wave:12345> 🦶 <t:1618953630:d> <:smile:9876>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'wave',
|
||||
id: '12345',
|
||||
animated: true,
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🦶',
|
||||
codepoints: '1f9b6',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDate,
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'smile',
|
||||
id: '9876',
|
||||
animated: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp edge cases', () => {
|
||||
const inputs = ['<t:>', '<t:1618953630:>', '<t::d>', '<t:1618953630::d>', '<t:1618953630:d:R>'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('very large valid timestamp', () => {
|
||||
const input = '<t:9999999999>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 9999999999,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with leading zeros', () => {
|
||||
const input = '<t:0001618953630>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp in code block should not be parsed', () => {
|
||||
const input = '```\n<t:1618953630>\n```';
|
||||
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: '<t:1618953630>\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp in inline code should not be parsed', () => {
|
||||
const input = '`<t:1618953630>`';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.InlineCode,
|
||||
content: '<t:1618953630>',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with non-digit characters should not parse', () => {
|
||||
const inputs = [
|
||||
'<t:12a34>',
|
||||
'<t:12.34>',
|
||||
'<t:12,34>',
|
||||
'<t:1234e5>',
|
||||
'<t:+1234>',
|
||||
'<t:-1234>',
|
||||
'<t:1234 >',
|
||||
'<t: 1234>',
|
||||
'<t:1_234>',
|
||||
'<t:0x1234>',
|
||||
'<t:0b1010>',
|
||||
'<t:123.456.789>',
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamp with valid integer formats', () => {
|
||||
const inputs = ['<t:1234>', '<t:01234>', '<t:9999999999>'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast[0].type).toBe(NodeType.Timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamp with zero value should not parse', () => {
|
||||
const parser = new Parser('<t:0>', 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:0>'}]);
|
||||
});
|
||||
});
|
||||
113
fluxer_app/src/lib/markdown/parser/parsers/timestamp-parsers.ts
Normal file
113
fluxer_app/src/lib/markdown/parser/parsers/timestamp-parsers.ts
Normal 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 '../types/enums';
|
||||
import type {ParserResult} from '../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;
|
||||
}
|
||||
}
|
||||
24
fluxer_app/src/lib/markdown/parser/types/constants.ts
Normal file
24
fluxer_app/src/lib/markdown/parser/types/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export const MAX_AST_NODES = 10000;
|
||||
export const MAX_INLINE_DEPTH = 10;
|
||||
export const MAX_LINES = 10000;
|
||||
export const MAX_LINE_LENGTH = 4096;
|
||||
export const MAX_LINK_URL_LENGTH = 2048;
|
||||
119
fluxer_app/src/lib/markdown/parser/types/enums.ts
Normal file
119
fluxer_app/src/lib/markdown/parser/types/enums.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export const ParserFlags = {
|
||||
ALLOW_SPOILERS: 1 << 0,
|
||||
ALLOW_HEADINGS: 1 << 1,
|
||||
ALLOW_LISTS: 1 << 2,
|
||||
ALLOW_CODE_BLOCKS: 1 << 3,
|
||||
ALLOW_MASKED_LINKS: 1 << 4,
|
||||
ALLOW_COMMAND_MENTIONS: 1 << 5,
|
||||
ALLOW_GUILD_NAVIGATIONS: 1 << 6,
|
||||
ALLOW_USER_MENTIONS: 1 << 7,
|
||||
ALLOW_ROLE_MENTIONS: 1 << 8,
|
||||
ALLOW_CHANNEL_MENTIONS: 1 << 9,
|
||||
ALLOW_EVERYONE_MENTIONS: 1 << 10,
|
||||
ALLOW_BLOCKQUOTES: 1 << 11,
|
||||
ALLOW_MULTILINE_BLOCKQUOTES: 1 << 12,
|
||||
ALLOW_SUBTEXT: 1 << 13,
|
||||
ALLOW_TABLES: 1 << 14,
|
||||
ALLOW_ALERTS: 1 << 15,
|
||||
ALLOW_AUTOLINKS: 1 << 16,
|
||||
} as const;
|
||||
export type ParserFlags = (typeof ParserFlags)[keyof typeof ParserFlags];
|
||||
|
||||
export const NodeType = {
|
||||
Text: 'Text',
|
||||
Blockquote: 'Blockquote',
|
||||
Strong: 'Strong',
|
||||
Emphasis: 'Emphasis',
|
||||
Underline: 'Underline',
|
||||
Strikethrough: 'Strikethrough',
|
||||
Spoiler: 'Spoiler',
|
||||
Heading: 'Heading',
|
||||
Subtext: 'Subtext',
|
||||
List: 'List',
|
||||
CodeBlock: 'CodeBlock',
|
||||
InlineCode: 'InlineCode',
|
||||
Sequence: 'Sequence',
|
||||
Link: 'Link',
|
||||
Mention: 'Mention',
|
||||
Timestamp: 'Timestamp',
|
||||
Emoji: 'Emoji',
|
||||
Table: 'Table',
|
||||
TableRow: 'TableRow',
|
||||
TableCell: 'TableCell',
|
||||
Alert: 'Alert',
|
||||
} as const;
|
||||
export type NodeType = (typeof NodeType)[keyof typeof NodeType];
|
||||
|
||||
export const AlertType = {
|
||||
Note: 'Note',
|
||||
Tip: 'Tip',
|
||||
Important: 'Important',
|
||||
Warning: 'Warning',
|
||||
Caution: 'Caution',
|
||||
} as const;
|
||||
export type AlertType = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
export const TableAlignment = {
|
||||
Left: 'Left',
|
||||
Center: 'Center',
|
||||
Right: 'Right',
|
||||
None: 'None',
|
||||
} as const;
|
||||
export type TableAlignment = (typeof TableAlignment)[keyof typeof TableAlignment];
|
||||
|
||||
export const TimestampStyle = {
|
||||
ShortTime: 'ShortTime',
|
||||
LongTime: 'LongTime',
|
||||
ShortDate: 'ShortDate',
|
||||
LongDate: 'LongDate',
|
||||
ShortDateTime: 'ShortDateTime',
|
||||
LongDateTime: 'LongDateTime',
|
||||
ShortDateShortTime: 'ShortDateShortTime',
|
||||
ShortDateMediumTime: 'ShortDateMediumTime',
|
||||
RelativeTime: 'RelativeTime',
|
||||
} as const;
|
||||
export type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle];
|
||||
|
||||
export const GuildNavKind = {
|
||||
Customize: 'Customize',
|
||||
Browse: 'Browse',
|
||||
Guide: 'Guide',
|
||||
LinkedRoles: 'LinkedRoles',
|
||||
} as const;
|
||||
export type GuildNavKind = (typeof GuildNavKind)[keyof typeof GuildNavKind];
|
||||
|
||||
export const MentionKind = {
|
||||
User: 'User',
|
||||
Channel: 'Channel',
|
||||
Role: 'Role',
|
||||
Command: 'Command',
|
||||
GuildNavigation: 'GuildNavigation',
|
||||
Everyone: 'Everyone',
|
||||
Here: 'Here',
|
||||
} as const;
|
||||
export type MentionKind = (typeof MentionKind)[keyof typeof MentionKind];
|
||||
|
||||
export const EmojiKind = {
|
||||
Standard: 'Standard',
|
||||
Custom: 'Custom',
|
||||
} as const;
|
||||
export type EmojiKind = (typeof EmojiKind)[keyof typeof EmojiKind];
|
||||
187
fluxer_app/src/lib/markdown/parser/types/nodes.ts
Normal file
187
fluxer_app/src/lib/markdown/parser/types/nodes.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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 type {AlertType, EmojiKind, GuildNavKind, MentionKind, NodeType, TableAlignment, TimestampStyle} from './enums';
|
||||
|
||||
interface BaseNode {
|
||||
type: (typeof NodeType)[keyof typeof NodeType];
|
||||
}
|
||||
|
||||
export interface TextNode extends BaseNode {
|
||||
type: typeof NodeType.Text;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BlockquoteNode extends BaseNode {
|
||||
type: typeof NodeType.Blockquote;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface FormattingNode extends BaseNode {
|
||||
type:
|
||||
| typeof NodeType.Strong
|
||||
| typeof NodeType.Emphasis
|
||||
| typeof NodeType.Underline
|
||||
| typeof NodeType.Strikethrough
|
||||
| typeof NodeType.Spoiler
|
||||
| typeof NodeType.Sequence;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface HeadingNode extends BaseNode {
|
||||
type: typeof NodeType.Heading;
|
||||
level: number;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SubtextNode extends BaseNode {
|
||||
type: typeof NodeType.Subtext;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface ListNode extends BaseNode {
|
||||
type: typeof NodeType.List;
|
||||
ordered: boolean;
|
||||
items: Array<ListItem>;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
children: Array<Node>;
|
||||
ordinal?: number;
|
||||
}
|
||||
|
||||
export interface CodeBlockNode extends BaseNode {
|
||||
type: typeof NodeType.CodeBlock;
|
||||
language?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InlineCodeNode extends BaseNode {
|
||||
type: typeof NodeType.InlineCode;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LinkNode extends BaseNode {
|
||||
type: typeof NodeType.Link;
|
||||
text?: Node;
|
||||
url: string;
|
||||
escaped: boolean;
|
||||
}
|
||||
|
||||
export interface MentionNode extends BaseNode {
|
||||
type: typeof NodeType.Mention;
|
||||
kind: MentionType;
|
||||
}
|
||||
|
||||
export interface TimestampNode extends BaseNode {
|
||||
type: typeof NodeType.Timestamp;
|
||||
timestamp: number;
|
||||
style: (typeof TimestampStyle)[keyof typeof TimestampStyle];
|
||||
}
|
||||
|
||||
export interface EmojiNode extends BaseNode {
|
||||
type: typeof NodeType.Emoji;
|
||||
kind: EmojiType;
|
||||
}
|
||||
|
||||
export interface SequenceNode extends BaseNode {
|
||||
type: typeof NodeType.Sequence;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface TableNode extends BaseNode {
|
||||
type: typeof NodeType.Table;
|
||||
header: TableRowNode;
|
||||
alignments: Array<(typeof TableAlignment)[keyof typeof TableAlignment]>;
|
||||
rows: Array<TableRowNode>;
|
||||
}
|
||||
|
||||
export interface TableRowNode extends BaseNode {
|
||||
type: typeof NodeType.TableRow;
|
||||
cells: Array<TableCellNode>;
|
||||
}
|
||||
|
||||
export interface TableCellNode extends BaseNode {
|
||||
type: typeof NodeType.TableCell;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface AlertNode extends BaseNode {
|
||||
type: typeof NodeType.Alert;
|
||||
alertType: (typeof AlertType)[keyof typeof AlertType];
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SpoilerNode extends BaseNode {
|
||||
type: typeof NodeType.Spoiler;
|
||||
children: Array<Node>;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
export type Node =
|
||||
| TextNode
|
||||
| BlockquoteNode
|
||||
| FormattingNode
|
||||
| HeadingNode
|
||||
| SubtextNode
|
||||
| ListNode
|
||||
| CodeBlockNode
|
||||
| InlineCodeNode
|
||||
| LinkNode
|
||||
| MentionNode
|
||||
| TimestampNode
|
||||
| EmojiNode
|
||||
| SequenceNode
|
||||
| TableNode
|
||||
| TableRowNode
|
||||
| TableCellNode
|
||||
| AlertNode
|
||||
| SpoilerNode;
|
||||
|
||||
type MentionType =
|
||||
| {kind: typeof MentionKind.User; id: string}
|
||||
| {kind: typeof MentionKind.Channel; id: string}
|
||||
| {kind: typeof MentionKind.Role; id: string}
|
||||
| {
|
||||
kind: typeof MentionKind.Command;
|
||||
name: string;
|
||||
subcommandGroup?: string;
|
||||
subcommand?: string;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: typeof MentionKind.GuildNavigation;
|
||||
navigationType: typeof GuildNavKind.Customize | typeof GuildNavKind.Browse | typeof GuildNavKind.Guide;
|
||||
}
|
||||
| {
|
||||
kind: typeof MentionKind.GuildNavigation;
|
||||
navigationType: typeof GuildNavKind.LinkedRoles;
|
||||
id?: string;
|
||||
}
|
||||
| {kind: typeof MentionKind.Everyone}
|
||||
| {kind: typeof MentionKind.Here};
|
||||
|
||||
type EmojiType =
|
||||
| {kind: typeof EmojiKind.Standard; raw: string; codepoints: string; name: string}
|
||||
| {kind: typeof EmojiKind.Custom; name: string; id: string; animated: boolean};
|
||||
|
||||
export interface ParserResult {
|
||||
node: Node;
|
||||
advance: number;
|
||||
}
|
||||
148
fluxer_app/src/lib/markdown/parser/utils/ast-utils.test.ts
Normal file
148
fluxer_app/src/lib/markdown/parser/utils/ast-utils.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {NodeType} from '../types/enums';
|
||||
import type {FormattingNode, Node, TextNode} from '../types/nodes';
|
||||
import {addTextNode, combineAdjacentTextNodes, flattenSameType, isFormattingNode, mergeTextNodes} from './ast-utils';
|
||||
|
||||
describe('AST Utils', () => {
|
||||
describe('isFormattingNode', () => {
|
||||
test('should identify formatting nodes', () => {
|
||||
const emphasisNode: FormattingNode = {
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'test'}],
|
||||
};
|
||||
const strongNode: FormattingNode = {
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'test'}],
|
||||
};
|
||||
const textNode: TextNode = {
|
||||
type: NodeType.Text,
|
||||
content: 'test',
|
||||
};
|
||||
|
||||
expect(isFormattingNode(emphasisNode)).toBe(true);
|
||||
expect(isFormattingNode(strongNode)).toBe(true);
|
||||
expect(isFormattingNode(textNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenSameType', () => {
|
||||
test('should flatten nodes of the same type', () => {
|
||||
const children: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'first'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested1'},
|
||||
{type: NodeType.Text, content: 'nested2'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'last'},
|
||||
];
|
||||
|
||||
flattenSameType(children, NodeType.Emphasis);
|
||||
expect(children).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle empty children arrays', () => {
|
||||
const children: Array<Node> = [];
|
||||
flattenSameType(children, NodeType.Text);
|
||||
expect(children).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineAdjacentTextNodes', () => {
|
||||
test('should combine adjacent text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'first'},
|
||||
{type: NodeType.Text, content: 'second'},
|
||||
{type: NodeType.Text, content: 'third'},
|
||||
];
|
||||
|
||||
combineAdjacentTextNodes(nodes);
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'firstsecondthird'});
|
||||
});
|
||||
|
||||
test('should not combine non-text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'text'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
|
||||
{type: NodeType.Text, content: 'more text'},
|
||||
];
|
||||
combineAdjacentTextNodes(nodes);
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'text'});
|
||||
expect(nodes[1].type).toBe(NodeType.Emphasis);
|
||||
expect(nodes[2]).toEqual({type: NodeType.Text, content: 'more text'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeTextNodes', () => {
|
||||
test('should merge text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'hello '},
|
||||
{type: NodeType.Text, content: 'world'},
|
||||
];
|
||||
const result = mergeTextNodes(nodes);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({type: NodeType.Text, content: 'hello world'});
|
||||
});
|
||||
|
||||
test('should handle mixed node types', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'start'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'middle'}]},
|
||||
{type: NodeType.Text, content: 'end'},
|
||||
];
|
||||
const result = mergeTextNodes(nodes);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({type: NodeType.Text, content: 'start'});
|
||||
expect(result[1].type).toBe(NodeType.Emphasis);
|
||||
expect(result[2]).toEqual({type: NodeType.Text, content: 'end'});
|
||||
});
|
||||
|
||||
test('should handle empty arrays', () => {
|
||||
const result = mergeTextNodes([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTextNode', () => {
|
||||
test('should add text node to array', () => {
|
||||
const nodes: Array<Node> = [];
|
||||
addTextNode(nodes, 'test content');
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'test content'});
|
||||
});
|
||||
|
||||
test('should handle empty text', () => {
|
||||
const nodes: Array<Node> = [];
|
||||
addTextNode(nodes, '');
|
||||
|
||||
expect(nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
522
fluxer_app/src/lib/markdown/parser/utils/ast-utils.ts
Normal file
522
fluxer_app/src/lib/markdown/parser/utils/ast-utils.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
* 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} from '../types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
FormattingNode,
|
||||
HeadingNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
Node,
|
||||
SequenceNode,
|
||||
SubtextNode,
|
||||
TableCellNode,
|
||||
TableNode,
|
||||
TableRowNode,
|
||||
TextNode,
|
||||
} from '../types/nodes';
|
||||
|
||||
const NT_TEXT = NodeType.Text;
|
||||
const NT_STRONG = NodeType.Strong;
|
||||
const NT_EMPHASIS = NodeType.Emphasis;
|
||||
const NT_UNDERLINE = NodeType.Underline;
|
||||
const NT_STRIKETHROUGH = NodeType.Strikethrough;
|
||||
const NT_SPOILER = NodeType.Spoiler;
|
||||
const NT_SEQUENCE = NodeType.Sequence;
|
||||
const NT_HEADING = NodeType.Heading;
|
||||
const NT_SUBTEXT = NodeType.Subtext;
|
||||
const NT_BLOCKQUOTE = NodeType.Blockquote;
|
||||
const NT_LIST = NodeType.List;
|
||||
const NT_LINK = NodeType.Link;
|
||||
const NT_TABLE = NodeType.Table;
|
||||
const NT_TABLE_ROW = NodeType.TableRow;
|
||||
const NT_TABLE_CELL = NodeType.TableCell;
|
||||
const NT_ALERT = NodeType.Alert;
|
||||
|
||||
const FORMATTING_NODE_TYPES: Set<(typeof NodeType)[keyof typeof NodeType]> = new Set([
|
||||
NT_STRONG,
|
||||
NT_EMPHASIS,
|
||||
NT_UNDERLINE,
|
||||
NT_STRIKETHROUGH,
|
||||
NT_SPOILER,
|
||||
NT_SEQUENCE,
|
||||
]);
|
||||
|
||||
export function flattenAST(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
flattenNode(nodes[i]);
|
||||
}
|
||||
|
||||
flattenChildren(nodes, false);
|
||||
}
|
||||
|
||||
function flattenNode(node: Node): void {
|
||||
const nodeType = node.type;
|
||||
|
||||
if (nodeType === NT_TEXT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (FORMATTING_NODE_TYPES.has(nodeType)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
const children = formattingNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
if (childCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (nodeType) {
|
||||
case NT_HEADING:
|
||||
case NT_SUBTEXT: {
|
||||
const typedNode = node as HeadingNode | SubtextNode;
|
||||
const children = typedNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_BLOCKQUOTE: {
|
||||
const blockquoteNode = node as BlockquoteNode;
|
||||
const children = blockquoteNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, true);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LIST: {
|
||||
const listNode = node as ListNode;
|
||||
const items = listNode.items;
|
||||
const itemCount = items.length;
|
||||
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const item = items[i];
|
||||
const itemChildren = item.children;
|
||||
const itemChildCount = itemChildren.length;
|
||||
|
||||
for (let j = 0; j < itemChildCount; j++) {
|
||||
flattenNode(itemChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(itemChildren, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LINK: {
|
||||
const linkNode = node as LinkNode;
|
||||
const text = linkNode.text;
|
||||
if (text) {
|
||||
flattenNode(text);
|
||||
if (text.type === NT_SEQUENCE) {
|
||||
const sequenceNode = text as SequenceNode;
|
||||
const seqChildren = sequenceNode.children;
|
||||
const seqChildCount = seqChildren.length;
|
||||
|
||||
for (let i = 0; i < seqChildCount; i++) {
|
||||
flattenNode(seqChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(seqChildren, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE: {
|
||||
const tableNode = node as TableNode;
|
||||
flattenTableRow(tableNode.header);
|
||||
|
||||
const rows = tableNode.rows;
|
||||
const rowCount = rows.length;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
flattenTableRow(rows[i]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE_ROW:
|
||||
flattenTableRow(node as TableRowNode);
|
||||
break;
|
||||
|
||||
case NT_TABLE_CELL: {
|
||||
const cellNode = node as TableCellNode;
|
||||
const cellChildren = cellNode.children;
|
||||
const cellChildCount = cellChildren.length;
|
||||
|
||||
for (let i = 0; i < cellChildCount; i++) {
|
||||
flattenNode(cellChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_ALERT: {
|
||||
const alertNode = node as AlertNode;
|
||||
const alertChildren = alertNode.children;
|
||||
const alertChildCount = alertChildren.length;
|
||||
|
||||
for (let i = 0; i < alertChildCount; i++) {
|
||||
flattenNode(alertChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(alertChildren, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTableRow(row: TableRowNode): void {
|
||||
const cells = row.cells;
|
||||
const cellCount = cells.length;
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const cell = cells[i];
|
||||
const cellChildren = cell.children;
|
||||
const childCount = cellChildren.length;
|
||||
|
||||
if (childCount === 0) continue;
|
||||
|
||||
for (let j = 0; j < childCount; j++) {
|
||||
flattenNode(cellChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenChildren(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenFormattingNodes(nodes);
|
||||
|
||||
combineAdjacentTextNodes(nodes, insideBlockquote);
|
||||
|
||||
removeEmptyTextNodesBetweenAlerts(nodes);
|
||||
}
|
||||
|
||||
function flattenFormattingNodes(nodes: Array<Node>): void {
|
||||
if (nodes.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < nodes.length) {
|
||||
const node = nodes[i];
|
||||
if (FORMATTING_NODE_TYPES.has(node.type)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
flattenSameType(formattingNode.children, node.type);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFormattingNode(node: Node): boolean {
|
||||
return FORMATTING_NODE_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
export function flattenSameType(children: Array<Node>, nodeType: NodeType): void {
|
||||
if (children.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFlattening = false;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type === nodeType) {
|
||||
needsFlattening = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsFlattening) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const result: Array<Node> = [];
|
||||
|
||||
while (i < children.length) {
|
||||
const child = children[i];
|
||||
if (child.type === nodeType && 'children' in child) {
|
||||
const innerNodes = (child as FormattingNode).children;
|
||||
for (let j = 0; j < innerNodes.length; j++) {
|
||||
result.push(innerNodes[j]);
|
||||
}
|
||||
} else {
|
||||
result.push(child);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
children.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
children.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function combineAdjacentTextNodes(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAdjacentTextNodes = false;
|
||||
let lastWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && lastWasText) {
|
||||
hasAdjacentTextNodes = true;
|
||||
break;
|
||||
}
|
||||
lastWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasAdjacentTextNodes && !insideBlockquote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
let currentText = '';
|
||||
let nonTextNodeSeen = false;
|
||||
|
||||
if (insideBlockquote) {
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
const isTextNode = node.type === NT_TEXT;
|
||||
|
||||
if (isTextNode) {
|
||||
if (nonTextNodeSeen) {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
nonTextNodeSeen = false;
|
||||
}
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
result.push(node);
|
||||
nonTextNodeSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
} else {
|
||||
let currentTextNode: TextNode | null = null;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
const textNode = node as TextNode;
|
||||
const content = textNode.content;
|
||||
|
||||
let isMalformedContent = false;
|
||||
if (content && (content[0] === '#' || (content[0] === '-' && content.length > 1 && content[1] === '#'))) {
|
||||
const trimmed = content.trim();
|
||||
isMalformedContent = trimmed.startsWith('#') || trimmed.startsWith('-#');
|
||||
}
|
||||
|
||||
if (isMalformedContent) {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push({type: NT_TEXT, content});
|
||||
} else if (currentTextNode) {
|
||||
const hasDoubleNewline = content.includes('\n\n');
|
||||
|
||||
if (hasDoubleNewline) {
|
||||
result.push(currentTextNode);
|
||||
result.push({type: NT_TEXT, content});
|
||||
currentTextNode = null;
|
||||
} else {
|
||||
currentTextNode.content += content;
|
||||
}
|
||||
} else {
|
||||
currentTextNode = {type: NT_TEXT, content};
|
||||
}
|
||||
} else {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyTextNodesBetweenAlerts(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAlert = false;
|
||||
let hasTextNode = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const type = nodes[i].type;
|
||||
hasAlert ||= type === NT_ALERT;
|
||||
hasTextNode ||= type === NT_TEXT;
|
||||
|
||||
if (hasAlert && hasTextNode) break;
|
||||
}
|
||||
|
||||
if (!hasAlert || !hasTextNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let emptyTextBetweenAlerts = false;
|
||||
for (let i = 1; i < nodeCount - 1; i++) {
|
||||
const current = nodes[i];
|
||||
if (
|
||||
current.type === NT_TEXT &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT &&
|
||||
(current as TextNode).content.trim() === ''
|
||||
) {
|
||||
emptyTextBetweenAlerts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emptyTextBetweenAlerts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const current = nodes[i];
|
||||
|
||||
if (
|
||||
i > 0 &&
|
||||
i < nodeCount - 1 &&
|
||||
current.type === NT_TEXT &&
|
||||
(current as TextNode).content.trim() === '' &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTextNodes(nodes: Array<Node>): Array<Node> {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
let hasConsecutiveTextNodes = false;
|
||||
let prevWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && prevWasText) {
|
||||
hasConsecutiveTextNodes = true;
|
||||
break;
|
||||
}
|
||||
prevWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasConsecutiveTextNodes) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const mergedNodes: Array<Node> = [];
|
||||
let currentText = '';
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
mergedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
|
||||
return mergedNodes;
|
||||
}
|
||||
|
||||
export function addTextNode(nodes: Array<Node>, text: string): void {
|
||||
if (text && text.length > 0) {
|
||||
nodes.push({type: NT_TEXT, content: text});
|
||||
}
|
||||
}
|
||||
120
fluxer_app/src/lib/markdown/parser/utils/string-utils.test.ts
Normal file
120
fluxer_app/src/lib/markdown/parser/utils/string-utils.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 {describe, expect, test} from 'vitest';
|
||||
import {isAlphaNumericChar, matchMarker, startsWithUrl} from './string-utils';
|
||||
|
||||
describe('String Utils', () => {
|
||||
describe('isAlphaNumericChar', () => {
|
||||
test('should return true for alphanumeric characters', () => {
|
||||
expect(isAlphaNumericChar('a')).toBe(true);
|
||||
expect(isAlphaNumericChar('Z')).toBe(true);
|
||||
expect(isAlphaNumericChar('5')).toBe(true);
|
||||
expect(isAlphaNumericChar('0')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for non-alphanumeric characters', () => {
|
||||
expect(isAlphaNumericChar('!')).toBe(false);
|
||||
expect(isAlphaNumericChar(' ')).toBe(false);
|
||||
expect(isAlphaNumericChar('@')).toBe(false);
|
||||
expect(isAlphaNumericChar('-')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for multi-character strings', () => {
|
||||
expect(isAlphaNumericChar('ab')).toBe(false);
|
||||
expect(isAlphaNumericChar('12')).toBe(false);
|
||||
expect(isAlphaNumericChar('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startsWithUrl', () => {
|
||||
test('should return true for valid HTTP URLs', () => {
|
||||
expect(startsWithUrl('http://example.com')).toBe(true);
|
||||
expect(startsWithUrl('http://sub.domain.com/path')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for valid HTTPS URLs', () => {
|
||||
expect(startsWithUrl('https://example.com')).toBe(true);
|
||||
expect(startsWithUrl('https://secure.site.org/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for URLs with quotes in protocol', () => {
|
||||
expect(startsWithUrl('ht"tp://example.com')).toBe(false);
|
||||
expect(startsWithUrl("htt'p://example.com")).toBe(false);
|
||||
expect(startsWithUrl('https"://example.com')).toBe(false);
|
||||
expect(startsWithUrl("http's://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for too short strings', () => {
|
||||
expect(startsWithUrl('http')).toBe(false);
|
||||
expect(startsWithUrl('https')).toBe(false);
|
||||
expect(startsWithUrl('http:/')).toBe(false);
|
||||
expect(startsWithUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for non-URL strings', () => {
|
||||
expect(startsWithUrl('ftp://example.com')).toBe(false);
|
||||
expect(startsWithUrl('mailto:test@example.com')).toBe(false);
|
||||
expect(startsWithUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchMarker', () => {
|
||||
test('should match single character markers', () => {
|
||||
const chars = ['a', 'b', 'c', 'd'];
|
||||
expect(matchMarker(chars, 0, 'a')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'b')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'x')).toBe(false);
|
||||
});
|
||||
|
||||
test('should match two character markers', () => {
|
||||
const chars = ['a', 'b', 'c', 'd'];
|
||||
expect(matchMarker(chars, 0, 'ab')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'bc')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'ac')).toBe(false);
|
||||
});
|
||||
|
||||
test('should match longer markers', () => {
|
||||
const chars = ['h', 'e', 'l', 'l', 'o'];
|
||||
expect(matchMarker(chars, 0, 'hello')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'ello')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'help')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when marker extends beyond array', () => {
|
||||
const chars = ['a', 'b'];
|
||||
expect(matchMarker(chars, 0, 'abc')).toBe(false);
|
||||
expect(matchMarker(chars, 1, 'bc')).toBe(false);
|
||||
expect(matchMarker(chars, 2, 'c')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle empty marker', () => {
|
||||
const chars = ['a', 'b', 'c'];
|
||||
expect(matchMarker(chars, 0, '')).toBe(true);
|
||||
expect(matchMarker(chars, 1, '')).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle edge positions', () => {
|
||||
const chars = ['x', 'y', 'z'];
|
||||
expect(matchMarker(chars, 0, 'x')).toBe(true);
|
||||
expect(matchMarker(chars, 2, 'z')).toBe(true);
|
||||
expect(matchMarker(chars, 3, 'a')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
fluxer_app/src/lib/markdown/parser/utils/string-utils.ts
Normal file
99
fluxer_app/src/lib/markdown/parser/utils/string-utils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
|
||||
const HTTP_PREFIX = 'http://';
|
||||
const HTTPS_PREFIX = 'https://';
|
||||
|
||||
const WORD_CHARS = new Set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_');
|
||||
|
||||
const ESCAPABLE_CHARS = new Set('[]()\\*_~`@!#$%^&+={}|:;"\'<>,.?/');
|
||||
|
||||
const URL_TERMINATION_CHARS = new Set(' \t\n\r)\'"');
|
||||
|
||||
function isWordCharacter(char: string): boolean {
|
||||
return char.length === 1 && WORD_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isEscapableCharacter(char: string): boolean {
|
||||
return char.length === 1 && ESCAPABLE_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isUrlTerminationChar(char: string): boolean {
|
||||
return char.length === 1 && URL_TERMINATION_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isWordUnderscore(chars: Array<string>, pos: number): boolean {
|
||||
if (chars[pos] !== '_') return false;
|
||||
|
||||
const prevChar = pos > 0 ? chars[pos - 1] : '';
|
||||
const nextChar = pos + 1 < chars.length ? chars[pos + 1] : '';
|
||||
|
||||
return isWordCharacter(prevChar) && isWordCharacter(nextChar);
|
||||
}
|
||||
|
||||
export function isAlphaNumeric(charCode: number): boolean {
|
||||
return (
|
||||
(charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAlphaNumericChar(char: string): boolean {
|
||||
return char.length === 1 && isAlphaNumeric(char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function startsWithUrl(text: string): boolean {
|
||||
if (text.length < 8) return false;
|
||||
|
||||
if (text.startsWith(HTTP_PREFIX)) {
|
||||
const prefixEnd = 7;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
if (text.startsWith(HTTPS_PREFIX)) {
|
||||
const prefixEnd = 8;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
if (text.startsWith(APP_PROTOCOL_PREFIX)) {
|
||||
const prefixEnd = APP_PROTOCOL_PREFIX.length;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchMarker(chars: Array<string>, pos: number, marker: string): boolean {
|
||||
if (pos + marker.length > chars.length) return false;
|
||||
|
||||
if (marker.length === 1) {
|
||||
return chars[pos] === marker;
|
||||
}
|
||||
|
||||
if (marker.length === 2) {
|
||||
return chars[pos] === marker[0] && chars[pos + 1] === marker[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < marker.length; i++) {
|
||||
if (chars[pos + i] !== marker[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
121
fluxer_app/src/lib/markdown/parser/utils/url-utils.ts
Normal file
121
fluxer_app/src/lib/markdown/parser/utils/url-utils.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 * as idna from 'idna-uts46-hx';
|
||||
|
||||
const HTTP_PROTOCOL = 'http:';
|
||||
const HTTPS_PROTOCOL = 'https:';
|
||||
const MAILTO_PROTOCOL = 'mailto:';
|
||||
const TEL_PROTOCOL = 'tel:';
|
||||
const SMS_PROTOCOL = 'sms:';
|
||||
const FLUXER_PROTOCOL = 'fluxer:';
|
||||
|
||||
const EMAIL_REGEX =
|
||||
/^[a-zA-Z0-9._%+-]+@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
const PHONE_REGEX = /^\+[1-9][\d\s\-()]+$/;
|
||||
|
||||
const SPECIAL_PROTOCOLS_REGEX = /^(mailto:|tel:|sms:|fluxer:)/;
|
||||
|
||||
const PROTOCOL_REGEX = /:\/\//;
|
||||
|
||||
const TRAILING_SLASH_REGEX = /\/+$/;
|
||||
|
||||
const NORMALIZE_PHONE_REGEX = /[\s\-()]/g;
|
||||
|
||||
function createUrlObject(url: string): URL | null {
|
||||
if (typeof url !== 'string') return null;
|
||||
|
||||
try {
|
||||
if (!PROTOCOL_REGEX.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return typeof email === 'string' && EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(phoneNumber: string): string {
|
||||
return phoneNumber.replace(NORMALIZE_PHONE_REGEX, '');
|
||||
}
|
||||
|
||||
export function isValidPhoneNumber(phoneNumber: string): boolean {
|
||||
if (typeof phoneNumber !== 'string' || !PHONE_REGEX.test(phoneNumber)) return false;
|
||||
|
||||
return normalizePhoneNumber(phoneNumber).length >= 7;
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
if (typeof url !== 'string') return url;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) {
|
||||
return url.replace(TRAILING_SLASH_REGEX, '');
|
||||
}
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? urlObj.toString() : url;
|
||||
}
|
||||
|
||||
function idnaEncodeURL(url: string): string {
|
||||
const urlObj = createUrlObject(url);
|
||||
if (!urlObj) return url;
|
||||
|
||||
try {
|
||||
urlObj.hostname = idna.toAscii(urlObj.hostname).toLowerCase();
|
||||
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToAsciiUrl(url: string): string {
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) return url;
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? idnaEncodeURL(url) : url;
|
||||
}
|
||||
|
||||
export function isValidUrl(urlStr: string): boolean {
|
||||
if (typeof urlStr !== 'string') return false;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(urlStr)) return true;
|
||||
|
||||
const urlObj = createUrlObject(urlStr);
|
||||
if (!urlObj) return false;
|
||||
|
||||
const {protocol} = urlObj;
|
||||
return (
|
||||
protocol === HTTP_PROTOCOL ||
|
||||
protocol === HTTPS_PROTOCOL ||
|
||||
protocol === MAILTO_PROTOCOL ||
|
||||
protocol === TEL_PROTOCOL ||
|
||||
protocol === SMS_PROTOCOL ||
|
||||
protocol === FLUXER_PROTOCOL
|
||||
);
|
||||
}
|
||||
317
fluxer_app/src/lib/markdown/plaintext.tsx
Normal file
317
fluxer_app/src/lib/markdown/plaintext.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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 type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import {Parser} from './parser/parser/parser';
|
||||
import {EmojiKind, GuildNavKind, MentionKind, NodeType} from './parser/types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
CodeBlockNode,
|
||||
EmojiNode,
|
||||
FormattingNode,
|
||||
HeadingNode,
|
||||
InlineCodeNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
MentionNode,
|
||||
Node,
|
||||
SequenceNode,
|
||||
SpoilerNode,
|
||||
SubtextNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
TimestampNode,
|
||||
} from './parser/types/nodes';
|
||||
import {formatTimestamp} from './utils/date-formatter';
|
||||
|
||||
interface PlaintextRenderOptions {
|
||||
channelId?: string;
|
||||
preserveMarkdown?: boolean;
|
||||
includeEmojiNames?: boolean;
|
||||
i18n?: I18n;
|
||||
}
|
||||
|
||||
function renderNodeToPlaintext(node: Node, options: PlaintextRenderOptions = {}): string {
|
||||
switch (node.type) {
|
||||
case NodeType.Text:
|
||||
return (node as TextNode).content;
|
||||
|
||||
case NodeType.Strong: {
|
||||
const strongNode = node as FormattingNode;
|
||||
const strongContent = renderNodesToPlaintext(strongNode.children, options);
|
||||
return options.preserveMarkdown ? `**${strongContent}**` : strongContent;
|
||||
}
|
||||
|
||||
case NodeType.Emphasis: {
|
||||
const emphasisNode = node as FormattingNode;
|
||||
const emphasisContent = renderNodesToPlaintext(emphasisNode.children, options);
|
||||
return options.preserveMarkdown ? `*${emphasisContent}*` : emphasisContent;
|
||||
}
|
||||
|
||||
case NodeType.Underline: {
|
||||
const underlineNode = node as FormattingNode;
|
||||
const underlineContent = renderNodesToPlaintext(underlineNode.children, options);
|
||||
return options.preserveMarkdown ? `__${underlineContent}__` : underlineContent;
|
||||
}
|
||||
|
||||
case NodeType.Strikethrough: {
|
||||
const strikethroughNode = node as FormattingNode;
|
||||
const strikethroughContent = renderNodesToPlaintext(strikethroughNode.children, options);
|
||||
return options.preserveMarkdown ? `~~${strikethroughContent}~~` : strikethroughContent;
|
||||
}
|
||||
|
||||
case NodeType.Spoiler: {
|
||||
const spoilerNode = node as SpoilerNode;
|
||||
const spoilerContent = renderNodesToPlaintext(spoilerNode.children, options);
|
||||
return options.preserveMarkdown ? `||${spoilerContent}||` : spoilerContent;
|
||||
}
|
||||
|
||||
case NodeType.Heading: {
|
||||
const headingNode = node as HeadingNode;
|
||||
const headingContent = renderNodesToPlaintext(headingNode.children, options);
|
||||
const headingPrefix = options.preserveMarkdown ? `${'#'.repeat(headingNode.level)} ` : '';
|
||||
return `${headingPrefix}${headingContent}`;
|
||||
}
|
||||
|
||||
case NodeType.Subtext: {
|
||||
const subtextNode = node as SubtextNode;
|
||||
return renderNodesToPlaintext(subtextNode.children, options);
|
||||
}
|
||||
|
||||
case NodeType.List: {
|
||||
const listNode = node as ListNode;
|
||||
return listNode.items
|
||||
.map((item, index) => {
|
||||
const content = renderNodesToPlaintext(item.children, options);
|
||||
if (listNode.ordered) {
|
||||
const ordinal = item.ordinal ?? index + 1;
|
||||
return `${ordinal}. ${content}`;
|
||||
}
|
||||
return `• ${content}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
case NodeType.CodeBlock: {
|
||||
const codeBlockNode = node as CodeBlockNode;
|
||||
return options.preserveMarkdown
|
||||
? `\`\`\`${codeBlockNode.language || ''}\n${codeBlockNode.content}\`\`\``
|
||||
: codeBlockNode.content;
|
||||
}
|
||||
|
||||
case NodeType.InlineCode: {
|
||||
const inlineCodeNode = node as InlineCodeNode;
|
||||
return options.preserveMarkdown ? `\`${inlineCodeNode.content}\`` : inlineCodeNode.content;
|
||||
}
|
||||
|
||||
case NodeType.Link: {
|
||||
const linkNode = node as LinkNode;
|
||||
if (linkNode.text) {
|
||||
const linkText = renderNodeToPlaintext(linkNode.text, options);
|
||||
return options.preserveMarkdown ? `[${linkText}](${linkNode.url})` : linkText;
|
||||
}
|
||||
return linkNode.url;
|
||||
}
|
||||
|
||||
case NodeType.Mention:
|
||||
return renderMentionToPlaintext(node as MentionNode, options);
|
||||
|
||||
case NodeType.Timestamp: {
|
||||
const timestampNode = node as TimestampNode;
|
||||
return formatTimestamp(timestampNode.timestamp, timestampNode.style, options.i18n!);
|
||||
}
|
||||
|
||||
case NodeType.Emoji:
|
||||
return renderEmojiToPlaintext(node as EmojiNode, options);
|
||||
|
||||
case NodeType.Blockquote: {
|
||||
const blockquoteNode = node as BlockquoteNode;
|
||||
const blockquoteContent = renderNodesToPlaintext(blockquoteNode.children, options);
|
||||
if (options.preserveMarkdown) {
|
||||
return blockquoteContent
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
return blockquoteContent;
|
||||
}
|
||||
|
||||
case NodeType.Sequence: {
|
||||
const sequenceNode = node as SequenceNode;
|
||||
return renderNodesToPlaintext(sequenceNode.children, options);
|
||||
}
|
||||
|
||||
case NodeType.Table: {
|
||||
const tableNode = node as TableNode;
|
||||
const headerContent = tableNode.header.cells
|
||||
.map((cell) => renderNodesToPlaintext(cell.children, options))
|
||||
.join(' | ');
|
||||
const rowsContent = tableNode.rows
|
||||
.map((row) => row.cells.map((cell) => renderNodesToPlaintext(cell.children, options)).join(' | '))
|
||||
.join('\n');
|
||||
return `${headerContent}\n${rowsContent}`;
|
||||
}
|
||||
|
||||
case NodeType.Alert: {
|
||||
const alertNode = node as AlertNode;
|
||||
const alertContent = renderNodesToPlaintext(alertNode.children, options);
|
||||
const alertPrefix = `[${alertNode.alertType.toUpperCase()}] `;
|
||||
return `${alertPrefix}${alertContent}`;
|
||||
}
|
||||
|
||||
case NodeType.TableRow:
|
||||
case NodeType.TableCell:
|
||||
return '';
|
||||
|
||||
default: {
|
||||
const nodeType = typeof (node as {type?: unknown}).type === 'string' ? (node as {type: string}).type : 'unknown';
|
||||
console.warn(`Unknown node type for plaintext rendering: ${nodeType}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentionToPlaintext(node: MentionNode, options: PlaintextRenderOptions): string {
|
||||
const {kind} = node;
|
||||
const i18n = options.i18n!;
|
||||
|
||||
switch (kind.kind) {
|
||||
case MentionKind.User: {
|
||||
const user = UserStore.getUser(kind.id);
|
||||
if (!user) {
|
||||
return `@${kind.id}`;
|
||||
}
|
||||
|
||||
let name = user.displayName;
|
||||
if (options.channelId) {
|
||||
const channel = ChannelStore.getChannel(options.channelId);
|
||||
if (channel?.guildId) {
|
||||
name = NicknameUtils.getNickname(user, channel.guildId) || name;
|
||||
}
|
||||
}
|
||||
return `@${name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Role: {
|
||||
const channel = options.channelId ? ChannelStore.getChannel(options.channelId) : null;
|
||||
const guild = GuildStore.getGuild(channel?.guildId ?? '');
|
||||
const role = guild ? guild.roles[kind.id] : null;
|
||||
if (!role) {
|
||||
return `@${i18n._(msg`unknown-role`)}`;
|
||||
}
|
||||
return `@${role.name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Channel: {
|
||||
const channel = ChannelStore.getChannel(kind.id);
|
||||
if (!channel || !TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
return `#${i18n._(msg`unknown-channel`)}`;
|
||||
}
|
||||
return `#${channel.name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Everyone:
|
||||
return '@everyone';
|
||||
|
||||
case MentionKind.Here:
|
||||
return '@here';
|
||||
|
||||
case MentionKind.Command: {
|
||||
const {name, subcommandGroup, subcommand} = kind;
|
||||
let commandName = `/${name}`;
|
||||
if (subcommandGroup) {
|
||||
commandName += ` ${subcommandGroup}`;
|
||||
}
|
||||
if (subcommand) {
|
||||
commandName += ` ${subcommand}`;
|
||||
}
|
||||
return commandName;
|
||||
}
|
||||
|
||||
case MentionKind.GuildNavigation: {
|
||||
const {navigationType} = kind;
|
||||
switch (navigationType) {
|
||||
case GuildNavKind.Customize:
|
||||
return '#customize';
|
||||
case GuildNavKind.Browse:
|
||||
return '#browse';
|
||||
case GuildNavKind.Guide:
|
||||
return '#guide';
|
||||
case GuildNavKind.LinkedRoles: {
|
||||
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
|
||||
return linkedRolesId ? `#linked-roles:${linkedRolesId}` : '#linked-roles';
|
||||
}
|
||||
default:
|
||||
return `#${navigationType}`;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return `@${i18n._(msg`unknown-mention`)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmojiToPlaintext(node: EmojiNode, options: PlaintextRenderOptions): string {
|
||||
const {kind} = node;
|
||||
|
||||
if (kind.kind === EmojiKind.Standard) {
|
||||
return kind.raw;
|
||||
}
|
||||
|
||||
if (options.includeEmojiNames !== false) {
|
||||
return `:${kind.name}:`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderNodesToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
|
||||
return nodes.map((node) => renderNodeToPlaintext(node, options)).join('');
|
||||
}
|
||||
|
||||
function renderToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
|
||||
const result = renderNodesToPlaintext(nodes, options);
|
||||
|
||||
return result
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAndRenderToPlaintext(
|
||||
content: string,
|
||||
parserFlags: number,
|
||||
options: PlaintextRenderOptions = {},
|
||||
): string {
|
||||
try {
|
||||
const parser = new Parser(content, parserFlags);
|
||||
const {nodes} = parser.parse();
|
||||
return renderToPlaintext(nodes, options);
|
||||
} catch (error) {
|
||||
console.error('Error parsing content for plaintext rendering:', error);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
170
fluxer_app/src/lib/markdown/renderers/MessageJumpLink.module.css
Normal file
170
fluxer_app/src/lib/markdown/renderers/MessageJumpLink.module.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.jumpLinkButton {
|
||||
border: 1px solid var(--markup-mention-border);
|
||||
padding: 0.1rem 0.35rem;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
background-color: var(--markup-jump-link-fill);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.jumpLinkButton:hover {
|
||||
background-color: var(--markup-jump-link-hover-fill);
|
||||
}
|
||||
|
||||
.jumpLinkInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
min-height: 1rem;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.jumpLinkGuild {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
|
||||
.jumpLinkGuildIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.jumpLinkGuildIcon > svg,
|
||||
.jumpLinkGuildIcon > img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jumpLinkGuildName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.jumpLinkCaret {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.jumpLinkLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkDM {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkDMName {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
|
||||
.jumpLinkMessage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jumpLinkMessageIcon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.jumpLinkMessageIcon > svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jumpLinkChannel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.jumpLinkChannelIcon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jumpLinkChannelIcon > svg {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkChannelName {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
235
fluxer_app/src/lib/markdown/renderers/common/block-elements.tsx
Normal file
235
fluxer_app/src/lib/markdown/renderers/common/block-elements.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {
|
||||
CircleWavyWarningIcon,
|
||||
InfoIcon,
|
||||
LightbulbFilamentIcon,
|
||||
WarningCircleIcon,
|
||||
WarningIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {AlertType} from '../../parser/types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
HeadingNode,
|
||||
ListItem,
|
||||
ListNode,
|
||||
SequenceNode,
|
||||
SubtextNode,
|
||||
TableNode,
|
||||
} from '../../parser/types/nodes';
|
||||
import {MarkdownContext, type RendererProps} from '..';
|
||||
|
||||
export const BlockquoteRenderer = observer(function BlockquoteRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<BlockquoteNode>): React.ReactElement {
|
||||
return (
|
||||
<div key={id} className={markupStyles.blockquoteContainer}>
|
||||
<div className={markupStyles.blockquoteDivider} />
|
||||
<blockquote className={markupStyles.blockquoteContent}>{renderChildren(node.children)}</blockquote>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ListRenderer = observer(function ListRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<ListNode>): React.ReactElement {
|
||||
const Tag = node.ordered ? 'ol' : 'ul';
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
if (!node.ordered) {
|
||||
return (
|
||||
<Tag key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{node.items.map((item, i) => (
|
||||
<li key={`${id}-item-${i}`} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(item.children)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const segments: Array<{startOrdinal: number; items: Array<ListItem>}> = [];
|
||||
let currentSegment: Array<ListItem> = [];
|
||||
let currentOrdinal = node.items[0]?.ordinal || 1;
|
||||
|
||||
node.items.forEach((item, i) => {
|
||||
const itemOrdinal = item.ordinal !== undefined ? item.ordinal : i === 0 ? 1 : currentOrdinal + 1;
|
||||
|
||||
if (itemOrdinal !== currentOrdinal && i > 0) {
|
||||
segments.push({
|
||||
startOrdinal: currentSegment[0].ordinal || 1,
|
||||
items: [...currentSegment],
|
||||
});
|
||||
currentSegment = [];
|
||||
}
|
||||
|
||||
currentSegment.push({...item, ordinal: itemOrdinal});
|
||||
currentOrdinal = itemOrdinal + 1;
|
||||
});
|
||||
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push({
|
||||
startOrdinal: currentSegment[0].ordinal || 1,
|
||||
items: [...currentSegment],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
{segments.map((segment, segmentIndex) => {
|
||||
let maxDigits = 1;
|
||||
if (node.items.length > 0) {
|
||||
const largestNumber = Math.max(segment.startOrdinal, segment.startOrdinal + segment.items.length - 1);
|
||||
maxDigits = String(largestNumber).length;
|
||||
}
|
||||
|
||||
const listStyle = {
|
||||
'--totalCharacters': maxDigits,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
key={`${id}-segment-${segmentIndex}`}
|
||||
className={isInlineContext ? markupStyles.inlineFormat : undefined}
|
||||
start={segment.startOrdinal}
|
||||
style={listStyle}
|
||||
>
|
||||
{segment.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${id}-segment-${segmentIndex}-item-${itemIndex}`}
|
||||
className={clsx(isInlineContext && markupStyles.inlineFormat)}
|
||||
>
|
||||
{renderChildren(item.children)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
export const HeadingRenderer = observer(function HeadingRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<HeadingNode>): React.ReactElement {
|
||||
const Tag = `h${node.level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (headingRef.current && !isInlineContext && node.level <= 3) {
|
||||
const headingId = headingRef.current.textContent
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
if (headingId) {
|
||||
headingRef.current.id = headingId;
|
||||
}
|
||||
}
|
||||
}, [isInlineContext, node.level]);
|
||||
|
||||
return (
|
||||
<Tag ref={headingRef} key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(node.children)}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
|
||||
export const SubtextRenderer = observer(function SubtextRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<SubtextNode>): React.ReactElement {
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
return (
|
||||
<small key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(node.children)}
|
||||
</small>
|
||||
);
|
||||
});
|
||||
|
||||
export const SequenceRenderer = observer(function SequenceRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<SequenceNode>): React.ReactElement {
|
||||
return <React.Fragment key={id}>{renderChildren(node.children)}</React.Fragment>;
|
||||
});
|
||||
|
||||
export const TableRenderer = observer(function TableRenderer(_props: RendererProps<TableNode>): React.ReactElement {
|
||||
throw new Error('unsupported');
|
||||
});
|
||||
|
||||
export const AlertRenderer = observer(function AlertRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<AlertNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const alertConfig: Record<
|
||||
AlertType,
|
||||
{
|
||||
Icon: React.ComponentType<{className?: string}>;
|
||||
className: string;
|
||||
title: string;
|
||||
}
|
||||
> = {
|
||||
[AlertType.Note]: {Icon: InfoIcon, className: markupStyles.alertNote, title: i18n._(msg`Note`)},
|
||||
[AlertType.Tip]: {Icon: LightbulbFilamentIcon, className: markupStyles.alertTip, title: i18n._(msg`Tip`)},
|
||||
[AlertType.Important]: {Icon: WarningIcon, className: markupStyles.alertImportant, title: i18n._(msg`Important`)},
|
||||
[AlertType.Warning]: {
|
||||
Icon: CircleWavyWarningIcon,
|
||||
className: markupStyles.alertWarning,
|
||||
title: i18n._(msg`Warning`),
|
||||
},
|
||||
[AlertType.Caution]: {Icon: WarningCircleIcon, className: markupStyles.alertCaution, title: i18n._(msg`Caution`)},
|
||||
};
|
||||
|
||||
const {Icon, className, title} = alertConfig[node.alertType] || alertConfig[AlertType.Note];
|
||||
|
||||
return (
|
||||
<div key={id} className={clsx(markupStyles.alert, className)}>
|
||||
<div className={markupStyles.alertTitle}>
|
||||
<Icon className={markupStyles.alertIcon} />
|
||||
{title}
|
||||
</div>
|
||||
<div className={markupStyles.alertContent}>{renderChildren(node.children)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
144
fluxer_app/src/lib/markdown/renderers/common/code-elements.tsx
Normal file
144
fluxer_app/src/lib/markdown/renderers/common/code-elements.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import highlight from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import codeElementsStyles from '~/styles/CodeElements.module.css';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import type {CodeBlockNode, InlineCodeNode} from '../../parser/types/nodes';
|
||||
import type {RendererProps} from '..';
|
||||
|
||||
export const CodeBlockRenderer = observer(function CodeBlockRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<CodeBlockNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const {content, language} = node;
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
TextCopyActionCreators.copy(i18n, content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyButton = (
|
||||
<div className={markupStyles.codeActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? i18n._(msg`Copied!`) : i18n._(msg`Copy code`)}
|
||||
className={clsx(isCopied && markupStyles.codeActionsVisible)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckCircleIcon className={codeElementsStyles.icon} />
|
||||
) : (
|
||||
<ClipboardIcon className={codeElementsStyles.icon} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (language?.toLowerCase() === 'latex' || language?.toLowerCase() === 'tex') {
|
||||
try {
|
||||
const html = katex.renderToString(content, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
errorColor: 'var(--accent-danger)',
|
||||
trust: false,
|
||||
strict: false,
|
||||
output: 'html',
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={id} className={markupStyles.latexCodeBlock}>
|
||||
<div className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<div
|
||||
className={markupStyles.latexContent}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: KaTeX output is sanitized
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('KaTeX rendering error:', error);
|
||||
return (
|
||||
<div key={id} className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<pre>
|
||||
<code className={markupStyles.hljs}>
|
||||
{i18n._(msg`Error rendering LaTeX: ${(error as Error).message || i18n._(msg`Unknown error`)}`)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let highlightedContent: React.ReactElement;
|
||||
|
||||
if (language && highlight.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = highlight.highlight(content, {
|
||||
language: language,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
|
||||
highlightedContent = (
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js output is sanitized
|
||||
<code className={clsx(markupStyles.hljs, language)} dangerouslySetInnerHTML={{__html: highlighted.value}} />
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Syntax highlighting error:', error);
|
||||
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
|
||||
}
|
||||
} else {
|
||||
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={id} className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<pre>{highlightedContent}</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const InlineCodeRenderer = observer(function InlineCodeRenderer({
|
||||
node,
|
||||
id,
|
||||
}: RendererProps<InlineCodeNode>): React.ReactElement {
|
||||
const normalizedContent = node.content.replace(/\s+/g, ' ');
|
||||
|
||||
return (
|
||||
<code key={id} className={markupStyles.inline}>
|
||||
{normalizedContent}
|
||||
</code>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {normalizeUrl, useSpoilerState} from '~/utils/SpoilerUtils';
|
||||
import {NodeType} from '../../parser/types/enums';
|
||||
import type {FormattingNode, Node} from '../../parser/types/nodes';
|
||||
import type {RendererProps} from '..';
|
||||
|
||||
export const StrongRenderer = observer(function StrongRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <strong key={id}>{renderChildren(node.children)}</strong>;
|
||||
});
|
||||
|
||||
export const EmphasisRenderer = observer(function EmphasisRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <em key={id}>{renderChildren(node.children)}</em>;
|
||||
});
|
||||
|
||||
export const UnderlineRenderer = observer(function UnderlineRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <u key={id}>{renderChildren(node.children)}</u>;
|
||||
});
|
||||
|
||||
export const StrikethroughRenderer = observer(function StrikethroughRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <s key={id}>{renderChildren(node.children)}</s>;
|
||||
});
|
||||
|
||||
interface SpoilerNode extends FormattingNode {
|
||||
type: typeof NodeType.Spoiler;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
export const SpoilerRenderer = observer(function SpoilerRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<SpoilerNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const collectUrls = useCallback((nodes: Array<Node>): Array<string> => {
|
||||
const urls: Array<string> = [];
|
||||
for (const child of nodes) {
|
||||
if (child.type === NodeType.Link) {
|
||||
const normalized = normalizeUrl(child.url);
|
||||
if (normalized) urls.push(normalized);
|
||||
}
|
||||
|
||||
if ('children' in child && Array.isArray((child as {children?: Array<Node>}).children)) {
|
||||
urls.push(...collectUrls((child as {children: Array<Node>}).children));
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}, []);
|
||||
|
||||
const spoilerUrls = useMemo(() => Array.from(new Set(collectUrls(node.children))), [collectUrls, node.children]);
|
||||
const {hidden, reveal, autoRevealed} = useSpoilerState(true, options.channelId, spoilerUrls);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (hidden) {
|
||||
reveal();
|
||||
}
|
||||
}, [hidden, reveal]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const isBlock = node.isBlock;
|
||||
const wrapperClass = isBlock ? markupStyles.blockSpoilerWrapper : markupStyles.spoilerWrapper;
|
||||
const spoilerClass = isBlock ? markupStyles.blockSpoiler : markupStyles.spoiler;
|
||||
|
||||
const shouldReveal = !hidden || autoRevealed;
|
||||
|
||||
return (
|
||||
<span key={id} className={wrapperClass}>
|
||||
{shouldReveal ? (
|
||||
<span className={spoilerClass} data-revealed={shouldReveal}>
|
||||
<span className={markupStyles.spoilerContent} aria-hidden={!shouldReveal}>
|
||||
{renderChildren(node.children)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={spoilerClass}
|
||||
data-revealed={shouldReveal}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={i18n._(msg`Click to reveal spoiler`)}
|
||||
>
|
||||
<span className={markupStyles.spoilerContent} aria-hidden>
|
||||
{renderChildren(node.children)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
280
fluxer_app/src/lib/markdown/renderers/emoji-renderer.tsx
Normal file
280
fluxer_app/src/lib/markdown/renderers/emoji-renderer.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* 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 {FloatingPortal} from '@floating-ui/react';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {EmojiInfoBottomSheet} from '~/components/bottomsheets/EmojiInfoBottomSheet';
|
||||
import {EmojiInfoContent} from '~/components/emojis/EmojiInfoContent';
|
||||
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
|
||||
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
||||
import {useMergeRefs} from '~/hooks/useMergeRefs';
|
||||
import {useReactionTooltip} from '~/hooks/useReactionTooltip';
|
||||
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
|
||||
import {EmojiKind} from '../parser/types/enums';
|
||||
import type {EmojiNode} from '../parser/types/nodes';
|
||||
import {getEmojiRenderData} from '../utils/emoji-detector';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
interface EmojiBottomSheetState {
|
||||
isOpen: boolean;
|
||||
emoji: {id?: string; name: string; animated?: boolean} | null;
|
||||
}
|
||||
|
||||
interface EmojiWithTooltipProps {
|
||||
children: React.ReactElement<Record<string, unknown> & {ref?: React.Ref<HTMLElement>}>;
|
||||
emojiUrl: string | null;
|
||||
nativeEmoji?: React.ReactNode;
|
||||
emojiName: string;
|
||||
emojiForSubtext: Emoji;
|
||||
}
|
||||
|
||||
const EmojiWithTooltip = observer(
|
||||
({children, emojiUrl, nativeEmoji, emojiName, emojiForSubtext}: EmojiWithTooltipProps) => {
|
||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
||||
|
||||
const childRef = children.props.ref ?? null;
|
||||
const mergedRef = useMergeRefs([targetRef, childRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
...handlers,
|
||||
} as Record<string, unknown>)}
|
||||
{state.isOpen && (
|
||||
<FloatingPortal root={tooltipPortalRoot}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={(node) => {
|
||||
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (node && targetRef.current) {
|
||||
updatePosition();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
zIndex: 'var(--z-index-tooltip)',
|
||||
visibility: state.isReady ? 'visible' : 'hidden',
|
||||
}}
|
||||
initial={{opacity: 0, scale: 0.98}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.98}}
|
||||
transition={{
|
||||
opacity: {duration: 0.1},
|
||||
scale: {type: 'spring', damping: 25, stiffness: 500},
|
||||
}}
|
||||
{...tooltipHandlers}
|
||||
>
|
||||
<EmojiTooltipContent
|
||||
emoji={nativeEmoji}
|
||||
emojiUrl={emojiUrl}
|
||||
emojiAlt={emojiName}
|
||||
primaryContent={emojiName}
|
||||
subtext={<EmojiInfoContent emoji={emojiForSubtext} />}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const EmojiRendererInner = observer(function EmojiRendererInner({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<EmojiNode>): React.ReactElement {
|
||||
const {shouldJumboEmojis, guildId, messageId, disableAnimatedEmoji} = options;
|
||||
const i18n = options.i18n!;
|
||||
const emojiData = getEmojiRenderData(node, guildId, disableAnimatedEmoji);
|
||||
const isMobile = MobileLayoutStore.enabled;
|
||||
|
||||
const [bottomSheetState, setBottomSheetState] = React.useState<EmojiBottomSheetState>({
|
||||
isOpen: false,
|
||||
emoji: null,
|
||||
});
|
||||
|
||||
const className = clsx('emoji', shouldJumboEmojis && 'jumboable');
|
||||
|
||||
const size = shouldJumboEmojis ? 240 : 96;
|
||||
const qualitySuffix = `?size=${size}&quality=lossless`;
|
||||
const tooltipEmojiSize = 240;
|
||||
const tooltipQualitySuffix = `?size=${tooltipEmojiSize}&quality=lossless`;
|
||||
|
||||
const isCustomEmoji = node.kind.kind === EmojiKind.Custom;
|
||||
const emojiRecord = isCustomEmoji && emojiData.id ? EmojiStore.getEmojiById(emojiData.id) : null;
|
||||
|
||||
const handleOpenBottomSheet = React.useCallback(() => {
|
||||
if (!isMobile) return;
|
||||
|
||||
const emojiInfo = {
|
||||
name: node.kind.name,
|
||||
id: isCustomEmoji ? (node.kind as {id: string}).id : undefined,
|
||||
animated: isCustomEmoji ? (node.kind as {animated: boolean}).animated : false,
|
||||
};
|
||||
|
||||
setBottomSheetState({isOpen: true, emoji: emojiInfo});
|
||||
}, [isMobile, node.kind, isCustomEmoji]);
|
||||
|
||||
const handleCloseBottomSheet = React.useCallback(() => {
|
||||
setBottomSheetState({isOpen: false, emoji: null});
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleOpenBottomSheet();
|
||||
}
|
||||
},
|
||||
[handleOpenBottomSheet],
|
||||
);
|
||||
|
||||
const buildEmojiForSubtext = React.useCallback((): Emoji => {
|
||||
if (emojiRecord) {
|
||||
return emojiRecord;
|
||||
}
|
||||
return {
|
||||
name: node.kind.name,
|
||||
allNamesString: node.kind.name,
|
||||
uniqueName: node.kind.name,
|
||||
};
|
||||
}, [emojiRecord, node.kind.name]);
|
||||
|
||||
const getTooltipData = React.useCallback(() => {
|
||||
const emojiUrl =
|
||||
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard
|
||||
? null
|
||||
: `${emojiData.url}${emojiData.id ? tooltipQualitySuffix : ''}`;
|
||||
|
||||
const nativeEmoji =
|
||||
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard ? (
|
||||
<span className={clsx('emoji', 'jumboable')}>{node.kind.raw}</span>
|
||||
) : undefined;
|
||||
|
||||
const emojiForSubtext = buildEmojiForSubtext();
|
||||
|
||||
return {emojiUrl, nativeEmoji, emojiForSubtext};
|
||||
}, [emojiData, node.kind, buildEmojiForSubtext, tooltipQualitySuffix]);
|
||||
|
||||
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.opacity = '0.5';
|
||||
target.alt = `${emojiData.name} ${i18n._(msg`(failed to load)`)}`;
|
||||
};
|
||||
|
||||
if (shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={className}
|
||||
data-message-id={messageId}
|
||||
onClick={handleOpenBottomSheet}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{node.kind.raw}
|
||||
</span>
|
||||
<EmojiInfoBottomSheet
|
||||
isOpen={bottomSheetState.isOpen}
|
||||
onClose={handleCloseBottomSheet}
|
||||
emoji={bottomSheetState.emoji}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipData = getTooltipData();
|
||||
return (
|
||||
<EmojiWithTooltip
|
||||
key={id}
|
||||
emojiUrl={tooltipData.emojiUrl}
|
||||
nativeEmoji={tooltipData.nativeEmoji}
|
||||
emojiName={emojiData.name}
|
||||
emojiForSubtext={tooltipData.emojiForSubtext}
|
||||
>
|
||||
<span className={className} data-message-id={messageId}>
|
||||
{node.kind.raw}
|
||||
</span>
|
||||
</EmojiWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<span onClick={handleOpenBottomSheet} onKeyDown={handleKeyDown} role="button" tabIndex={0}>
|
||||
<img
|
||||
draggable={false}
|
||||
className={className}
|
||||
alt={emojiData.name}
|
||||
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
|
||||
data-message-id={messageId}
|
||||
data-emoji-id={emojiData.id}
|
||||
data-animated={emojiData.isAnimated}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<EmojiInfoBottomSheet
|
||||
isOpen={bottomSheetState.isOpen}
|
||||
onClose={handleCloseBottomSheet}
|
||||
emoji={bottomSheetState.emoji}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipData = getTooltipData();
|
||||
return (
|
||||
<EmojiWithTooltip
|
||||
key={id}
|
||||
emojiUrl={tooltipData.emojiUrl}
|
||||
nativeEmoji={tooltipData.nativeEmoji}
|
||||
emojiName={emojiData.name}
|
||||
emojiForSubtext={tooltipData.emojiForSubtext}
|
||||
>
|
||||
<img
|
||||
draggable={false}
|
||||
className={className}
|
||||
alt={emojiData.name}
|
||||
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
|
||||
data-message-id={messageId}
|
||||
data-emoji-id={emojiData.id}
|
||||
data-animated={emojiData.isAnimated}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</EmojiWithTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmojiRenderer = EmojiRendererInner;
|
||||
209
fluxer_app/src/lib/markdown/renderers/index.tsx
Normal file
209
fluxer_app/src/lib/markdown/renderers/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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 type {I18n} from '@lingui/core';
|
||||
import {i18n} from '@lingui/core';
|
||||
import React from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {TABLE_PARSING_FLAG} from '../config';
|
||||
import {Parser} from '../parser/parser/parser';
|
||||
import {NodeType, ParserFlags} from '../parser/types/enums';
|
||||
import type {Node} from '../parser/types/nodes';
|
||||
import {shouldRenderJumboEmojis} from '../utils/jumbo-detector';
|
||||
import {
|
||||
AlertRenderer,
|
||||
BlockquoteRenderer,
|
||||
HeadingRenderer,
|
||||
ListRenderer,
|
||||
SequenceRenderer,
|
||||
SubtextRenderer,
|
||||
TableRenderer,
|
||||
} from './common/block-elements';
|
||||
import {CodeBlockRenderer, InlineCodeRenderer} from './common/code-elements';
|
||||
import {
|
||||
EmphasisRenderer,
|
||||
SpoilerRenderer,
|
||||
StrikethroughRenderer,
|
||||
StrongRenderer,
|
||||
UnderlineRenderer,
|
||||
} from './common/formatting-elements';
|
||||
import {EmojiRenderer} from './emoji-renderer';
|
||||
import {LinkRenderer} from './link-renderer';
|
||||
import {MentionRenderer} from './mention-renderer';
|
||||
import {TextRenderer} from './text-renderer';
|
||||
import {TimestampRenderer} from './timestamp-renderer';
|
||||
|
||||
export const MarkdownContext = {
|
||||
STANDARD_WITH_JUMBO: 0,
|
||||
RESTRICTED_INLINE_REPLY: 1,
|
||||
RESTRICTED_USER_BIO: 2,
|
||||
RESTRICTED_EMBED_DESCRIPTION: 3,
|
||||
STANDARD_WITHOUT_JUMBO: 4,
|
||||
} as const;
|
||||
export type MarkdownContext = (typeof MarkdownContext)[keyof typeof MarkdownContext];
|
||||
|
||||
export interface MarkdownParseOptions {
|
||||
context: MarkdownContext;
|
||||
disableAnimatedEmoji?: boolean;
|
||||
channelId?: string;
|
||||
messageId?: string;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
interface MarkdownRenderOptions extends MarkdownParseOptions {
|
||||
shouldJumboEmojis: boolean;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export interface RendererProps<T extends Node = Node> {
|
||||
node: T;
|
||||
id: string;
|
||||
renderChildren: (nodes: Array<Node>) => React.ReactNode;
|
||||
options: MarkdownRenderOptions;
|
||||
}
|
||||
|
||||
const STANDARD_FLAGS =
|
||||
ParserFlags.ALLOW_SPOILERS |
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_LISTS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_MASKED_LINKS |
|
||||
ParserFlags.ALLOW_COMMAND_MENTIONS |
|
||||
ParserFlags.ALLOW_GUILD_NAVIGATIONS |
|
||||
ParserFlags.ALLOW_USER_MENTIONS |
|
||||
ParserFlags.ALLOW_ROLE_MENTIONS |
|
||||
ParserFlags.ALLOW_CHANNEL_MENTIONS |
|
||||
ParserFlags.ALLOW_EVERYONE_MENTIONS |
|
||||
ParserFlags.ALLOW_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
TABLE_PARSING_FLAG |
|
||||
ParserFlags.ALLOW_ALERTS |
|
||||
ParserFlags.ALLOW_AUTOLINKS;
|
||||
|
||||
const RESTRICTED_INLINE_REPLY_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(
|
||||
ParserFlags.ALLOW_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
ParserFlags.ALLOW_TABLES |
|
||||
ParserFlags.ALLOW_ALERTS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_LISTS
|
||||
);
|
||||
|
||||
const RESTRICTED_USER_BIO_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_ROLE_MENTIONS |
|
||||
ParserFlags.ALLOW_EVERYONE_MENTIONS |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
ParserFlags.ALLOW_TABLES |
|
||||
ParserFlags.ALLOW_ALERTS
|
||||
);
|
||||
|
||||
const RESTRICTED_EMBED_DESCRIPTION_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_ALERTS | ParserFlags.ALLOW_AUTOLINKS);
|
||||
|
||||
export function getParserFlagsForContext(context: MarkdownContext): number {
|
||||
switch (context) {
|
||||
case MarkdownContext.RESTRICTED_INLINE_REPLY:
|
||||
return RESTRICTED_INLINE_REPLY_FLAGS;
|
||||
case MarkdownContext.RESTRICTED_USER_BIO:
|
||||
return RESTRICTED_USER_BIO_FLAGS;
|
||||
case MarkdownContext.RESTRICTED_EMBED_DESCRIPTION:
|
||||
return RESTRICTED_EMBED_DESCRIPTION_FLAGS;
|
||||
default:
|
||||
return STANDARD_FLAGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function parse({content, context}: {content: string; context: MarkdownContext}) {
|
||||
const flags = getParserFlagsForContext(context);
|
||||
const parser = new Parser(content, flags);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
const renderers: Record<NodeType, React.ComponentType<RendererProps<any>>> = {
|
||||
[NodeType.Sequence]: SequenceRenderer,
|
||||
[NodeType.Text]: TextRenderer,
|
||||
[NodeType.Strong]: StrongRenderer,
|
||||
[NodeType.Emphasis]: EmphasisRenderer,
|
||||
[NodeType.Underline]: UnderlineRenderer,
|
||||
[NodeType.Strikethrough]: StrikethroughRenderer,
|
||||
[NodeType.Spoiler]: SpoilerRenderer,
|
||||
[NodeType.Timestamp]: TimestampRenderer,
|
||||
[NodeType.Blockquote]: BlockquoteRenderer,
|
||||
[NodeType.CodeBlock]: CodeBlockRenderer,
|
||||
[NodeType.InlineCode]: InlineCodeRenderer,
|
||||
[NodeType.Link]: LinkRenderer,
|
||||
[NodeType.Mention]: MentionRenderer,
|
||||
[NodeType.Emoji]: EmojiRenderer,
|
||||
[NodeType.List]: ListRenderer,
|
||||
[NodeType.Heading]: HeadingRenderer,
|
||||
[NodeType.Subtext]: SubtextRenderer,
|
||||
[NodeType.Table]: TableRenderer,
|
||||
[NodeType.TableRow]: () => null,
|
||||
[NodeType.TableCell]: () => null,
|
||||
[NodeType.Alert]: AlertRenderer,
|
||||
};
|
||||
|
||||
function renderNode(node: Node, id: string, options: MarkdownRenderOptions): React.ReactNode {
|
||||
const renderer = renderers[node.type];
|
||||
if (!renderer) {
|
||||
console.warn(`No renderer found for node type: ${node.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderChildrenFn = (children: Array<Node>) =>
|
||||
children.map((child, i) => renderNode(child, `${id}-${i}`, options));
|
||||
|
||||
return React.createElement(renderer, {
|
||||
node,
|
||||
id,
|
||||
renderChildren: renderChildrenFn,
|
||||
options,
|
||||
key: id,
|
||||
});
|
||||
}
|
||||
|
||||
export function render(nodes: Array<Node>, options: MarkdownParseOptions): React.ReactNode {
|
||||
const shouldJumboEmojis = options.context === MarkdownContext.STANDARD_WITH_JUMBO && shouldRenderJumboEmojis(nodes);
|
||||
|
||||
const renderOptions: MarkdownRenderOptions = {
|
||||
...options,
|
||||
shouldJumboEmojis,
|
||||
i18n,
|
||||
};
|
||||
|
||||
return nodes.map((node, i) => renderNode(node, `${options.context}-${i}`, renderOptions));
|
||||
}
|
||||
|
||||
export function wrapRenderedContent(content: React.ReactNode, context: MarkdownContext): React.ReactNode {
|
||||
if (context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
|
||||
return <div className={markupStyles.inlineFormat}>{content}</div>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
279
fluxer_app/src/lib/markdown/renderers/link-renderer.tsx
Normal file
279
fluxer_app/src/lib/markdown/renderers/link-renderer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {CaretRightIcon, ChatTeardropIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as InviteActionCreators from '~/actions/InviteActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ThemeActionCreators from '~/actions/ThemeActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {ExternalLinkWarningModal} from '~/components/modals/ExternalLinkWarningModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import DeveloperModeStore from '~/stores/DeveloperModeStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
import {getDMDisplayName, getIcon, getName} from '~/utils/ChannelUtils';
|
||||
import {
|
||||
isInternalChannelHost,
|
||||
parseChannelJumpLink,
|
||||
parseChannelUrl,
|
||||
parseMessageJumpLink,
|
||||
} from '~/utils/DeepLinkUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import {goToMessage} from '~/utils/MessageNavigator';
|
||||
import {openExternalUrl} from '~/utils/NativeUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import * as ThemeUtils from '~/utils/ThemeUtils';
|
||||
import type {LinkNode} from '../parser/types/nodes';
|
||||
import type {RendererProps} from '.';
|
||||
import jumpLinkStyles from './MessageJumpLink.module.css';
|
||||
|
||||
interface JumpLinkMentionProps {
|
||||
channel: ChannelRecord;
|
||||
guild: GuildRecord | null;
|
||||
messageId?: string;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
const JumpLinkMention = observer(function JumpLinkMention({channel, guild, messageId, i18n}: JumpLinkMentionProps) {
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (messageId) {
|
||||
goToMessage(channel.id, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelPath = channel.guildId
|
||||
? Routes.guildChannel(channel.guildId, channel.id)
|
||||
: Routes.dmChannel(channel.id);
|
||||
RouterUtils.transitionTo(channelPath);
|
||||
},
|
||||
[channel.id, channel.guildId, messageId],
|
||||
);
|
||||
|
||||
const displayName = channel.isPrivate() ? getDMDisplayName(channel) : (channel.name ?? channel.id);
|
||||
const labelText = guild ? guild.name : displayName;
|
||||
const shouldShowChannelInfo = !messageId && Boolean(channel.guildId);
|
||||
const channelDisplayName = channel.name ?? getName(channel);
|
||||
const isDMChannel = channel.isPrivate() && !channel.guildId;
|
||||
const shouldShowDMIconLabel = isDMChannel && !messageId;
|
||||
const hasDetailChunk = Boolean(messageId) || shouldShowChannelInfo;
|
||||
const ariaLabel = messageId
|
||||
? labelText
|
||||
? i18n._(msg`Jump to the message in ${labelText}`)
|
||||
: i18n._(msg`Jump to the linked message`)
|
||||
: labelText
|
||||
? i18n._(msg`Jump to ${labelText}`)
|
||||
: i18n._(msg`Jump to the linked channel`);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
|
||||
onClick={handleClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className={jumpLinkStyles.jumpLinkInfo}>
|
||||
{guild ? (
|
||||
<span className={jumpLinkStyles.jumpLinkGuild}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={jumpLinkStyles.jumpLinkGuildIcon} />
|
||||
<span className={jumpLinkStyles.jumpLinkGuildName}>{guild.name}</span>
|
||||
</span>
|
||||
) : shouldShowDMIconLabel ? (
|
||||
<span className={jumpLinkStyles.jumpLinkDM}>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
|
||||
<span className={jumpLinkStyles.jumpLinkDMName}>{displayName}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={jumpLinkStyles.jumpLinkLabel}>{displayName}</span>
|
||||
)}
|
||||
{hasDetailChunk && (
|
||||
<span className={jumpLinkStyles.jumpLinkMessage} aria-hidden="true">
|
||||
<CaretRightIcon size={6} weight="bold" className={jumpLinkStyles.jumpLinkCaret} />
|
||||
{messageId ? (
|
||||
<span className={jumpLinkStyles.jumpLinkMessageIcon}>
|
||||
<ChatTeardropIcon size={12} weight="fill" />
|
||||
</span>
|
||||
) : (
|
||||
shouldShowChannelInfo && (
|
||||
<span className={jumpLinkStyles.jumpLinkChannel}>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelName}>{channelDisplayName}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export const LinkRenderer = observer(function LinkRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<LinkNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const {url, text} = node;
|
||||
|
||||
const inviteCode = InviteUtils.findInvite(url);
|
||||
const themeCode = ThemeUtils.findTheme(url);
|
||||
const messageJumpTarget = parseMessageJumpLink(url);
|
||||
const jumpTarget = messageJumpTarget ?? parseChannelJumpLink(url);
|
||||
const jumpChannel = jumpTarget ? (ChannelStore.getChannel(jumpTarget.channelId) ?? null) : null;
|
||||
const jumpGuild = jumpChannel?.guildId ? (GuildStore.getGuild(jumpChannel.guildId) ?? null) : null;
|
||||
|
||||
if (jumpTarget && jumpChannel) {
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<JumpLinkMention channel={jumpChannel} guild={jumpGuild} messageId={messageJumpTarget?.messageId} i18n={i18n} />
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel);
|
||||
|
||||
let isInternal = false;
|
||||
let handleClick: ((e: React.MouseEvent) => void) | undefined;
|
||||
|
||||
if (shouldShowAccessDeniedModal) {
|
||||
handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Channel Access Denied`)}
|
||||
description={i18n._(msg`You do not have access to the channel where this message was sent.`)}
|
||||
primaryText={i18n._(msg`Okay`)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
isInternal = true;
|
||||
} else if (url === `${APP_PROTOCOL_PREFIX}dev`) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
if (DeveloperModeStore.isDeveloper) {
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="developer_options" />));
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title="Secret Link Found!"
|
||||
description="You found a secret link, but it wasn't meant for you!"
|
||||
primaryText="Okay"
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
};
|
||||
isInternal = true;
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/');
|
||||
|
||||
if (inviteCode) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
InviteActionCreators.acceptAndTransitionToChannel(inviteCode, i18n);
|
||||
};
|
||||
} else if (themeCode) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
ThemeActionCreators.openAcceptModal(themeCode, i18n);
|
||||
};
|
||||
isInternal = true;
|
||||
} else if (isInternal) {
|
||||
const channelPath = parseChannelUrl(url);
|
||||
if (channelPath) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
RouterUtils.transitionTo(channelPath);
|
||||
};
|
||||
} else {
|
||||
isInternal = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInternal && !inviteCode) {
|
||||
const isTrusted = TrustedDomainStore.isTrustedDomain(parsed.hostname);
|
||||
if (!isTrusted) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
ModalActionCreators.push(modal(() => <ExternalLinkWarningModal url={url} hostname={parsed.hostname} />));
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
console.warn('Invalid URL in link:', url);
|
||||
}
|
||||
}
|
||||
|
||||
const content = text ? renderChildren([text]) : url;
|
||||
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<a
|
||||
href={url}
|
||||
target={isInternal ? undefined : '_blank'}
|
||||
rel={isInternal ? undefined : 'noopener noreferrer'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (handleClick) {
|
||||
handleClick(e);
|
||||
return;
|
||||
}
|
||||
if (!isInternal) {
|
||||
e.preventDefault();
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
}}
|
||||
className={markupStyles.link}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</FocusRing>
|
||||
);
|
||||
});
|
||||
284
fluxer_app/src/lib/markdown/renderers/mention-renderer.tsx
Normal file
284
fluxer_app/src/lib/markdown/renderers/mention-renderer.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ChannelTypes, Permissions} from '~/Constants';
|
||||
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
|
||||
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
|
||||
import {ChannelContextMenu} from '~/components/uikit/ContextMenu/ChannelContextMenu';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Routes} from '~/Routes';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import SelectedGuildStore from '~/stores/SelectedGuildStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import mentionRendererStyles from '~/styles/MentionRenderer.module.css';
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import * as ColorUtils from '~/utils/ColorUtils';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import {GuildNavKind, MentionKind} from '../parser/types/enums';
|
||||
import type {MentionNode} from '../parser/types/nodes';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
interface UserInfo {
|
||||
userId: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
function getUserInfo(userId: string, channelId?: string): UserInfo {
|
||||
if (!userId) {
|
||||
return {userId: null, name: null};
|
||||
}
|
||||
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) {
|
||||
return {userId, name: userId};
|
||||
}
|
||||
|
||||
let name = user.displayName;
|
||||
|
||||
if (channelId) {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (channel?.guildId) {
|
||||
name = NicknameUtils.getNickname(user, channel.guildId) || name;
|
||||
}
|
||||
}
|
||||
|
||||
return {userId: user.id, name};
|
||||
}
|
||||
|
||||
export const MentionRenderer = observer(function MentionRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<MentionNode>): React.ReactElement {
|
||||
const {kind} = node;
|
||||
const {channelId} = options;
|
||||
const i18n = options.i18n!;
|
||||
|
||||
switch (kind.kind) {
|
||||
case MentionKind.User: {
|
||||
const {userId, name} = getUserInfo(kind.id, channelId);
|
||||
|
||||
const genericMention = (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
@{name || kind.id}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
return genericMention;
|
||||
}
|
||||
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) {
|
||||
return genericMention;
|
||||
}
|
||||
|
||||
const channel = channelId ? ChannelStore.getChannel(channelId) : undefined;
|
||||
const guildId = channel?.guildId || '';
|
||||
|
||||
return (
|
||||
<PreloadableUserPopout key={id} user={user} isWebhook={false} guildId={guildId} position="right-start">
|
||||
<FocusRing>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@{name || user.displayName}
|
||||
</span>
|
||||
</FocusRing>
|
||||
</PreloadableUserPopout>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Role: {
|
||||
const selectedGuildId = SelectedGuildStore.selectedGuildId;
|
||||
const guild = selectedGuildId != null ? GuildStore.getGuild(selectedGuildId) : null;
|
||||
const role = guild?.roles[kind.id];
|
||||
|
||||
if (!role) {
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
@{i18n._(msg`Unknown Role`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const roleColor = role.color ? ColorUtils.int2rgb(role.color) : undefined;
|
||||
const roleBgColor = role.color ? ColorUtils.int2rgba(role.color, 0.1) : undefined;
|
||||
const roleBgHoverColor = role.color ? ColorUtils.int2rgba(role.color, 0.2) : undefined;
|
||||
const style = {
|
||||
color: roleColor,
|
||||
backgroundColor: roleBgColor,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
'--hover-bg': roleBgHoverColor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention} style={style}>
|
||||
@{role.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Channel: {
|
||||
const unknownMention = (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{ChannelUtils.getIcon({type: ChannelTypes.GUILD_TEXT}, {className: mentionRendererStyles.channelIcon})}
|
||||
{i18n._(msg`unknown-channel`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const channel = ChannelStore.getChannel(kind.id);
|
||||
if (!channel) {
|
||||
return unknownMention;
|
||||
}
|
||||
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
return <span key={id}>#{channel.name}</span>;
|
||||
}
|
||||
|
||||
if (
|
||||
channel.type !== ChannelTypes.GUILD_TEXT &&
|
||||
channel.type !== ChannelTypes.GUILD_VOICE &&
|
||||
channel.type !== ChannelTypes.GUILD_LINK
|
||||
) {
|
||||
return unknownMention;
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<button
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
const canConnect = PermissionStore.can(Permissions.CONNECT, channel);
|
||||
if (!canConnect) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Missing Permissions`)}
|
||||
message={i18n._(msg`You don't have permission to connect to this voice channel.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId!, channel.id));
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
|
||||
<ChannelContextMenu channel={channel} onClose={onClose} />
|
||||
));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{ChannelUtils.getIcon(channel, {className: mentionRendererStyles.channelIcon})}
|
||||
{channel.name}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Everyone: {
|
||||
return (
|
||||
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.everyoneMention)}>
|
||||
@everyone
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Here: {
|
||||
return (
|
||||
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.hereMention)}>
|
||||
@here
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Command: {
|
||||
const {name} = kind;
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.GuildNavigation: {
|
||||
const {navigationType} = kind;
|
||||
|
||||
let content: string;
|
||||
|
||||
switch (navigationType) {
|
||||
case GuildNavKind.Customize:
|
||||
content = '<id:customize>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.Browse:
|
||||
content = '<id:browse>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.Guide:
|
||||
content = '<id:guide>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.LinkedRoles: {
|
||||
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
|
||||
content = linkedRolesId ? `<id:linked-roles:${linkedRolesId}>` : '<id:linked-roles>';
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
content = `<id:${navigationType}>`;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span key={id}>{'<unknown-mention>'}</span>;
|
||||
}
|
||||
});
|
||||
37
fluxer_app/src/lib/markdown/renderers/text-renderer.tsx
Normal file
37
fluxer_app/src/lib/markdown/renderers/text-renderer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import type {TextNode} from '../parser/types/nodes';
|
||||
import {MarkdownContext, type RendererProps} from '.';
|
||||
|
||||
export const TextRenderer = observer(function TextRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<TextNode>): React.ReactElement {
|
||||
let content = node.content;
|
||||
|
||||
if (options.context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
|
||||
content = content.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return <span key={id}>{content}</span>;
|
||||
});
|
||||
100
fluxer_app/src/lib/markdown/renderers/timestamp-renderer.tsx
Normal file
100
fluxer_app/src/lib/markdown/renderers/timestamp-renderer.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 {ClockIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {DateTime} from 'luxon';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import WindowStore from '~/stores/WindowStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import timestampRendererStyles from '~/styles/TimestampRenderer.module.css';
|
||||
import {TimestampStyle} from '../parser/types/enums';
|
||||
import type {TimestampNode} from '../parser/types/nodes';
|
||||
import {formatTimestamp} from '../utils/date-formatter';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
export const TimestampRenderer = observer(function TimestampRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<TimestampNode>): ReactElement {
|
||||
const {timestamp, style} = node;
|
||||
const i18n = options.i18n;
|
||||
|
||||
const totalMillis = timestamp * 1000;
|
||||
const date = DateTime.fromMillis(totalMillis);
|
||||
const now = DateTime.now();
|
||||
|
||||
const isPast = date < now;
|
||||
const isFuture = date > now;
|
||||
const isToday = date.hasSame(now, 'day');
|
||||
|
||||
const tooltipFormat = "EEEE, LLLL d, yyyy 'at' h:mm:ss a ZZZZ";
|
||||
const fullDateTime = date.toFormat(tooltipFormat);
|
||||
|
||||
const isRelativeStyle = style === TimestampStyle.RelativeTime;
|
||||
const isWindowFocused = WindowStore.focused;
|
||||
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
|
||||
const relativeTime = date.toRelative();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRelativeStyle || !isWindowFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshDisplay = () => {
|
||||
setRelativeDisplayTime((previous) => {
|
||||
const nextValue = formatTimestamp(timestamp, style, i18n);
|
||||
return previous === nextValue ? previous : nextValue;
|
||||
});
|
||||
};
|
||||
|
||||
refreshDisplay();
|
||||
const intervalId = setInterval(refreshDisplay, 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
||||
|
||||
const tooltipContent = (
|
||||
<div className={timestampRendererStyles.tooltipContainer}>
|
||||
<div className={timestampRendererStyles.tooltipFullDateTime}>{fullDateTime}</div>
|
||||
<div className={timestampRendererStyles.tooltipRelativeTime}>{relativeTime}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const displayTime = isRelativeStyle ? relativeDisplayTime : formatTimestamp(timestamp, style, i18n);
|
||||
|
||||
const timestampClasses = clsx(
|
||||
markupStyles.timestamp,
|
||||
isPast && !isToday && timestampRendererStyles.timestampPast,
|
||||
isFuture && timestampRendererStyles.timestampFuture,
|
||||
isToday && timestampRendererStyles.timestampToday,
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={id} text={() => tooltipContent} position="top" delay={200} maxWidth="xl">
|
||||
<time className={timestampClasses} dateTime={date.toISO() ?? ''}>
|
||||
<ClockIcon className={timestampRendererStyles.clockIcon} />
|
||||
{displayTime}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
231
fluxer_app/src/lib/markdown/utils/date-formatter.ts
Normal file
231
fluxer_app/src/lib/markdown/utils/date-formatter.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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 type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {DateTime} from 'luxon';
|
||||
import {shouldUse12HourFormat} from '~/utils/DateUtils';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import {TimestampStyle} from '../parser/types/enums';
|
||||
|
||||
function isToday(date: DateTime, now: DateTime): boolean {
|
||||
return date.hasSame(now, 'day');
|
||||
}
|
||||
|
||||
function isYesterday(date: DateTime, now: DateTime): boolean {
|
||||
const yesterday = now.minus({days: 1});
|
||||
return date.hasSame(yesterday, 'day');
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const date = DateTime.fromSeconds(timestamp).setLocale(locale);
|
||||
const now = DateTime.now().setLocale(locale);
|
||||
|
||||
if (isToday(date, now)) {
|
||||
const timeString = date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
return i18n._(msg`Today at ${timeString}`);
|
||||
}
|
||||
|
||||
if (isYesterday(date, now)) {
|
||||
const timeString = date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
return i18n._(msg`Yesterday at ${timeString}`);
|
||||
}
|
||||
|
||||
const diff = date.diff(now).shiftTo('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds');
|
||||
const {years, months, weeks, days, hours, minutes, seconds} = diff.toObject();
|
||||
|
||||
if (years && Math.abs(years) > 0) {
|
||||
const absYears = Math.abs(Math.round(years));
|
||||
return date > now
|
||||
? absYears === 1
|
||||
? i18n._(msg`next year`)
|
||||
: i18n._(msg`in ${absYears} years`)
|
||||
: absYears === 1
|
||||
? i18n._(msg`last year`)
|
||||
: i18n._(msg`${absYears} years ago`);
|
||||
}
|
||||
|
||||
if (months && Math.abs(months) > 0) {
|
||||
const absMonths = Math.abs(Math.round(months));
|
||||
return date > now
|
||||
? absMonths === 1
|
||||
? i18n._(msg`next month`)
|
||||
: i18n._(msg`in ${absMonths} months`)
|
||||
: absMonths === 1
|
||||
? i18n._(msg`last month`)
|
||||
: i18n._(msg`${absMonths} months ago`);
|
||||
}
|
||||
|
||||
if (weeks && Math.abs(weeks) > 0) {
|
||||
const absWeeks = Math.abs(Math.round(weeks));
|
||||
return date > now
|
||||
? absWeeks === 1
|
||||
? i18n._(msg`next week`)
|
||||
: i18n._(msg`in ${absWeeks} weeks`)
|
||||
: absWeeks === 1
|
||||
? i18n._(msg`last week`)
|
||||
: i18n._(msg`${absWeeks} weeks ago`);
|
||||
}
|
||||
|
||||
if (days && Math.abs(days) > 0) {
|
||||
const absDays = Math.abs(Math.round(days));
|
||||
return date > now
|
||||
? absDays === 1
|
||||
? i18n._(msg`tomorrow`)
|
||||
: absDays === 2
|
||||
? i18n._(msg`in two days`)
|
||||
: i18n._(msg`in ${absDays} days`)
|
||||
: absDays === 1
|
||||
? i18n._(msg`yesterday`)
|
||||
: absDays === 2
|
||||
? i18n._(msg`two days ago`)
|
||||
: i18n._(msg`${absDays} days ago`);
|
||||
}
|
||||
|
||||
if (hours && Math.abs(hours) > 0) {
|
||||
const absHours = Math.abs(Math.round(hours));
|
||||
return date > now
|
||||
? absHours === 1
|
||||
? i18n._(msg`in one hour`)
|
||||
: i18n._(msg`in ${absHours} hours`)
|
||||
: absHours === 1
|
||||
? i18n._(msg`one hour ago`)
|
||||
: i18n._(msg`${absHours} hours ago`);
|
||||
}
|
||||
|
||||
if (minutes && Math.abs(minutes) > 0) {
|
||||
const absMinutes = Math.abs(Math.round(minutes));
|
||||
return date > now
|
||||
? absMinutes === 1
|
||||
? i18n._(msg`in one minute`)
|
||||
: i18n._(msg`in ${absMinutes} minutes`)
|
||||
: absMinutes === 1
|
||||
? i18n._(msg`one minute ago`)
|
||||
: i18n._(msg`${absMinutes} minutes ago`);
|
||||
}
|
||||
|
||||
const absSeconds = Math.abs(Math.round(seconds ?? 1));
|
||||
return date > now
|
||||
? absSeconds === 0
|
||||
? i18n._(msg`now`)
|
||||
: absSeconds === 1
|
||||
? i18n._(msg`in one second`)
|
||||
: i18n._(msg`in ${absSeconds} seconds`)
|
||||
: absSeconds === 0
|
||||
? i18n._(msg`just now`)
|
||||
: absSeconds === 1
|
||||
? i18n._(msg`one second ago`)
|
||||
: i18n._(msg`${absSeconds} seconds ago`);
|
||||
}
|
||||
|
||||
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const date = DateTime.fromMillis(timestamp * 1000).setLocale(locale);
|
||||
|
||||
switch (style) {
|
||||
case TimestampStyle.ShortTime:
|
||||
return date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.LongTime:
|
||||
return date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDate:
|
||||
return date.toLocaleString(DateTime.DATE_SHORT);
|
||||
|
||||
case TimestampStyle.LongDate:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateTime:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.LongDateTime:
|
||||
return date.toLocaleString({
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateShortTime:
|
||||
return date.toLocaleString({
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateMediumTime:
|
||||
return date.toLocaleString({
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.RelativeTime:
|
||||
return formatRelativeTime(timestamp, i18n);
|
||||
|
||||
default:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
}
|
||||
}
|
||||
63
fluxer_app/src/lib/markdown/utils/emoji-detector.ts
Normal file
63
fluxer_app/src/lib/markdown/utils/emoji-detector.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 ChannelStore from '~/stores/ChannelStore';
|
||||
import EmojiStore from '~/stores/EmojiStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import {EmojiKind} from '../parser/types/enums';
|
||||
import type {EmojiNode} from '../parser/types/nodes';
|
||||
|
||||
interface EmojiRenderData {
|
||||
url: string | null;
|
||||
name: string;
|
||||
isAnimated: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function getEmojiRenderData(
|
||||
emojiNode: EmojiNode,
|
||||
guildId?: string,
|
||||
disableAnimatedEmoji = false,
|
||||
): EmojiRenderData {
|
||||
const {kind} = emojiNode;
|
||||
const emojiName = `:${kind.name}:`;
|
||||
|
||||
if (kind.kind === EmojiKind.Standard) {
|
||||
return {
|
||||
url: EmojiUtils.getTwemojiURL(kind.codepoints),
|
||||
name: emojiName,
|
||||
isAnimated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {id, animated} = kind;
|
||||
const shouldAnimate = !disableAnimatedEmoji && animated;
|
||||
|
||||
const channel = guildId ? ChannelStore.getChannel(guildId) : undefined;
|
||||
const disambiguatedEmoji = EmojiStore.getDisambiguatedEmojiContext(channel?.guildId).getById(id);
|
||||
const finalEmojiName = `:${disambiguatedEmoji?.name || kind.name}:`;
|
||||
|
||||
return {
|
||||
url: AvatarUtils.getEmojiURL({id, animated: shouldAnimate}),
|
||||
name: finalEmojiName,
|
||||
isAnimated: shouldAnimate,
|
||||
id,
|
||||
};
|
||||
}
|
||||
48
fluxer_app/src/lib/markdown/utils/jumbo-detector.ts
Normal file
48
fluxer_app/src/lib/markdown/utils/jumbo-detector.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import {NodeType} from '../parser/types/enums';
|
||||
import type {Node, TextNode} from '../parser/types/nodes';
|
||||
|
||||
export function shouldRenderJumboEmojis(nodes: Array<Node>): boolean {
|
||||
if (UserSettingsStore.getMessageDisplayCompact()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emojiCount = nodes.filter((node) => {
|
||||
return (
|
||||
node.type === NodeType.Emoji ||
|
||||
(node.type === NodeType.Text && UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content))
|
||||
);
|
||||
}).length;
|
||||
|
||||
return (
|
||||
emojiCount > 0 &&
|
||||
emojiCount <= 6 &&
|
||||
nodes.every((node) => {
|
||||
return (
|
||||
node.type === NodeType.Emoji ||
|
||||
(node.type === NodeType.Text &&
|
||||
((node as TextNode).content.trim() === '' || UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content)))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user