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

23 KiB
Raw Blame History

Рендер вики-разметки 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:

    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") с 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).