249 lines
23 KiB
Markdown
249 lines
23 KiB
Markdown
# Рендер вики-разметки 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)`.
|