feat(web): WikiText — рендер вики-разметки Jira React-элементами, исходник при ошибке (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bd042f0d17
commit
3fe820d195
13
web/src/issues/wiki/WikiText.fallback.test.tsx
Normal file
13
web/src/issues/wiki/WikiText.fallback.test.tsx
Normal 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();
|
||||
});
|
||||
73
web/src/issues/wiki/WikiText.test.tsx
Normal file
73
web/src/issues/wiki/WikiText.test.tsx
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
web/src/issues/wiki/WikiText.tsx
Normal file
74
web/src/issues/wiki/WikiText.tsx
Normal 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>;
|
||||
}
|
||||
});
|
||||
}
|
||||
21
web/src/issues/wiki/wiki.css
Normal file
21
web/src/issues/wiki/wiki.css
Normal 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}
|
||||
Loading…
x
Reference in New Issue
Block a user