feat(web): строчный разборщик вики-разметки Jira (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
edaa3a5b90
commit
cfeb5de59c
70
web/src/issues/wiki/inline.test.ts
Normal file
70
web/src/issues/wiki/inline.test.ts
Normal 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")]));
|
||||
});
|
||||
135
web/src/issues/wiki/inline.ts
Normal file
135
web/src/issues/wiki/inline.ts
Normal 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 }] };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user