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}