Files
glchat/web/src/lib/markdown.ts
T
grendervill ca34d05377 fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
2026-09-20 18:13:04 +03:00

396 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Общий разбор разметки сообщения: блоки (текст, цитата, код) и строчные
* токены с диапазонами.
*
* Модуль намеренно не рисует ничего сам: им пользуются оба потребителя —
* `MessageContent` (лента сообщений) и зеркало композера (подсветка под
* textarea). Разбор один, поэтому предпросмотр в поле ввода показывает ровно
* то же форматирование, что увидит получатель.
*
* Диапазоны нужны зеркалу: по ним видно, где у токена маркеры
* (`start`…`contentStart` и `contentEnd`…`end`), а где содержимое. Это
* позволяет спрятать маркеры, пока каретка вне фрагмента, и показать их
* приглушёнными, когда пользователь его правит.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
* `<#id>`, `#канал` и кастомные эмодзи сервера `<:имя:file_id>`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
*/
import { CUSTOM_EMOJI_TOKEN_SOURCE } from '@/lib/customEmoji';
/** Вид строчного токена. */
export type InlineKind =
| 'code'
| 'bold-italic'
| 'bold'
| 'italic'
| 'strike'
| 'spoiler'
| 'mark'
| 'mention'
| 'channel-link'
| 'custom-emoji'
| 'link'
| 'channel';
/** Токен разметки с диапазонами в исходном тексте. */
export interface InlineToken {
kind: InlineKind;
/** Исходный текст токена вместе с маркерами. */
raw: string;
/** Смещение первого символа токена (включая открывающий маркер). */
start: number;
/** Смещение за последним символом токена. */
end: number;
/** Начало содержимого: сразу после открывающего маркера. */
contentStart: number;
/** Конец содержимого: перед закрывающим маркером. */
contentEnd: number;
}
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
* текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
* запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
* `_` — внутри слова (`foo_bar_baz`).
*
* Токен кастомного эмодзи `<:имя:file_id>` (`<a:…>` — анимированный) стоит
* отдельной альтернативой: с `<@id>` и `<#id>` он не пересекается, а строгий
* формат (латиница/цифры/подчёркивания в имени, снежинка в id) оставляет
* битые токены обычным текстом.
*/
const INLINE_ALTERNATIVES = [
'(`[^`\\n]+`)',
'(\\*\\*\\*[^*\\n]+\\*\\*\\*)',
'(\\*\\*[^*\\n]+\\*\\*)',
'(__[^_\\n]+__)',
'(~~[^~\\n]+~~)',
'(\\|\\|[^|\\n]+\\|\\|)',
'((?<!\\*)\\*(?!\\*)[^*\\n]+(?<!\\*)\\*(?!\\*))',
'((?<![\\p{L}\\p{N}_])_(?!_)[^_\\n]+(?<!_)_(?![\\p{L}\\p{N}_]))',
'(==[^=\\n]+==)',
'(<@[^\\s<>]+>)',
'(<#[^\\s<>]+>)',
`(${CUSTOM_EMOJI_TOKEN_SOURCE})`,
'(https?:\\/\\/[^\\s<>()]+)',
'(#[\\p{L}\\p{N}_-]+)',
];
const INLINE_PATTERN = new RegExp(INLINE_ALTERNATIVES.join('|'), 'gu');
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
const GROUP_KINDS: readonly InlineKind[] = [
'code',
'bold-italic',
'bold',
'bold',
'strike',
'spoiler',
'italic',
'italic',
'mark',
'mention',
'channel-link',
'custom-emoji',
'link',
'channel',
];
/** Длина парных маркеров вокруг содержимого. */
const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
code: 1,
'bold-italic': 3,
bold: 2,
italic: 1,
strike: 2,
spoiler: 2,
mark: 2,
// У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
// содержимое — весь токен.
mention: 0,
'channel-link': 0,
// Токен эмодзи `<:имя:id>` целиком считается содержимым: маркеры `<:имя:`
// и `>` рисует зеркало композера, когда каретка внутри токена.
'custom-emoji': 0,
link: 0,
channel: 0,
};
/** Вид токена по совпавшей группе шаблона. */
function kindOf(match: RegExpMatchArray): InlineKind | null {
for (let group = 1; group < match.length; group += 1) {
if (match[group] !== undefined) {
return GROUP_KINDS[group - 1] ?? null;
}
}
return null;
}
/** Границы содержимого токена в абсолютных смещениях. */
function contentBounds(
kind: InlineKind,
raw: string,
start: number,
): { contentStart: number; contentEnd: number } {
if (kind === 'mention' || kind === 'channel-link') {
// `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
}
const marker = MARKER_LENGTH[kind];
return { contentStart: start + marker, contentEnd: start + raw.length - marker };
}
/**
* Разбор строчной разметки. `offset` — смещение `text` в исходном тексте:
* все диапазоны токенов возвращаются в абсолютных координатах, что нужно
* зеркалу для сопоставления с позицией каретки.
*/
export function parseInline(text: string, offset = 0): InlineToken[] {
const tokens: InlineToken[] = [];
for (const match of text.matchAll(INLINE_PATTERN)) {
const kind = kindOf(match);
if (kind === null) {
continue;
}
const raw = match[0];
const start = offset + (match.index ?? 0);
tokens.push({
kind,
raw,
start,
end: start + raw.length,
...contentBounds(kind, raw, start),
});
}
return tokens;
}
/**
* Есть ли у токена маркеры, которые зеркало прячет вне каретки. У упоминаний,
* ссылок на комнаты, ссылок и каналов их нет: содержимое — весь токен, прятать
* нечего. У кастомного эмодзи маркеры есть (`<:имя:` и `>`), но «содержимым»
* остаётся весь токен — зеркало показывает его текстом, пока каретка внутри.
*/
export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
}
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
export function splitLink(raw: string): { href: string; trailing: string } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
/** Строка блока с абсолютными смещениями в исходном тексте. */
export interface BlockLine {
text: string;
/** Смещение первого символа строки. */
start: number;
/** Смещение за последним символом строки (без завершающего `\n`). */
end: number;
}
/** Вид блока: обычный текст, цитата или блок кода. */
export type BlockKind = 'text' | 'quote' | 'code';
/** Блок разметки: строки и границы в исходном тексте. */
export interface MessageBlock {
kind: BlockKind;
start: number;
end: number;
lines: BlockLine[];
/** Ограждение ``` в начале блока кода; у остальных блоков — null. */
fenceStart: BlockLine | null;
/** Закрывающее ограждение; у незакрытого блока кода — null. */
fenceEnd: BlockLine | null;
}
/** Префикс цитаты: с него начинается каждая строка блока-цитаты. */
export const QUOTE_PREFIX = '> ';
/** Шаблон строки-ограждения блока кода. */
const CODE_FENCE_PATTERN = /^```(\w*)\s*$/u;
function fence(text: string): boolean {
return CODE_FENCE_PATTERN.exec(text.trim()) !== null;
}
/**
* Разбиение на блоки: цитаты, огороженный код и обычный текст. Строки блока
* кода хранятся без ограждений, сами ограждения — в `fenceStart`/`fenceEnd`
* (зеркало рисует их отдельно, а `MessageContent` не рисует вовсе).
*/
export function parseBlocks(content: string): MessageBlock[] {
const blocks: MessageBlock[] = [];
let open: MessageBlock | null = null;
let cursor = 0;
for (const text of content.split('\n')) {
const line: BlockLine = { text, start: cursor, end: cursor + text.length };
cursor = line.end + 1;
if (open !== null) {
if (fence(text)) {
open.fenceEnd = line;
open.end = line.end;
blocks.push(open);
open = null;
} else {
open.lines.push(line);
open.end = line.end;
}
continue;
}
if (fence(text)) {
open = {
kind: 'code',
start: line.start,
end: line.end,
lines: [],
fenceStart: line,
fenceEnd: null,
};
continue;
}
if (text.startsWith(QUOTE_PREFIX)) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line);
previous.end = line.end;
} else {
blocks.push({
kind: 'quote',
start: line.start,
end: line.end,
lines: [line],
fenceStart: null,
fenceEnd: null,
});
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'text') {
previous.lines.push(line);
previous.end = line.end;
} else {
blocks.push({
kind: 'text',
start: line.start,
end: line.end,
lines: [line],
fenceStart: null,
fenceEnd: null,
});
}
}
if (open !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(open);
}
return blocks;
}
/** Как рисовать строку в зеркале композера. */
export type MirrorLineKind = 'text' | 'quote' | 'code' | 'fence';
/** Строка зеркала: исходный текст строки и разобранные токены. */
export interface MirrorLine {
kind: MirrorLineKind;
/** Исходный текст строки (у цитаты — вместе с префиксом `> `). */
text: string;
start: number;
end: number;
/** Токены разметки; у кода и ограждений пусто (внутри кода не форматируем). */
tokens: InlineToken[];
/** Длина префикса цитаты, который рисуется отдельно; иначе 0. */
prefixLength: number;
/** Блок кода, к которому относится строка (для `code` и `fence`); иначе null. */
codeRange: { start: number; end: number } | null;
}
/**
* Строки зеркала композера: та же последовательность строк, что и в исходном
* тексте (`lines.map(text).join('\n') === content`), но с готовым разбором.
* Инвариант важен: переносы строк в зеркале обязаны совпадать с textarea,
* иначе подсветка разъедется с полем ввода.
*/
export function toMirrorLines(content: string): MirrorLine[] {
const lines: MirrorLine[] = [];
for (const block of parseBlocks(content)) {
const codeRange = block.kind === 'code' ? { start: block.start, end: block.end } : null;
const fenceStart = block.fenceStart;
if (block.kind === 'code' && fenceStart !== null) {
lines.push({
kind: 'fence',
text: fenceStart.text,
start: fenceStart.start,
end: fenceStart.end,
tokens: [],
prefixLength: 0,
codeRange,
});
}
for (const line of block.lines) {
const isQuote = block.kind === 'quote';
lines.push({
kind: isQuote ? 'quote' : block.kind === 'code' ? 'code' : 'text',
text: line.text,
start: line.start,
end: line.end,
tokens:
block.kind === 'code'
? []
: parseInline(
isQuote ? line.text.slice(QUOTE_PREFIX.length) : line.text,
isQuote ? line.start + QUOTE_PREFIX.length : line.start,
),
prefixLength: isQuote ? QUOTE_PREFIX.length : 0,
codeRange,
});
}
const fenceEnd = block.fenceEnd;
if (block.kind === 'code' && fenceEnd !== null) {
lines.push({
kind: 'fence',
text: fenceEnd.text,
start: fenceEnd.start,
end: fenceEnd.end,
tokens: [],
prefixLength: 0,
codeRange,
});
}
}
return lines;
}
/**
* Пересекается ли выделение с диапазоном. Для каретки без выделения это
* «строго внутри»: позиции сразу за открывающим и перед закрывающим маркером
* попадают внутрь, а позиции на самих маркерах снаружи — там маркеры скрыты.
*/
export function selectionIntersects(
selection: { start: number; end: number },
start: number,
end: number,
): boolean {
return selection.start < end && selection.end > start;
}