23 KiB
Рендер вики-разметки Jira в карточке задачи — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: описание и комментарии в карточке задачи показываются отрендеренной вики-разметкой Jira, у каждого блока — кнопка «исходник» / «текст».
Architecture: чистый разборщик web/src/issues/wiki/ (строчный inline.ts, блочный parse.ts) строит дерево;
WikiText.tsx превращает его в React-элементы без innerHTML и при ошибке показывает исходник; IssueCard.tsx
получает переключатель у описания и у каждого комментария.
Branch: feat/53-wiki-render (issue aep/jira-hub#53)
Tech Stack: React 19, TypeScript 6, Vitest 5 + Testing Library, Playwright; новых зависимостей нет.
Spec: openspec/changes/wiki-render/ (proposal.md, design.md, specs/web-app/spec.md — требование «Разметка Jira в
карточке» и изменённое «Карточка задачи»; рулинги R-1…R-8 в design.md).
Global Constraints
- Язык комментариев, коммитов, текстов интерфейса — русский; идентификаторы — английские.
- Без новых зависимостей в
web/package.json. innerHTML/dangerouslySetInnerHTMLвweb/src/issues/wiki/и в изменённом коде карточки — запрещены.- Ссылки — только схемы
http,https,mailto(без учёта регистра, после обрезки пробелов);target="_blank" rel="noopener noreferrer". - Ключ задачи — только
<project_key>-<цифры>,project_keyиз настроек хаба (GET /settings); без него ключи — текст. - Тексты кнопок ровно: «исходник», «текст»; «развернуть», «свернуть» — как сейчас.
- Фикстуры — синтетические тексты по образцу реальных, без данных заказчика (R-1).
- Проверка ветки:
cd web && npm run lint && npm testзелёные; e2e —cd web && npm run e2e.
Review Focus
- Текст с
\r\n(Jira отдаёт CRLF — так в реальных описаниях) — разбирается как с\n,\rнигде не виден. Тест — Task 2. |внутри ссылки[текст|url]в ячейке таблицы — не режет ячейку. Тест — Task 2.- Незакрытые маркеры и блоки (
*жирныйбез пары,{code}без конца,[ссылкабез]) — текст как есть, ничего не пропадает. Тесты — Task 1, Task 2. - Пустая строка и строка из одних пробелов в описании / комментарии (
body: "") — без падения, без пустых абзацев. Тест — Task 2, Task 4. - Длинный адрес или широкая таблица — прокрутка внутри блока, окно карточки не расширяется по горизонтали. Тест — Task 5 (e2e, узкий экран уже есть — дополнить).
Task 1: Строчный разборщик parseInline
Files:
- Create:
web/src/issues/wiki/inline.ts - Test:
web/src/issues/wiki/inline.test.ts
Interfaces:
-
Produces:
export interface WikiOptions { projectKey?: string } export type Inline = | { t: "text"; text: string } | { t: "strong" | "em" | "del" | "ins" | "sup" | "sub"; children: Inline[] } | { t: "code"; text: string } // {{…}} | { t: "link"; href: string; children: Inline[] } // проверенная схема | { t: "issue"; key: string; children: Inline[] } // children пустые → показывать key | { t: "mention"; login: string } // [~login] | { t: "br" }; // \\ export function parseInline(line: string, opts?: WikiOptions): Inline[];Вход — одна строка без
\n. Соседниеtextсливаются, пустыхtextнет. -
Step 1: Тесты —
inline.test.ts, табличныйit.each«вход → дерево» (toEqual),P = { projectKey: "AURORA" }:*Итог:* ошибка→[strong[text "Итог:"], text " ошибка"];_к_,-з-,+п+,^в^,~н~→ em/del/ins/sup/sub;- вложенность
*_x_*→strong[em[text x]]; {{data_type}}→code "data_type";{{*x*}}→code "*x*"(без разбора);- обычный текст без изменений (один
text):data_type = null,2026-10-15,счёт-фактура,a*b*c,18 803 903,65 - 9 351 102,89,**важно**,* одинокая,*незакрытый,[без конца; - граница закрывающего:
*x*y→ текст как есть;(*x*)→text "(" , strong[x], text ")"; \*не жирный\*→text "*не жирный*";a\\b→text a, br, text b;[Jira|https://x.ru/a?b=1]→link href "https://x.ru/a?b=1" [text Jira];[https://x.ru]→ link с текстом адреса;[mail|MAILTO:a@b.ru]→ link;[нажми|javascript:alert(1)]и[x|ftp://h]→ одинtextс исходником;- голый адрес
см. https://x.ru/p, далее→text "см. ",link "https://x.ru/p",text ", далее"; хвост.,;:!?)не входит в адрес; [~ivanov]→mention "ivanov";- ключи с
P:ведётся в AURORA-1445; AURORA-1645 снята→issue AURORA-1445,issue AURORA-1645с пустыми children;[AURORA-1]→issue AURORA-1 [];[см.|AURORA-2]→issue AURORA-2 [text "см."];UTF-8,SHA-256,DOC-221280,XAURORA-1,AURORA-12x→ текст; ключ внутри{{…}}и внутри адресаhttps://j/browse/AURORA-1→ не issue; - без
projectKey:AURORA-1445→ текст.
-
Step 2:
cd web && npx vitest run src/issues/wiki/inline.test.ts— FAIL (модуля нет). -
Step 3: Реализация
parseInline— однопроходный сканер по позиции (design #4, #5). Границы маркеров* _ - + ^ ~: открывающий — в начале строки или после символа не\p{L}\p{N}, за ним не пробел и не тот же символ (R-7); закрывающий — тот же символ, перед ним не пробел, за ним конец или не\p{L}\p{N}; ищется ближайший подходящий в пределах строки; содержимое — рекурсивноparseInline. Ключ —new RegExp("(?<![\\p{L}\\p{N}_-])" + escape(projectKey) + "-\\d+(?![\\p{L}\\p{N}_])", "u"). Проверка схемы — отдельная функцияsafeHref(raw): string | null, экспортировать для Task 3 не нужно. -
Step 4: тот же запуск — PASS;
npm run lint— без ошибок. -
Step 5: Коммит
feat(web): строчный разборщик вики-разметки Jira (#53).
Task 2: Блочный разборщик parseWiki
Files:
- Create:
web/src/issues/wiki/parse.ts - Test:
web/src/issues/wiki/parse.test.ts
Interfaces:
-
Consumes:
parseInline,Inline,WikiOptionsиз./inline.ts. -
Produces:
export interface ListBlock { t: "list"; ordered: boolean; items: ListItem[] } export interface ListItem { children: Inline[]; lists: ListBlock[] } // lists — вложенные списки пункта 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[] } // строки абзаца через { t: "br" } | ListBlock | { t: "table"; rows: Cell[][] } | { t: "pre"; text: string } // {code}, {code:язык}, {noformat} | { t: "quote"; children: Block[] } // {quote}…{quote}, bq. | { t: "hr" }; export function parseWiki(text: string, opts?: WikiOptions): Block[]; -
Step 1: Тесты —
parse.test.ts,toEqualна деревьях (строчные части — черезparseInlineв ожиданиях можно не повторять: сравниватьchildrenс[{t:"text",text:"…"}]для простых строк):- сценарий спеки «Описание со структурой»:
h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка в {{data_type}}→heading 3,p "Шаги:",list ordered 2 пункта,p [strong, text, code]; - абзац из двух строк →
p [text, br, text]; две пустые строки подряд → два абзаца, пустыхpнет;""и" \n "→[]; - CRLF: тот же текст с
\r\nдаёт то же дерево,\rнигде нет; - сценарий «Списки с отступом и пустыми строками»:
Шаги воспроизведения:\n * Загрузить ОСВ.\n\n * Дождаться обработки.→p, одинlist(unordered) из двух пунктов (R-4); - вложенность:
* a\n** b\n*# c\n* d→ список изa(вложенные: unordered[b], ordered[c]) иd;- x\n- y→ unordered из 2;-- x,*- x→ абзацы;----→hr;## Заголовок→list orderedиз одного пункта с пустымиchildrenи вложенным ordered-списком[Заголовок](как в Jira — второй уровень); - список, затем строка без маркера → список и абзац;
- таблица
||Поле||Значение||\n|data_type|null|\n|ссылка|[a|https://x.ru]|→ 3 строки, первая — header-ячейки, в третьей 2 ячейки, вторая —link(Review Focus 2); {code:sql}\nselect *\nfrom t\n{code}→pre "select *\nfrom t";{noformat}*x*{noformat}→pre "*x*";{code}без закрывающего → строки абзацем как есть ({code}виден текстом);{quote}\nh3. Внутри\nтекст\n{quote}хвост→quote [heading, p], затемp "хвост";bq. цитата→quote [p];см. {quote}x{quote}→ одинpс исходником (R-8);- заголовки
h1.–h6.;h7. xиh3.без пробела→ абзац; - неизвестное
{color:red}важно{color},!схема.png!,{panel}x{panel}→ абзацы с исходником как есть.
- сценарий спеки «Описание со структурой»:
-
Step 2:
npx vitest run src/issues/wiki/parse.test.ts— FAIL. -
Step 3: Реализация
parseWiki— нормализация\r\n/\r→\n, затем построчный проход (design #3): классификация строки, сборка абзацев, списков (стек по глубине маркеров, вид — по последнему маркеру; пустые строки между пунктами одного списка пропускаются, если следующая непустая строка — пункт), таблиц (ячейки — разбиение по||/|вне[…]), блоков-макросов с поиском закрывающего тега, рекурсивныйparseWikiдля{quote}. -
Step 4: PASS;
npm run lint. -
Step 5: Коммит
feat(web): блочный разборщик вики-разметки Jira (#53).
Task 3: Компонент WikiText
Files:
- Create:
web/src/issues/wiki/WikiText.tsx,web/src/issues/wiki/wiki.css - Test:
web/src/issues/wiki/WikiText.test.tsx,web/src/issues/wiki/WikiText.fallback.test.tsx
Interfaces:
-
Consumes:
parseWiki,Block,Inline,WikiOptions;IssueLink(../IssueLink.tsx, нужен роутер). -
Produces:
export function WikiText({ text, projectKey }: { text: string; projectKey?: string }): JSX.Element— корень<div className="wiki">; при ошибке разбора или рендера —<pre className="wiki-source">{text}</pre>. -
Step 1: Тесты
WikiText.test.tsx(рендер внутриcreateMemoryRouter, как вIssueCard.test.tsx):- структура сценария «Описание со структурой»:
getByRole("heading", {level: 3, name: "Контекст"}),getByRole("list")с 2listitem, «Итог:» внутриstrong, «data_type» внутриcode;container.textContentне содержитh3.,{{; - таблица:
getAllByRole("columnheader")— 2,getAllByRole("cell")— 4; - внешняя ссылка:
getByRole("link", {name: "Jira"})—hrefhttps://x.ru/,target_blank,relnoopener noreferrer; - ключ:
AURORA-1445сprojectKey="AURORA"→ ссылка сhref/issues/AURORA-1445; безprojectKey— не ссылка; [~ivanov]→ текст@ivanov;- сценарий «Неизвестная разметка и опасный адрес»:
queryByRole("link")иqueryByRole("img")—null, текст[нажми|javascript:alert(1)]и{color:red}важно{color}виден; <script>alert(1)</script>и<b>x</b>в тексте → видны как текст,container.querySelector("script, b")—null;- исходники модуля (
fs.readFileSyncвсех*.ts(x)вsrc/issues/wiki/, кроме тестов) не содержатdangerouslySetInnerHTMLиinnerHTML.
- структура сценария «Описание со структурой»:
-
Step 2:
WikiText.fallback.test.tsx:vi.mock("./parse.ts", () => ({ parseWiki: () => { throw new Error("x"); } }));render(<WikiText text={"h3. A\nB"} />)→container.querySelector("pre.wiki-source")сtextContent"h3. A\nB". -
Step 3:
npx vitest run src/issues/wiki/— новые тесты FAIL. -
Step 4: Реализация —
WikiText= граница ошибок-класс (по образцуshell/ErrorBoundary.tsx, фолбэк — исходник,console.errorвcomponentDidCatch) вокруг внутреннего компонента, который вызываетparseWikiи рендерит дерево (design #2).wiki.css— вид по design #9: компактные отступыp/ul/ol(первый/последний без внешних отступов), заголовки 15/14/13 px полужирные, таблица с рамкамиvar(--line)иdisplay:block;overflow-x:auto,pre/code—var(--font-mono),preсoverflow-x:auto,blockquote— левая полосаvar(--line), длинные слова и адреса —overflow-wrap:anywhere;.wiki { line-height: 1.5 }. -
Step 5: PASS;
npm run lint. -
Step 6: Коммит
feat(web): WikiText — рендер вики-разметки без innerHTML, исходник при ошибке (#53).
Task 4: Карточка — рендер и переключатель «исходник» / «текст»
Files:
- Modify:
web/src/issues/IssueCard.tsx(Description~102–119,Comments~121–144),web/src/issues/issues.css:43-50,web/src/api/types.ts:15-20(project_key?: stringвHubSettings) - Test:
web/src/issues/IssueCard.test.tsx
Interfaces:
-
Consumes:
WikiText(Task 3),useSettings().data?.project_key. -
Produces (внутри
IssueCard.tsx):SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void })—<button className="link" type="button" aria-pressed={source}>{source ? "текст" : "исходник"}</button>;Description({ text, projectKey });Comment({ c, projectKey }). -
Step 1: Тесты в
IssueCard.test.tsx(настройки в моке —{ ...settings, project_key: "AURORA" }); существующие тесты описания, опирающиеся на<pre class="description">и точный текст"строка 1\nстрока 2", переписать под режим рендера:- по умолчанию описание отрендерено:
h3. Контекст→heading {level: 3, name: "Контекст"}внутри раздела «Описание»; кнопкаgetByRole("button", {name: "исходник"})в заголовке раздела; - «исходник» у описания →
pre.descriptionс текстом исходника (h3. Контекствиден), кнопка — «текст»; комментарии остаются отрендеренными; «текст» → снова заголовок; - сценарий «Исходник у одного комментария»: два комментария
*a*и*b*; «исходник» у второго (кнопки в.who,within(items[1])) → второй —pre.bodyс*b*, первый —strong«a»; затемrouter.navigate("/issues/AURORA-9002")и обратно → оба отрендерены (карточка монтируется заново; в тесте — сменаissueKeyчерез маршрут, при необходимости обернутьCardAtвkey={key}, как делаетIssueModal); - ключ в комментарии
П. 1 ведётся в AURORA-9002→ ссылка; щелчок → карточка AURORA-9002 (router.state.location.pathname); - свёртка в режиме рендера: описание из 20 абзацев,
scrollHeight/clientHeightзамоканы черезObject.defineProperty(HTMLElement.prototype, …)как в существующем тесте свёртки → видна «развернуть», контейнерdiv.description.clamped; «развернуть» → безclamped, «свернуть»; «исходник» не сбрасывает «развёрнуто»; - описание из 3 пунктов
* xчерез пустые строки (5 строк исходника) с замоканнымscrollHeight == clientHeight→ в режиме рендера кнопки «развернуть» нет (design #8); - комментарий с
body: ""— без падения, автор виден (существующий тест — сохранить зелёным).
- по умолчанию описание отрендерено:
-
Step 2:
npx vitest run src/issues/IssueCard.test.tsx— новые тесты FAIL. -
Step 3: Реализация.
Description: заголовок раздела —.sec-head(как у комментариев) сh3«Описание» иSourceToggle; состоянияsource,open,overflow; тело —source ? <pre ref className={"description" + (open ? "" : " clamped")}>{text}</pre> : <div ref className={"description" + (open ? "" : " clamped")}><WikiText text projectKey /></div>; признак «длинное» — в режиме исходника прежний, в режиме рендера только измерение (useLayoutEffectс зависимостями[text, open, source]).Comment—<li>с.who(автор, время,SourceToggle) и теломpre.body/div.body > WikiText. CSS: правило-webkit-line-clamp— только дляpre.description.clamped; дляdiv.description.clamped—max-height: 12em(8 строк ×line-height: 1.5) иoverflow: hiddenс тем же приёмом нижней рамки;div.descriptionиdiv.body— безwhite-space: pre-wrap;.who— flex с кнопкой справа. Подключить./wiki/wiki.cssчерез импорт вWikiText.tsx(Task 3) — здесь не повторять. -
Step 4:
npm test— весь набор фронта PASS;npm run lint. -
Step 5: Коммит
feat(web): карточка — описание и комментарии в разметке Jira, кнопка «исходник» (#53).
Task 5: e2e на тестовом хабе и проверка ветки
Files:
- Modify:
web/scripts/seed-extras.json(описание и комментарии AURORA-9001),web/e2e/issues.spec.ts:72-93и тест узкого экрана (~111)
Interfaces:
-
Consumes: всё из Task 1–4; тестовый хаб
bash web/scripts/test-hub.sh(поднимает Playwright по конфигу). -
Step 1: Фикстура. Описание AURORA-9001 — вики-разметка не короче 12 строк исходника и заведомо выше 8 строк в рендере, с
h3. Контекст, нумерованным списком,*жирным*,{{моно}}, таблицей шириной больше узкого экрана (Review Focus 5) и последней строкойКонец описания.; комментарий 502 —Схему согласовали, см. AURORA-9002.; комментарий 501 —*Первый комментарий e2e*\nвторая строка. Тексты синтетические (R-1). -
Step 2: e2e. В тесте карточки: локаторы
pre.description→.description; добавить: заголовок «Контекст» виден,# …не виден текстом; «исходник» у описания →pre.descriptionсодержитh3. Контекст, «текст» — обратно; «исходник» у комментария 501 показывает*Первый комментарий e2e*только в нём; щелчок поAURORA-9002в комментарии → заголовок окна «API свода», «назад» → AURORA-9001 с отрендеренными блоками. В тесте узкого экрана — после «развернуть» у описанияdocument.documentElement.scrollWidth <= innerWidthи у диалога нет горизонтальной прокрутки (таблица прокручивается внутри). -
Step 3:
cd web && npm run e2e— PASS (весь набор). -
Step 4:
make checkиз корня worktree — PASS (pytest + lint + тесты фронта). -
Step 5: Коммит
test(e2e): разметка Jira и кнопка «исходник» в карточке (#53).