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:05:12 +04:00
parent cfeb5de59c
commit bd042f0d17
2 changed files with 244 additions and 0 deletions

View File

@ -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))]));
});

View File

@ -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;
}