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