sdlc-hub/docs/superpowers/plans/2026-10-17-wiki-render.md
Andrey Epifancev edaa3a5b90 docs(plan): ветка в шапке плана (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:02:37 +04:00

249 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Рендер вики-разметки 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
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("(?<![\\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:
```ts
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")`
с 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}` виден;
- `<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)`.