diff --git a/web/src/modules/jira/issues/wiki/inline.test.ts b/web/src/modules/jira/issues/wiki/inline.test.ts index 1073755..618a930 100644 --- a/web/src/modules/jira/issues/wiki/inline.test.ts +++ b/web/src/modules/jira/issues/wiki/inline.test.ts @@ -116,4 +116,10 @@ describe("parseInline: ссылки на вложения", () => { it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => { expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]); }); + it("\\# — символ решётки (M-5)", () => { + expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]); + }); + it("[~логин|текст] — упоминание логина без подписи (M-6)", () => { + expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]); + }); }); diff --git a/web/src/modules/jira/issues/wiki/inline.ts b/web/src/modules/jira/issues/wiki/inline.ts index abfb5a8..b34ebb0 100644 --- a/web/src/modules/jira/issues/wiki/inline.ts +++ b/web/src/modules/jira/issues/wiki/inline.ts @@ -18,7 +18,7 @@ export type Inline = | { t: "br" }; const MARKS: Record = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" }; -const ESCAPABLE = new Set([..."*_-+^~{}[]|!\\"]); +const ESCAPABLE = new Set([..."*_-+^~{}[]|!#\\"]); const WORD = /[\p{L}\p{N}]/u; const SPACE = /\s/; const URL = /https?:\/\/[^\s\]|<>"]+/y; @@ -147,7 +147,7 @@ function closing(line: string, i: number): number { // содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null { if (body.startsWith("~")) { - const login = body.slice(1).trim(); + const login = body.slice(1).split("|")[0].trim(); return login && !SPACE.test(login) ? { t: "mention", login } : null; } const ref = ATT_LINK.exec(body); diff --git a/web/src/modules/jira/issues/wiki/parse.test.ts b/web/src/modules/jira/issues/wiki/parse.test.ts index f6e57bf..22e799b 100644 --- a/web/src/modules/jira/issues/wiki/parse.test.ts +++ b/web/src/modules/jira/issues/wiki/parse.test.ts @@ -77,4 +77,23 @@ describe("parseWiki: блоки-макросы", () => { it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))])); it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) => expect(parseWiki(src)).toEqual([p(t(src))])); + it("\\# в начале строки — абзац, не список", () => { + expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]); + }); + it("quote и noformat с параметрами (M-5)", () => { + expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]); + expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]); + }); + it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => { + expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]); + }); + it("непарная [ в ячейке не съедает разделители (M-6)", () => { + const [table] = parseWiki("|[draft|итог|готово|"); + expect(table).toMatchObject({ t: "table" }); + expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]); + }); + it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => { + const [table] = parseWiki("|[док|https://x.ru]|b|"); + expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2); + }); }); diff --git a/web/src/modules/jira/issues/wiki/parse.ts b/web/src/modules/jira/issues/wiki/parse.ts index f3d8c6b..635b1bd 100644 --- a/web/src/modules/jira/issues/wiki/parse.ts +++ b/web/src/modules/jira/issues/wiki/parse.ts @@ -14,7 +14,7 @@ export type Block = | { t: "quote"; children: Block[] } | { t: "hr" }; -const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/; +const MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/; const HEADING = /^\s*h([1-6])\.\s+(.*)$/; const BQ = /^\s*bq\.\s+(.*)$/; const HR = /^\s*----\s*$/; @@ -101,14 +101,14 @@ function blocks(src: string[], opts: WikiOptions): Block[] { // закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null { - const name = open[1].split(":")[0]; + const name = open[1]; const tag = `{${name}}`; const rest = lines[start].slice(open[0].length); const body: string[] = []; for (let k = start; k < lines.length; k++) { const text = k === start ? rest : lines[k]; const at = text.indexOf(tag); - if (at < 0) { if (k !== start || text) body.push(text); continue; } + if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; } if (text.slice(0, at)) body.push(text.slice(0, at)); return { name, body, end: k, after: text.slice(at + tag.length) }; } @@ -126,7 +126,7 @@ function row(line: string, opts: WikiOptions): Cell[] { let depth = 0; // `|` внутри [ссылки|адрес] ячейку не режет while (j < s.length && (s[j] !== "|" || depth > 0)) { - if (s[j] === "[") depth++; + if (s[j] === "[") { if (s.indexOf("]", j + 1) >= 0) depth++; } else if (s[j] === "]" && depth > 0) depth--; j++; }