164 lines
7.6 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.

// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху)
export interface WikiAttachment { id: string; filename: string; created: string | null }
// anyAttachment — только для плоского текста (wikiPlain): `!имя!` и `[^имя]` — вложение, даже если имени нет в списке
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[]; anyAttachment?: boolean }
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
export type Inline =
| { t: "text"; text: string }
| { t: Mark; children: Inline[] }
| { t: "code"; text: string }
| { t: "link"; href: string; children: Inline[] }
| { t: "issue"; key: string; children: Inline[] }
| { t: "attachment"; id: string; children: Inline[] }
| { t: "mention"; login: string }
| { t: "br" };
const MARKS: Record<string, Mark> = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" };
const ESCAPABLE = new Set([..."*_-+^~{}[]|!#\\"]);
const WORD = /[\p{L}\p{N}]/u;
const SPACE = /\s/;
const URL = /https?:\/\/[^\s\]|<>"]+/y;
const URL_TAIL = /[.,;:!?)]+$/;
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
// имя файла с расширением, без пробелов по краям: только такое `!имя!` без списка вложений — вложение (N-1)
const FILE_NAME = /^\S(?:.*\S)?\.[\p{L}\p{N}]{1,10}$/u;
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
function safeHref(raw: string): string | null {
const href = raw.trim();
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
}
// вложение по имени: несколько с одним именем — первое в порядке хаба; image — `!имя!`, без списка только имя файла,
// иначе «Срочно! Проверьте!» в ленте теряет «!» (N-1)
function findAttachment(opts: WikiOptions, name: string, image = false): string | null {
const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id;
return found ?? (opts.anyAttachment && (!image || FILE_NAME.test(name)) ? "" : null);
}
function keyPattern(projectKey: string | undefined): RegExp | null {
if (!projectKey) return null;
const escaped = projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`${escaped}-\\d+(?![\\p{L}\\p{N}_])`, "uy");
}
// label — подпись ссылки: без ссылок, адресов и ключей внутри, чтобы не было <a> в <a> (I-2)
export function parseInline(line: string, opts: WikiOptions = {}, label = false): Inline[] {
const key = label ? null : keyPattern(opts.projectKey);
const isKey = (s: string) => !!key && new RegExp(`^${key.source}$`, "u").test(s);
const out: Inline[] = [];
const text = (s: string) => {
if (!s) return;
const last = out[out.length - 1];
if (last?.t === "text") last.text += s;
else out.push({ t: "text", text: s });
};
const prevOk = (i: number) => i === 0 || !WORD.test(line[i - 1]);
let i = 0;
while (i < line.length) {
const c = line[i];
if (c === "\\") {
const next = line[i + 1];
if (next === "\\") { out.push({ t: "br" }); i += 2; continue; }
if (next !== undefined && ESCAPABLE.has(next)) { text(next); i += 2; continue; }
text(c); i += 1; continue;
}
if (c === "{" && line[i + 1] === "{") {
const end = line.indexOf("}}", i + 2);
if (end > i + 2) { out.push({ t: "code", text: line.slice(i + 2, end) }); i = end + 2; continue; }
text(c); i += 1; continue;
}
if (c === "[" && !label) {
const end = line.indexOf("]", i + 1);
const node = end > i + 1 ? bracket(line.slice(i + 1, end), opts, isKey) : null;
if (node) { out.push(node); i = end + 1; continue; }
if (end > i) { text(line.slice(i, end + 1)); i = end + 1; continue; }
text(c); i += 1; continue;
}
if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) {
IMAGE.lastIndex = i;
const m = IMAGE.exec(line);
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1], true) : null;
if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
}
if (c === "h" && !label && prevOk(i)) {
URL.lastIndex = i;
const m = URL.exec(line);
if (m) {
const href = m[0].replace(URL_TAIL, "");
if (safeHref(href)) { out.push({ t: "link", href, children: [{ t: "text", text: href }] }); i += href.length; continue; }
}
}
if (key && (i === 0 || !/[\p{L}\p{N}_-]/u.test(line[i - 1]))) {
key.lastIndex = i;
const m = key.exec(line);
if (m) { out.push({ t: "issue", key: m[0], children: [] }); i += m[0].length; continue; }
}
const mark = MARKS[c];
if (mark) {
const close = closing(line, i);
if (close > 0) {
out.push({ t: mark, children: parseInline(line.slice(i + 1, close), opts, label) });
i = close + 1;
continue;
}
}
text(c);
i += 1;
}
return out;
}
// позиция закрывающего маркера для открывающего в i или -1: границы — спека «Разметка Jira в карточке», R-7
function closing(line: string, i: number): number {
const c = line[i];
const next = line[i + 1];
const prev = line[i - 1];
if (prev !== undefined && (WORD.test(prev) || prev === c)) return -1;
if (next === undefined || SPACE.test(next) || next === c) return -1;
for (let k = i + 2; k < line.length; k++) {
if (line[k] !== c) continue;
const before = line[k - 1];
const after = line[k + 1];
if (SPACE.test(before) || before === c) continue;
if (after !== undefined && (WORD.test(after) || after === c)) continue;
return k;
}
return -1;
}
// содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть
function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null {
if (body.startsWith("~")) {
const login = body.slice(1).split("|")[0].trim();
return login && !SPACE.test(login) ? { t: "mention", login } : null;
}
const ref = ATT_LINK.exec(body);
if (ref) {
const id = findAttachment(opts, ref[2]);
return id !== null ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
}
const bar = body.indexOf("|");
const label = bar >= 0 ? body.slice(0, bar) : "";
const target = (bar >= 0 ? body.slice(bar + 1) : body).trim();
if (isKey(target)) return { t: "issue", key: target, children: label ? parseInline(label, opts, true) : [] };
const href = safeHref(target);
if (!href) return null;
return { t: "link", href, children: label ? parseInline(label, opts, true) : [{ t: "text", text: target }] };
}