164 lines
7.6 KiB
TypeScript
164 lines
7.6 KiB
TypeScript
// Строчная вики-разметка 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 }] };
|
||
}
|