diff --git a/web/src/issues/wiki/WikiText.fallback.test.tsx b/web/src/issues/wiki/WikiText.fallback.test.tsx
new file mode 100644
index 0000000..8456914
--- /dev/null
+++ b/web/src/issues/wiki/WikiText.fallback.test.tsx
@@ -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();
+ const pre = container.querySelector("pre.wiki-source");
+ expect(pre?.textContent).toBe("h3. A\nB");
+ err.mockRestore();
+});
diff --git a/web/src/issues/wiki/WikiText.test.tsx b/web/src/issues/wiki/WikiText.test.tsx
new file mode 100644
index 0000000..e9620ed
--- /dev/null
+++ b/web/src/issues/wiki/WikiText.test.tsx
@@ -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: }]);
+ return render();
+}
+
+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(" и x");
+ expect(container.querySelector("script, b")).toBeNull();
+ expect(container.textContent).toContain("");
+ });
+
+ 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/);
+ }
+ });
+});
diff --git a/web/src/issues/wiki/WikiText.tsx b/web/src/issues/wiki/WikiText.tsx
new file mode 100644
index 0000000..c70f367
--- /dev/null
+++ b/web/src/issues/wiki/WikiText.tsx
@@ -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 ;
+}
+
+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 ?
{this.props.text} : this.props.children;
+ }
+}
+
+function Rendered({ text, projectKey }: Props) {
+ return {renderBlocks(parseWiki(text, { projectKey }))}
;
+}
+
+function renderBlocks(blocks: Block[]): ReactNode[] {
+ return blocks.map((b, n) => {
+ switch (b.t) {
+ case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children)}; }
+ case "p": return {renderInlines(b.children)}
;
+ case "list": return renderList(b, n);
+ case "table":
+ return (
+
+ {b.rows.map((r, i) => (
+ {r.map((c, j) => c.header ? | {renderInlines(c.children)} | : {renderInlines(c.children)} | )}
+ ))}
+
+ );
+ case "pre": return {b.text};
+ case "quote": return {renderBlocks(b.children)}
;
+ case "hr": return
;
+ }
+ });
+}
+
+function renderList(list: ListBlock, key: number): ReactNode {
+ const L = list.ordered ? "ol" : "ul";
+ return (
+
+ {list.items.map((it, i) => {renderInlines(it.children)}{it.lists.map(renderList)})}
+
+ );
+}
+
+function renderInlines(nodes: Inline[]): ReactNode[] {
+ return nodes.map((x, n) => {
+ switch (x.t) {
+ case "text": return x.text;
+ case "br": return
;
+ case "strong": return {renderInlines(x.children)};
+ case "em": return {renderInlines(x.children)};
+ case "del": return {renderInlines(x.children)};
+ case "ins": return {renderInlines(x.children)};
+ case "sup": return {renderInlines(x.children)};
+ case "sub": return {renderInlines(x.children)};
+ case "code": return {x.text};
+ case "link": return {renderInlines(x.children)};
+ case "issue": return {x.children.length ? renderInlines(x.children) : undefined};
+ case "mention": return @{x.login};
+ }
+ });
+}
diff --git a/web/src/issues/wiki/wiki.css b/web/src/issues/wiki/wiki.css
new file mode 100644
index 0000000..3e769bf
--- /dev/null
+++ b/web/src/issues/wiki/wiki.css
@@ -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}