# Рендер вики-разметки 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` из настроек хаба (`GET /settings`); без него ключи — текст. - Тексты кнопок ровно: «исходник», «текст»; «развернуть», «свернуть» — как сейчас. - Фикстуры — синтетические тексты по образцу реальных, без данных заказчика (R-1). - Проверка ветки: `cd web && npm run lint && npm test` зелёные; e2e — `cd web && npm run e2e`. ## Review Focus 1. Текст с `\r\n` (Jira отдаёт CRLF — так в реальных описаниях) — разбирается как с `\n`, `\r` нигде не виден. Тест — Task 2. 2. `|` внутри ссылки `[текст|url]` в ячейке таблицы — не режет ячейку. Тест — Task 2. 3. Незакрытые маркеры и блоки (`*жирный` без пары, `{code}` без конца, `[ссылка` без `]`) — текст как есть, ничего не пропадает. Тесты — Task 1, Task 2. 4. Пустая строка и строка из одних пробелов в описании / комментарии (`body: ""`) — без падения, без пустых абзацев. Тест — Task 2, Task 4. 5. Длинный адрес или широкая таблица — прокрутка внутри блока, окно карточки не расширяется по горизонтали. Тест — Task 5 (e2e, узкий экран уже есть — дополнить). --- ### Task 1: Строчный разборщик `parseInline` **Files:** - Create: `web/src/issues/wiki/inline.ts` - Test: `web/src/issues/wiki/inline.test.ts` **Interfaces:** - Produces: ```ts 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("(?`; при ошибке разбора или рендера — `
{text}
`. - [ ] **Step 1: Тесты** `WikiText.test.tsx` (рендер внутри `createMemoryRouter`, как в `IssueCard.test.tsx`): - структура сценария «Описание со структурой»: `getByRole("heading", {level: 3, name: "Контекст"})`, `getByRole("list")` с 2 `listitem`, «Итог:» внутри `strong`, «data_type» внутри `code`; `container.textContent` не содержит `h3.`, `{{`; - таблица: `getAllByRole("columnheader")` — 2, `getAllByRole("cell")` — 4; - внешняя ссылка: `getByRole("link", {name: "Jira"})` — `href` `https://x.ru/`, `target` `_blank`, `rel` `noopener noreferrer`; - ключ: `AURORA-1445` с `projectKey="AURORA"` → ссылка с `href` `/issues/AURORA-1445`; без `projectKey` — не ссылка; - `[~ivanov]` → текст `@ivanov`; - сценарий «Неизвестная разметка и опасный адрес»: `queryByRole("link")` и `queryByRole("img")` — `null`, текст `[нажми|javascript:alert(1)]` и `{color:red}важно{color}` виден; - `` и `x` в тексте → видны как текст, `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()` → `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 })` — ``; `Description({ text, projectKey })`; `Comment({ c, projectKey })`. - [ ] **Step 1: Тесты** в `IssueCard.test.tsx` (настройки в моке — `{ ...settings, project_key: "AURORA" }`); существующие тесты описания, опирающиеся на `
` и точный текст `"строка 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 ? 
{text}
:
`; признак «длинное» — в режиме исходника прежний, в режиме рендера только измерение (`useLayoutEffect` с зависимостями `[text, open, source]`). `Comment` — `
  • ` с `.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)`.