feat(web): WikiText — рендер вики-разметки Jira React-элементами, исходник при ошибке (#53)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 01:06:18 +04:00
parent bd042f0d17
commit 3fe820d195
4 changed files with 181 additions and 0 deletions

View File

@ -0,0 +1,13 @@
import { render } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { WikiText } from "./WikiText.tsx";
vi.mock("./parse.ts", () => ({ parseWiki: () => { throw new Error("сбой разбора"); } }));
it("ошибка разбора — блок показывается исходником", () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const { container } = render(<WikiText text={"h3. A\nB"} />);
const pre = container.querySelector("pre.wiki-source");
expect(pre?.textContent).toBe("h3. A\nB");
err.mockRestore();
});

View File

@ -0,0 +1,73 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { render, screen, within } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, it } from "vitest";
import { WikiText } from "./WikiText.tsx";
function show(text: string, projectKey?: string) {
const router = createMemoryRouter([{ path: "*", element: <WikiText text={text} projectKey={projectKey} /> }]);
return render(<RouterProvider router={router} />);
}
describe("WikiText", () => {
it("сценарий «Описание со структурой»", () => {
const { container } = show("h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка в {{data_type}}");
expect(screen.getByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
expect(within(screen.getByRole("list")).getAllByRole("listitem")).toHaveLength(2);
expect(screen.getByText("Итог:").tagName).toBe("STRONG");
expect(screen.getByText("data_type").tagName).toBe("CODE");
expect(container.textContent).not.toContain("h3.");
expect(container.textContent).not.toContain("{{");
expect(container.firstElementChild).toHaveClass("wiki");
});
it("таблица", () => {
show("||Поле||Значение||\n|a|b|\n|c|d|");
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
expect(screen.getAllByRole("cell")).toHaveLength(4);
});
it("внешняя ссылка — в новой вкладке", () => {
show("[Jira|https://x.ru/]");
const a = screen.getByRole("link", { name: "Jira" });
expect(a).toHaveAttribute("href", "https://x.ru/");
expect(a).toHaveAttribute("target", "_blank");
expect(a).toHaveAttribute("rel", "noopener noreferrer");
});
it("ключ задачи проекта — ссылка на карточку; без projectKey — текст", () => {
const { unmount } = show("см. AURORA-1445", "AURORA");
expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/issues/AURORA-1445");
unmount();
show("см. AURORA-1445");
expect(screen.queryByRole("link")).toBeNull();
});
it("упоминание — @логин", () => {
show("[~ivanov] посмотри");
expect(screen.getByText(/@ivanov/)).toBeInTheDocument();
});
it("сценарий «Неизвестная разметка и опасный адрес»", () => {
const { container } = show("{color:red}важно{color}\n!схема.png!\n[нажми|javascript:alert(1)]");
expect(screen.queryByRole("link")).toBeNull();
expect(screen.queryByRole("img")).toBeNull();
expect(container.textContent).toContain("{color:red}важно{color}");
expect(container.textContent).toContain("[нажми|javascript:alert(1)]");
});
it("HTML в тексте — просто текст", () => {
const { container } = show("<script>alert(1)</script> и <b>x</b>");
expect(container.querySelector("script, b")).toBeNull();
expect(container.textContent).toContain("<script>alert(1)</script>");
});
it("в модуле нет innerHTML", () => {
const dir = join(process.cwd(), "src/issues/wiki");
for (const f of readdirSync(dir).filter((n) => /\.tsx?$/.test(n) && !n.includes(".test."))) {
const src = readFileSync(join(dir, f), "utf8");
expect(src, f).not.toMatch(/innerHTML|dangerouslySetInnerHTML/);
}
});
});

View File

@ -0,0 +1,74 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { IssueLink } from "../IssueLink.tsx";
import type { Inline } from "./inline.ts";
import { type Block, type ListBlock, parseWiki } from "./parse.ts";
import "./wiki.css";
interface Props { text: string; projectKey?: string }
// вики-разметка Jira только React-элементами, HTML строкой не вставляется (design wiki-render #2, #5); при ошибке — исходник (#6)
export function WikiText({ text, projectKey }: Props) {
return <Fallback text={text}><Rendered text={text} projectKey={projectKey} /></Fallback>;
}
class Fallback extends Component<{ text: string; children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка рендера разметки Jira", error, info.componentStack); }
render() {
return this.state.failed ? <pre className="wiki-source">{this.props.text}</pre> : this.props.children;
}
}
function Rendered({ text, projectKey }: Props) {
return <div className="wiki">{renderBlocks(parseWiki(text, { projectKey }))}</div>;
}
function renderBlocks(blocks: Block[]): ReactNode[] {
return blocks.map((b, n) => {
switch (b.t) {
case "heading": { const H = `h${b.level}` as const; return <H key={n}>{renderInlines(b.children)}</H>; }
case "p": return <p key={n}>{renderInlines(b.children)}</p>;
case "list": return renderList(b, n);
case "table":
return (
<table key={n}><tbody>
{b.rows.map((r, i) => (
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children)}</th> : <td key={j}>{renderInlines(c.children)}</td>)}</tr>
))}
</tbody></table>
);
case "pre": return <pre key={n}>{b.text}</pre>;
case "quote": return <blockquote key={n}>{renderBlocks(b.children)}</blockquote>;
case "hr": return <hr key={n} />;
}
});
}
function renderList(list: ListBlock, key: number): ReactNode {
const L = list.ordered ? "ol" : "ul";
return (
<L key={key}>
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children)}{it.lists.map(renderList)}</li>)}
</L>
);
}
function renderInlines(nodes: Inline[]): ReactNode[] {
return nodes.map((x, n) => {
switch (x.t) {
case "text": return x.text;
case "br": return <br key={n} />;
case "strong": return <strong key={n}>{renderInlines(x.children)}</strong>;
case "em": return <em key={n}>{renderInlines(x.children)}</em>;
case "del": return <del key={n}>{renderInlines(x.children)}</del>;
case "ins": return <ins key={n}>{renderInlines(x.children)}</ins>;
case "sup": return <sup key={n}>{renderInlines(x.children)}</sup>;
case "sub": return <sub key={n}>{renderInlines(x.children)}</sub>;
case "code": return <code key={n}>{x.text}</code>;
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children)}</a>;
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children) : undefined}</IssueLink>;
case "mention": return <span key={n} className="mention">@{x.login}</span>;
}
});
}

