mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import Guilds from '@app/features/guild/state/Guilds';
|
|
import {formatTimestamp} from '@app/features/messaging/utils/markdown/DateFormatter';
|
|
import {
|
|
AlertType,
|
|
EmojiKind,
|
|
GuildNavKind,
|
|
MentionKind,
|
|
NodeType,
|
|
TableAlignment,
|
|
} from '@app/features/messaging/utils/markdown/parser/Enums';
|
|
import type {
|
|
AlertNode,
|
|
BlockquoteNode,
|
|
CodeBlockNode,
|
|
EmojiNode,
|
|
FormattingNode,
|
|
HeadingNode,
|
|
InlineCodeNode,
|
|
LinkNode,
|
|
ListItem,
|
|
ListNode,
|
|
MentionNode,
|
|
Node,
|
|
SequenceNode,
|
|
SpoilerNode,
|
|
SubtextNode,
|
|
TableCellNode,
|
|
TableNode,
|
|
TableRowNode,
|
|
TextNode,
|
|
TimestampNode,
|
|
} from '@app/features/messaging/utils/markdown/parser/Nodes';
|
|
import {WasmParser} from '@app/features/messaging/utils/markdown/parser/WasmParser';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import Users from '@app/features/user/state/Users';
|
|
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
|
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
|
import type {ChannelMention} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import type {I18n} from '@lingui/core';
|
|
import {msg} from '@lingui/core/macro';
|
|
|
|
const logger = new Logger('MarkdownPlaintext');
|
|
|
|
export interface PlaintextRenderOptions {
|
|
channelId?: string;
|
|
preserveMarkdown?: boolean;
|
|
includeEmojiNames?: boolean;
|
|
includeLinkUrls?: boolean;
|
|
mentionChannels?: ReadonlyArray<ChannelMention>;
|
|
i18n: I18n;
|
|
}
|
|
|
|
const ALERT_TIP_LABEL = msg({
|
|
message: 'Tip',
|
|
comment: 'Markdown alert label for a tip callout when copied as plaintext.',
|
|
});
|
|
const ALERT_IMPORTANT_LABEL = msg({
|
|
message: 'Important',
|
|
comment: 'Markdown alert label for an important callout when copied as plaintext.',
|
|
});
|
|
const ALERT_WARNING_LABEL = msg({
|
|
message: 'Warning',
|
|
comment: 'Markdown alert label for a warning callout when copied as plaintext.',
|
|
});
|
|
const ALERT_CAUTION_LABEL = msg({
|
|
message: 'Caution',
|
|
comment: 'Markdown alert label for a caution callout when copied as plaintext.',
|
|
});
|
|
const ALERT_NOTE_LABEL = msg({
|
|
message: 'Note',
|
|
comment: 'Markdown alert label for a note callout when copied as plaintext.',
|
|
});
|
|
const UNKNOWN_ROLE_MENTION_LABEL = msg({
|
|
message: 'unknown-role',
|
|
comment: 'Fallback plaintext for an unresolved role mention in copied markdown. Keep lowercase and hyphenated.',
|
|
});
|
|
const UNKNOWN_CHANNEL_MENTION_LABEL = msg({
|
|
message: 'unknown-channel',
|
|
comment: 'Fallback plaintext for an unresolved channel mention in copied markdown. Keep lowercase and hyphenated.',
|
|
});
|
|
const UNKNOWN_MENTION_LABEL = msg({
|
|
message: 'unknown-mention',
|
|
comment: 'Fallback plaintext for an unresolved mention in copied markdown. Keep lowercase and hyphenated.',
|
|
});
|
|
const BLOCK_NODE_TYPES = new Set<NodeType>([
|
|
NodeType.Alert,
|
|
NodeType.Blockquote,
|
|
NodeType.CodeBlock,
|
|
NodeType.Heading,
|
|
NodeType.List,
|
|
NodeType.Subtext,
|
|
NodeType.Table,
|
|
NodeType.TableRow,
|
|
]);
|
|
const COPYABLE_CHANNEL_MENTION_TYPES = new Set<number>([
|
|
ChannelTypes.GUILD_TEXT,
|
|
ChannelTypes.GUILD_VOICE,
|
|
ChannelTypes.GUILD_LINK,
|
|
ChannelTypes.GUILD_CATEGORY,
|
|
]);
|
|
|
|
function isBlockNode(node: Node): boolean {
|
|
return BLOCK_NODE_TYPES.has(node.type) || (node.type === NodeType.Spoiler && (node as SpoilerNode).isBlock);
|
|
}
|
|
|
|
function joinWithVisibleLineBreaks(parts: Array<string>): string {
|
|
return parts.filter((part) => part.length > 0).join('\n');
|
|
}
|
|
|
|
function indentContinuationLines(text: string, width: number): string {
|
|
const lines = text.split('\n');
|
|
if (lines.length <= 1) {
|
|
return text;
|
|
}
|
|
const continuationIndent = ' '.repeat(width);
|
|
return lines.map((line, index) => (index === 0 ? line : `${continuationIndent}${line}`)).join('\n');
|
|
}
|
|
|
|
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);
|
|
if (spoilerNode.isBlock && options.preserveMarkdown) {
|
|
return `||\n${spoilerContent}\n||`;
|
|
}
|
|
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;
|
|
const startOrdinal = listNode.items[0]?.ordinal ?? 1;
|
|
return listNode.items
|
|
.map((item: ListItem, index: number) => {
|
|
const content = renderNodesToPlaintext(item.children, options).trim();
|
|
if (listNode.ordered) {
|
|
const prefix = `${startOrdinal + index}. `;
|
|
return `${prefix}${indentContinuationLines(content, prefix.length)}`;
|
|
}
|
|
const prefix = options.preserveMarkdown ? '- ' : '• ';
|
|
return `${prefix}${indentContinuationLines(content, prefix.length)}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
case NodeType.CodeBlock: {
|
|
const codeBlockNode = node as CodeBlockNode;
|
|
if (!options.preserveMarkdown) {
|
|
return codeBlockNode.content;
|
|
}
|
|
const content = codeBlockNode.content.endsWith('\n') ? codeBlockNode.content : `${codeBlockNode.content}\n`;
|
|
return `\`\`\`${codeBlockNode.language || ''}\n${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);
|
|
if (options.preserveMarkdown) {
|
|
return `[${linkText}](${linkNode.url})`;
|
|
}
|
|
if (
|
|
options.includeLinkUrls &&
|
|
normaliseUrlForComparison(linkText) !== normaliseUrlForComparison(linkNode.url)
|
|
) {
|
|
return `${linkText} (${linkNode.url})`;
|
|
}
|
|
return 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) {
|
|
if (blockquoteNode.children.length === 0) {
|
|
const lineCount = Math.max(1, Math.min(blockquoteNode.blankLines ?? 1, 100));
|
|
return Array.from({length: lineCount}, () => '> ').join('\n');
|
|
}
|
|
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;
|
|
return renderTableNodeToMarkdown(tableNode, options);
|
|
}
|
|
case NodeType.Alert: {
|
|
const alertNode = node as AlertNode;
|
|
const alertContent = renderNodesToPlaintext(alertNode.children, options);
|
|
const alertLabel = renderAlertLabel(alertNode.alertType, options);
|
|
if (options.preserveMarkdown) {
|
|
return `> [!${alertNode.alertType.toUpperCase()}]\n${alertContent
|
|
.split('\n')
|
|
.map((line) => `> ${line}`)
|
|
.join('\n')}`;
|
|
}
|
|
return alertContent ? `${alertLabel}\n${alertContent}` : alertLabel;
|
|
}
|
|
case NodeType.TableRow:
|
|
return renderTableRowToPlaintext(node as TableRowNode, options);
|
|
case NodeType.TableCell:
|
|
return renderTableCellToPlaintext(node as TableCellNode, options);
|
|
default: {
|
|
const nodeType =
|
|
typeof (
|
|
node as {
|
|
type?: unknown;
|
|
}
|
|
).type === 'string'
|
|
? (
|
|
node as {
|
|
type: string;
|
|
}
|
|
).type
|
|
: 'unknown';
|
|
logger.warn(`Unknown node type for plaintext rendering: ${nodeType}`);
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderTableRowToPlaintext(row: TableRowNode, options: PlaintextRenderOptions): string {
|
|
return row.cells.map((cell) => renderTableCellToPlaintext(cell, options)).join(' | ');
|
|
}
|
|
|
|
function renderTableCellToPlaintext(cell: TableCellNode, options: PlaintextRenderOptions): string {
|
|
return renderNodesToPlaintext(cell.children, options)
|
|
.replace(/\s*\n+\s*/gu, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function normaliseUrlForComparison(value?: string | null): string | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(value).href.replace(/\/$/u, '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getTableColumnCount(table: TableNode, rows: Array<Array<string>>): number {
|
|
const bodyColumnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
return Math.max(table.header.cells.length, bodyColumnCount, table.alignments.length, 1);
|
|
}
|
|
|
|
function padTableRowCells(cells: Array<string>, columnCount: number): Array<string> {
|
|
if (cells.length >= columnCount) {
|
|
return cells;
|
|
}
|
|
return [...cells, ...Array.from({length: columnCount - cells.length}, () => '')];
|
|
}
|
|
|
|
function escapeMarkdownTableCell(value: string): string {
|
|
return value.replace(/\|/gu, '\\|');
|
|
}
|
|
|
|
function formatMarkdownTableRow(cells: Array<string>): string {
|
|
return `| ${cells.map(escapeMarkdownTableCell).join(' | ')} |`;
|
|
}
|
|
|
|
function formatMarkdownTableSeparator(alignment: TableAlignment | undefined): string {
|
|
switch (alignment) {
|
|
case TableAlignment.Left:
|
|
return ':---';
|
|
case TableAlignment.Center:
|
|
return ':---:';
|
|
case TableAlignment.Right:
|
|
return '---:';
|
|
default:
|
|
return '---';
|
|
}
|
|
}
|
|
|
|
export function renderTableNodeToMarkdown(table: TableNode, options: PlaintextRenderOptions): string {
|
|
const headerCells = table.header.cells.map((cell) => renderTableCellToPlaintext(cell, options));
|
|
const bodyRows = table.rows.map((row) => row.cells.map((cell) => renderTableCellToPlaintext(cell, options)));
|
|
const columnCount = getTableColumnCount(table, bodyRows);
|
|
const normalisedHeaderCells = padTableRowCells(headerCells, columnCount);
|
|
const separatorCells = Array.from({length: columnCount}, (_value, index) =>
|
|
formatMarkdownTableSeparator(table.alignments[index]),
|
|
);
|
|
return joinWithVisibleLineBreaks([
|
|
formatMarkdownTableRow(normalisedHeaderCells),
|
|
formatMarkdownTableRow(separatorCells),
|
|
...bodyRows.map((row) => formatMarkdownTableRow(padTableRowCells(row, columnCount))),
|
|
]);
|
|
}
|
|
|
|
function renderAlertLabel(alertType: AlertType, options: PlaintextRenderOptions): string {
|
|
const i18n = options.i18n;
|
|
switch (alertType) {
|
|
case AlertType.Tip:
|
|
return i18n._(ALERT_TIP_LABEL);
|
|
case AlertType.Important:
|
|
return i18n._(ALERT_IMPORTANT_LABEL);
|
|
case AlertType.Warning:
|
|
return i18n._(ALERT_WARNING_LABEL);
|
|
case AlertType.Caution:
|
|
return i18n._(ALERT_CAUTION_LABEL);
|
|
default:
|
|
return i18n._(ALERT_NOTE_LABEL);
|
|
}
|
|
}
|
|
|
|
function renderMentionToPlaintext(node: MentionNode, options: PlaintextRenderOptions): string {
|
|
const {kind} = node;
|
|
switch (kind.kind) {
|
|
case MentionKind.User: {
|
|
const user = Users.getUser(kind.id);
|
|
if (!user) {
|
|
return `@${kind.id}`;
|
|
}
|
|
const channel = options.channelId ? Channels.getChannel(options.channelId) : null;
|
|
return `@${NicknameUtils.getNickname(user, channel?.guildId ?? null, options.channelId)}`;
|
|
}
|
|
case MentionKind.Role: {
|
|
const channel = options.channelId ? Channels.getChannel(options.channelId) : null;
|
|
const guild = Guilds.getGuild(channel?.guildId ?? '');
|
|
const role = guild ? guild.roles[kind.id] : null;
|
|
if (!role) {
|
|
return `@${options.i18n._(UNKNOWN_ROLE_MENTION_LABEL)}`;
|
|
}
|
|
return `@${role.name}`;
|
|
}
|
|
case MentionKind.Channel: {
|
|
const channel = Channels.getChannel(kind.id);
|
|
if (channel && COPYABLE_CHANNEL_MENTION_TYPES.has(channel.type)) {
|
|
return `#${channel.name}`;
|
|
}
|
|
const fallbackMention = options.mentionChannels?.find((mention) => mention.id === kind.id);
|
|
if (fallbackMention && COPYABLE_CHANNEL_MENTION_TYPES.has(fallbackMention.type)) {
|
|
return `#${fallbackMention.name}`;
|
|
}
|
|
if (!channel || !COPYABLE_CHANNEL_MENTION_TYPES.has(channel.type)) {
|
|
return `#${options.i18n._(UNKNOWN_CHANNEL_MENTION_LABEL)}`;
|
|
}
|
|
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: 'LinkedRoles';
|
|
id?: string;
|
|
}
|
|
).id;
|
|
return linkedRolesId ? `#linked-roles:${linkedRolesId}` : '#linked-roles';
|
|
}
|
|
default:
|
|
return `#${navigationType}`;
|
|
}
|
|
}
|
|
default:
|
|
return `@${options.i18n._(UNKNOWN_MENTION_LABEL)}`;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let result = '';
|
|
let previousNode: Node | null = null;
|
|
for (const node of nodes) {
|
|
const rendered = renderNodeToPlaintext(node, options);
|
|
if (!rendered) {
|
|
previousNode = node;
|
|
continue;
|
|
}
|
|
const needsBlockSeparator =
|
|
result.length > 0 &&
|
|
previousNode != null &&
|
|
(isBlockNode(previousNode) || isBlockNode(node)) &&
|
|
!result.endsWith('\n') &&
|
|
!rendered.startsWith('\n');
|
|
if (needsBlockSeparator) {
|
|
const lastLine = result.slice(result.lastIndexOf('\n') + 1);
|
|
if (!(options.preserveMarkdown && lastLine === '> ')) {
|
|
result = result.replace(/[ \t]+$/u, '');
|
|
}
|
|
result += `\n${rendered}`;
|
|
} else {
|
|
result += rendered;
|
|
}
|
|
previousNode = node;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function trimTrailingWhitespaceBeforeLineBreaks(value: string, preserveMarkdown: boolean | undefined): string {
|
|
if (!preserveMarkdown) {
|
|
return value.replace(/[ \t]+\n/gu, '\n');
|
|
}
|
|
const lines = value.split('\n');
|
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
if (lines[i] !== '> ') {
|
|
lines[i] = lines[i].replace(/[ \t]+$/u, '');
|
|
}
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function renderToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions): string {
|
|
const result = renderNodesToPlaintext(nodes, options);
|
|
return trimTrailingWhitespaceBeforeLineBreaks(result, options.preserveMarkdown)
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.replace(/^\n+|\n+$/gu, '');
|
|
}
|
|
|
|
export function renderAstToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions): string {
|
|
return renderToPlaintext(nodes, options);
|
|
}
|
|
|
|
export function parseAndRenderToPlaintext(
|
|
content: string,
|
|
parserFlags: number,
|
|
options: PlaintextRenderOptions,
|
|
): string {
|
|
try {
|
|
const parser = new WasmParser(content, parserFlags);
|
|
const {nodes} = parser.parse();
|
|
return renderToPlaintext(nodes, options);
|
|
} catch (error) {
|
|
logger.error('Error parsing content for plaintext rendering:', error);
|
|
return content;
|
|
}
|
|
}
|