diff --git a/web/src/issues/wiki/parse.test.ts b/web/src/issues/wiki/parse.test.ts new file mode 100644 index 0000000..f6e57bf --- /dev/null +++ b/web/src/issues/wiki/parse.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { Inline } from "./inline.ts"; +import { type Block, type ListBlock, type ListItem, parseWiki } from "./parse.ts"; + +const P = { projectKey: "AURORA" }; +const t = (text: string): Inline => ({ t: "text", text }); +const br: Inline = { t: "br" }; +const p = (...children: Inline[]): Block => ({ t: "p", children }); +const item = (text: string, ...lists: ListBlock[]): ListItem => ({ children: text ? [t(text)] : [], lists }); +const ul = (...items: ListItem[]): ListBlock => ({ t: "list", ordered: false, items }); +const ol = (...items: ListItem[]): ListBlock => ({ t: "list", ordered: true, items }); +const h = (level: 1 | 2 | 3 | 4 | 5 | 6, text: string): Block => ({ t: "heading", level, children: [t(text)] }); + +const STRUCTURE = "h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка в {{data_type}}"; + +describe("parseWiki: абзацы и заголовки", () => { + it("сценарий «Описание со структурой»", () => + expect(parseWiki(STRUCTURE, P)).toEqual([ + h(3, "Контекст"), + p(t("Шаги:")), + ol(item("Загрузить ОСВ"), item("Открыть документ")), + p({ t: "strong", children: [t("Итог:")] }, t(" ошибка в "), { t: "code", text: "data_type" }), + ])); + it("CRLF даёт то же дерево", () => { + const tree = parseWiki(STRUCTURE.replace(/\n/g, "\r\n"), P); + expect(tree).toEqual(parseWiki(STRUCTURE, P)); + expect(JSON.stringify(tree)).not.toContain("\\r"); + }); + it("абзац из двух строк — через br", () => expect(parseWiki("a\nb")).toEqual([p(t("a"), br, t("b"))])); + it("пустые строки разделяют абзацы, пустых абзацев нет", () => expect(parseWiki("a\n\n\n\nb")).toEqual([p(t("a")), p(t("b"))])); + it.each(["", " \n ", "\r\n"])("пустой текст %j → []", (src) => expect(parseWiki(src)).toEqual([])); + it.each([1, 2, 3, 4, 5, 6] as const)("h%i.", (n) => expect(parseWiki(`h${n}. Заголовок`)).toEqual([h(n, "Заголовок")])); + it.each(["h7. x", "h3.без пробела"])("%s — абзац", (src) => expect(parseWiki(src)).toEqual([p(t(src))])); + it("----", () => expect(parseWiki("a\n----\nb")).toEqual([p(t("a")), { t: "hr" }, p(t("b"))])); +}); + +describe("parseWiki: списки", () => { + it("сценарий «Списки с отступом и пустыми строками» (R-4)", () => + expect(parseWiki("Шаги воспроизведения:\n * Загрузить ОСВ.\n\n * Дождаться обработки.")).toEqual([ + p(t("Шаги воспроизведения:")), ul(item("Загрузить ОСВ."), item("Дождаться обработки.")), + ])); + it("вложенность и смешанные маркеры", () => + expect(parseWiki("* a\n** b\n*# c\n* d")).toEqual([ul(item("a", ul(item("b")), ol(item("c"))), item("d"))])); + it("- пункты", () => expect(parseWiki("- x\n- y")).toEqual([ul(item("x"), item("y"))])); + it.each(["-- x", "*- x", "-# x"])("%s — не список", (src) => expect(parseWiki(src)).toEqual([p(t(src))])); + it("## Заголовок — второй уровень нумерованного", () => + expect(parseWiki("## Заголовок")).toEqual([ol(item("", ol(item("Заголовок"))))])); + it("строка без маркера после списка — абзац", () => expect(parseWiki("* a\nтекст")).toEqual([ul(item("a")), p(t("текст"))])); + it("смена вида на первом уровне — новый список", () => expect(parseWiki("* a\n# b")).toEqual([ul(item("a")), ol(item("b"))])); + it("*жирный* в начале строки — не список", () => + expect(parseWiki("*Уточнение.* текст")).toEqual([p({ t: "strong", children: [t("Уточнение.")] }, t(" текст"))])); +}); + +describe("parseWiki: таблицы", () => { + it("заголовки, ячейки, | внутри ссылки", () => + expect(parseWiki("||Поле||Значение||\n|data_type|null|\n|ссылка|[a|https://x.ru]|", P)).toEqual([{ + t: "table", + rows: [ + [{ header: true, children: [t("Поле")] }, { header: true, children: [t("Значение")] }], + [{ header: false, children: [t("data_type")] }, { header: false, children: [t("null")] }], + [{ header: false, children: [t("ссылка")] }, { header: false, children: [{ t: "link", href: "https://x.ru", children: [t("a")] }] }], + ], + }])); + it("смешанная строка ||h|c|", () => + expect(parseWiki("||h|c|")).toEqual([{ t: "table", rows: [[{ header: true, children: [t("h")] }, { header: false, children: [t("c")] }]] }])); +}); + +describe("parseWiki: блоки-макросы", () => { + it("{code:sql}", () => expect(parseWiki("{code:sql}\nselect *\nfrom t\n{code}")).toEqual([{ t: "pre", text: "select *\nfrom t" }])); + it("{noformat} в одной строке", () => expect(parseWiki("{noformat}*x*{noformat}")).toEqual([{ t: "pre", text: "*x*" }])); + it("незакрытый {code} — текст", () => expect(parseWiki("{code}\nx")).toEqual([p(t("{code}"), br, t("x"))])); + it("{quote} и хвост после закрывающего тега", () => + expect(parseWiki("{quote}\nh3. Внутри\nтекст\n{quote}хвост")).toEqual([ + { t: "quote", children: [h(3, "Внутри"), p(t("текст"))] }, p(t("хвост")), + ])); + it("bq.", () => expect(parseWiki("bq. цитата")).toEqual([{ t: "quote", children: [p(t("цитата"))] }])); + 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))])); +}); diff --git a/web/src/issues/wiki/parse.ts b/web/src/issues/wiki/parse.ts new file mode 100644 index 0000000..f3d8c6b --- /dev/null +++ b/web/src/issues/wiki/parse.ts @@ -0,0 +1,164 @@ +// Блочная вики-разметка Jira (design wiki-render #3): построчный разбор в дерево блоков, строки — parseInline. +// Разбор не бросает исключений; нераспознанное остаётся абзацем с текстом как есть. +import { type Inline, parseInline, type WikiOptions } from "./inline.ts"; + +export interface ListBlock { t: "list"; ordered: boolean; items: ListItem[] } +export interface ListItem { children: Inline[]; lists: ListBlock[] } +export interface Cell { header: boolean; children: Inline[] } +export type Block = + | { t: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; children: Inline[] } + | { t: "p"; children: Inline[] } + | ListBlock + | { t: "table"; rows: Cell[][] } + | { t: "pre"; text: string } + | { t: "quote"; children: Block[] } + | { t: "hr" }; + +const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/; +const HEADING = /^\s*h([1-6])\.\s+(.*)$/; +const BQ = /^\s*bq\.\s+(.*)$/; +const HR = /^\s*----\s*$/; +const TABLE = /^\s*\|/; +// `*`/`#` любой глубины или ровно один `-` (спека: `--`, `*-`, `-#` — не список) +const LIST = /^\s*([*#]+|-)\s+(.*)$/; + +export function parseWiki(text: string, opts: WikiOptions = {}): Block[] { + return blocks(text.replace(/\r\n?/g, "\n").split("\n"), opts); +} + +function blocks(src: string[], opts: WikiOptions): Block[] { + const lines = [...src]; + const out: Block[] = []; + let para: string[] = []; + const flush = () => { + if (!para.length) return; + out.push({ t: "p", children: para.flatMap((l, n) => [...(n ? [{ t: "br" } as Inline] : []), ...parseInline(l, opts)]) }); + para = []; + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) { flush(); i++; continue; } + + const macro = MACRO.exec(line); + if (macro) { + const found = closeMacro(lines, i, macro); + if (found) { + flush(); + out.push(found.name === "quote" ? { t: "quote", children: blocks(found.body, opts) } : { t: "pre", text: found.body.join("\n") }); + if (found.after.trim()) { lines[found.end] = found.after; i = found.end; } else i = found.end + 1; + continue; + } + para.push(line); i++; continue; + } + + if (HR.test(line)) { flush(); out.push({ t: "hr" }); i++; continue; } + + const heading = HEADING.exec(line); + if (heading) { + flush(); + out.push({ t: "heading", level: Number(heading[1]) as 1 | 2 | 3 | 4 | 5 | 6, children: parseInline(heading[2], opts) }); + i++; continue; + } + + const bq = BQ.exec(line); + if (bq) { flush(); out.push({ t: "quote", children: [{ t: "p", children: parseInline(bq[1], opts) }] }); i++; continue; } + + if (TABLE.test(line)) { + flush(); + const rows: Cell[][] = []; + while (i < lines.length && TABLE.test(lines[i])) rows.push(row(lines[i++], opts)); + out.push({ t: "table", rows }); + continue; + } + + if (LIST.test(line)) { + flush(); + const entries: { markers: string; text: string }[] = []; + const first = LIST.exec(line)![1][0]; + while (i < lines.length) { + const m = LIST.exec(lines[i]); + if (m) { entries.push({ markers: m[1], text: m[2] }); i++; continue; } + if (lines[i].trim()) break; + // пустые строки между пунктами того же вида список не рвут (R-4) + let j = i; + while (j < lines.length && !lines[j].trim()) j++; + const next = j < lines.length ? LIST.exec(lines[j]) : null; + if (!next || next[1][0] !== first) break; + i = j; + } + out.push(...buildLists(entries, opts)); + continue; + } + + para.push(line); + i++; + } + flush(); + return out; +} + +// закрывающий тег макроса в той же строке после открывающего или в последующих; 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 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 (text.slice(0, at)) body.push(text.slice(0, at)); + return { name, body, end: k, after: text.slice(at + tag.length) }; + } + return null; +} + +function row(line: string, opts: WikiOptions): Cell[] { + const s = line.trim(); + const cells: Cell[] = []; + let pos = 0; + while (pos < s.length && s[pos] === "|") { + const header = s.startsWith("||", pos); + pos += header ? 2 : 1; + let j = pos; + let depth = 0; + // `|` внутри [ссылки|адрес] ячейку не режет + while (j < s.length && (s[j] !== "|" || depth > 0)) { + if (s[j] === "[") depth++; + else if (s[j] === "]" && depth > 0) depth--; + j++; + } + const content = s.slice(pos, j); + if (j >= s.length && !content.trim()) break; + cells.push({ header, children: parseInline(content.trim(), opts) }); + pos = j; + } + return cells; +} + +// стек по глубине маркеров; вид — по последнему маркеру; недостающие уровни — пустые пункты (как в Jira) +function buildLists(entries: { markers: string; text: string }[], opts: WikiOptions): ListBlock[] { + const roots: ListBlock[] = []; + const stack: ListBlock[] = []; + const attach = (level: number, list: ListBlock) => { + if (level === 1) roots.push(list); + else stack[level - 2].items[stack[level - 2].items.length - 1].lists.push(list); + stack[level - 1] = list; + stack.length = level; + }; + for (const { markers, text } of entries) { + const depth = markers.length; + stack.length = Math.min(stack.length, depth); + for (let level = 1; level < depth; level++) { + if (!stack[level - 1]) attach(level, { t: "list", ordered: markers[level - 1] === "#", items: [{ children: [], lists: [] }] }); + } + const ordered = markers[depth - 1] === "#"; + const current = stack[depth - 1]; + const item: ListItem = { children: parseInline(text, opts), lists: [] }; + if (current && current.ordered === ordered) current.items.push(item); + else attach(depth, { t: "list", ordered, items: [item] }); + } + return roots; +}