View File

@ -0,0 +1,21 @@
/* вики-разметка Jira в карточке (design wiki-render #9); селекторы div.wiki … сильнее общих .issue h3 и т. п. */
div.wiki{line-height:1.5;overflow-wrap:anywhere;min-width:0}
div.wiki>:first-child{margin-top:0}
div.wiki>:last-child{margin-bottom:0}
div.wiki p{margin:0 0 6px}
div.wiki :is(h1,h2,h3,h4,h5,h6){font-weight:600;color:inherit;text-transform:none;letter-spacing:normal;line-height:1.3;margin:10px 0 4px}
div.wiki :is(h1,h2){font-size:15px}
div.wiki :is(h3,h4){font-size:14px}
div.wiki :is(h5,h6){font-size:13px}
div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px}
div.wiki li>:is(ul,ol){margin:2px 0 0}
div.wiki table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px}
div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top}
div.wiki th{background:var(--soft);font-weight:600}
div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px}
div.wiki code{background:var(--soft);border-radius:3px;padding:0 3px}
div.wiki pre{background:var(--soft);border-radius:4px;padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal}
div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--line);color:var(--muted)}
div.wiki hr{border:0;border-top:1px solid var(--line);margin:8px 0}
div.wiki .mention{color:var(--accent)}
pre.wiki-source{white-space:pre-wrap;font:inherit;margin:0}