feat(web): строчный разборщик вики-разметки Jira (#53)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 01:03:45 +04:00
parent edaa3a5b90
commit cfeb5de59c
2 changed files with 205 additions and 0 deletions

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { type Inline, parseInline } from "./inline.ts";
const P = { projectKey: "AURORA" };
const t = (text: string): Inline => ({ t: "text", text });
const wrap = (kind: "strong" | "em" | "del" | "ins" | "sup" | "sub", ...children: Inline[]): Inline => ({ t: kind, children });
const issue = (key: string, ...children: Inline[]): Inline => ({ t: "issue", key, children });
const link = (href: string, ...children: Inline[]): Inline => ({ t: "link", href, children });
describe("parseInline: выделение", () => {
it.each<[string, Inline[]]>([
["*Итог:* ошибка", [wrap("strong", t("Итог:")), t(" ошибка")]],
["_к_", [wrap("em", t("к"))]],
["-з-", [wrap("del", t("з"))]],
["+п+", [wrap("ins", t("п"))]],
["^в^", [wrap("sup", t("в"))]],
["~н~", [wrap("sub", t("н"))]],
["*_x_*", [wrap("strong", wrap("em", t("x")))]],
["(*x*)", [t("("), wrap("strong", t("x")), t(")")]],
["a *b c* d", [t("a "), wrap("strong", t("b c")), t(" d")]],
["{{data_type}}", [{ t: "code", text: "data_type" }]],
["{{*x*}} и", [{ t: "code", text: "*x*" }, t(" и")]],
])("%s", (src, tree) => expect(parseInline(src, P)).toEqual(tree));
});
describe("parseInline: обычный текст не портится", () => {
it.each([
"data_type = null", "2026-10-15", "счёт-фактура", "a*b*c", "18 803 903,65 - 9 351 102,89", "**важно**",
"* одинокая", "*незакрытый", "[без конца", "*x*y", "{{без конца", "UTF-8", "SHA-256", "DOC-221280", "XAURORA-1",
"AURORA-12x", "",
])("%s", (src) => expect(parseInline(src, P)).toEqual(src ? [t(src)] : []));
});
describe("parseInline: экранирование и перенос", () => {
it("\\* — сам символ", () => expect(parseInline("\\*не жирный\\*", P)).toEqual([t("*не жирный*")]));
it("\\\\ — перенос", () => expect(parseInline("a\\\\b", P)).toEqual([t("a"), { t: "br" }, t("b")]));
});
describe("parseInline: ссылки", () => {
it.each<[string, Inline[]]>([
["[Jira|https://x.ru/a?b=1]", [link("https://x.ru/a?b=1", t("Jira"))]],
["[https://x.ru]", [link("https://x.ru", t("https://x.ru"))]],
["[mail|MAILTO:a@b.ru]", [link("MAILTO:a@b.ru", t("mail"))]],
["[*Жирная* ссылка|http://x.ru]", [link("http://x.ru", wrap("strong", t("Жирная")), t(" ссылка"))]],
["см. https://x.ru/p, далее", [t("см. "), link("https://x.ru/p", t("https://x.ru/p")), t(", далее")]],
["(https://x.ru/p).", [t("("), link("https://x.ru/p", t("https://x.ru/p")), t(").")]],
["[~ivanov]", [{ t: "mention", login: "ivanov" }]],
])("%s", (src, tree) => expect(parseInline(src, P)).toEqual(tree));
it.each(["[нажми|javascript:alert(1)]", "[x|ftp://h]", "[javascript:alert(1)]", "[просто текст]"])(
"опасная или неизвестная цель — текст как есть: %s",
(src) => expect(parseInline(src, P)).toEqual([t(src)]),
);
});
describe("parseInline: ключи задач", () => {
it("ключи проекта в тексте", () =>
expect(parseInline("ведётся в AURORA-1445; AURORA-1645 снята", P)).toEqual([
t("ведётся в "), issue("AURORA-1445"), t("; "), issue("AURORA-1645"), t(" снята"),
]));
it("[ключ] и [текст|ключ]", () => {
expect(parseInline("[AURORA-1]", P)).toEqual([issue("AURORA-1")]);
expect(parseInline("[см.|AURORA-2]", P)).toEqual([issue("AURORA-2", t("см."))]);
});
it("не внутри {{…}} и адреса", () => {
expect(parseInline("{{AURORA-1}}", P)).toEqual([{ t: "code", text: "AURORA-1" }]);
expect(parseInline("https://j/browse/AURORA-1", P)).toEqual([link("https://j/browse/AURORA-1", t("https://j/browse/AURORA-1"))]);
});
it("без projectKey — текст", () => expect(parseInline("AURORA-1445")).toEqual([t("AURORA-1445")]));
});

View File

@ -0,0 +1,135 @@
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
export interface WikiOptions { projectKey?: string }
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: "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 = /[.,;:!?)]+$/;
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
function safeHref(raw: string): string | null {
const href = raw.trim();
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : 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");
}
export function parseInline(line: string, opts: WikiOptions = {}): Inline[] {
const key = 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 === "[") {
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 === "h" || c === "H") && 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) });
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).trim();
return login && !SPACE.test(login) ? { t: "mention", login } : 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) : [] };
const href = safeHref(target);
if (!href) return null;
return { t: "link", href, children: label ? parseInline(label, opts) : [{ t: "text", text: target }] };
}