From cfeb5de59c1b84a028514a1646c6ae184bfc0873 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 01:03:45 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B2=D0=B8=D0=BA=D0=B8-=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20Jira=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/issues/wiki/inline.test.ts | 70 +++++++++++++++ web/src/issues/wiki/inline.ts | 135 +++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 web/src/issues/wiki/inline.test.ts create mode 100644 web/src/issues/wiki/inline.ts diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts new file mode 100644 index 0000000..3160e56 --- /dev/null +++ b/web/src/issues/wiki/inline.test.ts @@ -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")])); +}); diff --git a/web/src/issues/wiki/inline.ts b/web/src/issues/wiki/inline.ts new file mode 100644 index 0000000..263a748 --- /dev/null +++ b/web/src/issues/wiki/inline.ts @@ -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 = { "*": "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 }] }; +}