diff --git a/docs/superpowers/plans/2026-10-17-wiki-render.md b/docs/superpowers/plans/2026-10-17-wiki-render.md new file mode 100644 index 0000000..c698772 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-wiki-render.md @@ -0,0 +1,248 @@ +# Рендер вики-разметки 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)`. diff --git a/openspec/changes/wiki-render/.openspec.yaml b/openspec/changes/wiki-render/.openspec.yaml new file mode 100644 index 0000000..b699b88 --- /dev/null +++ b/openspec/changes/wiki-render/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/wiki-render/design.md b/openspec/changes/wiki-render/design.md new file mode 100644 index 0000000..b1dd1ed --- /dev/null +++ b/openspec/changes/wiki-render/design.md @@ -0,0 +1,105 @@ +## Context + +`main` 86739cf. Устройство, на которое опираются правки: + +- **Данные.** `GET /issues/{key}` отдаёт `description: string | null` и `comments: [{comment_id, author, created, + updated, body}]` — тексты Jira как есть (вики-разметка Jira Server, REST v2). Хаб и MCP не меняются. +- **Карточка** — `web/src/issues/IssueCard.tsx`: `Description` выводит текст в `
    ` со свёрткой
    +  до 8 строк (`-webkit-line-clamp` на `.description.clamped`, признак «длинное» — больше 8 строк или
    +  `scrollHeight > clientHeight`, кнопка «развернуть» / «свернуть»); `Comments` выводит `
    ` в каждом
    +  `
  • `, над ним `.who` (автор, время); «Скрыть» / «Показать» раздела — `useCommentsHidden` (localStorage). + Стили — `web/src/issues/issues.css`, токены цветов — `var(--panel)`, `var(--line)`, `var(--muted)` и др. +- **Ссылка на карточку** — `IssueLink` (`web/src/issues/IssueLink.tsx`): ``, открывает задачу в том же окне с сохранением страницы под ним. +- **Реальные тексты AURORA** (выборка 17.10.2026): заголовки `h3.`; нумерованные `#` и маркированные `*` списки; + ` * пункт` с пробелом перед маркером и пустыми строками между пунктами (шаблон ошибок); `*Жирный заголовок.*` в начале + комментария; ключи задач `AURORA-1445` в тексте; в описаниях, написанных через MCP, — Markdown-конструкции + (`` `код` ``, `- пункт`), которые Jira показывает как есть (`-` — список Jira). + +## Goals / Non-Goals + +**Goals:** описание и комментарии читаются так, как в Jira; исходник любого блока — одной кнопкой; рендер не ломает +карточку ни на каком тексте и не открывает путь для вставки разметки или скриптов. + +**Non-Goals:** см. proposal.md. + +## Decisions + +1. **Модуль `web/src/issues/wiki/`.** `parse.ts` — чистая функция `parseWiki(text: string): Block[]` (дерево блоков и + строчных узлов, без React); `WikiText.tsx` — компонент ``, превращающий дерево в React-элементы. + Разделение даёт тесты разборщика без DOM и один компонент для описания и комментариев. +2. **Дерево.** Блоки: `heading {level, inlines}`, `paragraph {inlines}` (одиночные переносы внутри — узел `br`), + `list {ordered, items: [{inlines, children: list[]}]}`, `table {rows: [{cells: [{header, inlines}]}]}`, + `pre {text}` (`{code}`, `{noformat}`), `quote {blocks}` (`{quote}`, `bq.`), `hr`. Строчные: `text`, `strong`, `em`, + `del`, `ins`, `sup`, `sub`, `code` (`{{…}}`), `link {href, inlines}`, `issue {key, inlines?}`, `mention {login}`, + `br`. Рендер: `h1`–`h6` (внутри карточки уровни сдвигаются стилем, не тегом — тег по разметке), `p`, `ul`/`ol`/`li`, + `table`/`th`/`td`, `pre`, `blockquote`, `hr`, `strong`, `em`, `del`, `ins`, `sup`, `sub`, `code`, + ``, `IssueLink`. +3. **Разбор блоков — построчно.** Строка классифицируется по началу: `h[1-6]. `, маркеры списка `^\s*[*#]+ ` или + `^\s*- ` (ровно один `-`; `--`, `*-`, `-#` — не список), `|`, `{code…}` / `{noformat}` / `{quote}` (открывающий тег в + начале строки после необязательных пробелов; содержимое — всё до первого закрывающего тега, который может стоять + в той же строке или в любой последующей; остаток строки после закрывающего тега разбирается как новая строка; + тег не в начале строки — обычный текст), `bq. `, `----`, пустая строка; прочее — абзац. Соседние строки списка образуют один список; пустая строка между пунктами одного вида списка **не** рвёт + список (так Jira показывает шаблон ошибок с пустыми строками между ` * `). Незакрытый `{code}` / `{noformat}` / + `{quote}` — открывающая строка разбирается как обычный текст (видна как есть). +4. **Строчный разбор — однопроходный по позиции** с проверкой границ из спеки: открывающий маркер — в начале текста + или после пробела / знака препинания, за ним не пробел; за открывающим не тот же символ (`**x**`, написанное через MCP Markdown-ом, + остаётся текстом как есть — читается лучше полуразобранного); закрывающий — тот же символ после не-пробела, за ним не + буква и не цифра; закрывающий ищется до конца строки (выделение не переходит через перенос). Без пары — символ + остаётся текстом. Вложенность (жирный курсив `*_x_*`) — рекурсивным разбором содержимого. `{{…}}` и содержимое + `{code}` не разбираются. `\` экранирует следующий символ разметки. +5. **Ссылки и безопасность.** Адрес ссылки проходит проверку схемы (`http:`, `https:`, `mailto:`, без учёта регистра, + после обрезки пробелов); иначе вся конструкция `[…]` — текст. Голые адреса — `https?://` до пробела, хвостовые + `.,;:!?)` не входят в адрес. Ключ задачи — `\b-\d+\b`, где `project_key` — из настроек хаба + (`useSettings()`, сейчас `AURORA`), не внутри адреса, `{{…}}`, `{code}`; `parseWiki(text, {projectKey})`, без + `projectKey` (настройки ещё не загружены) ключи — текст. Так `UTF-8`, `SHA-256`, `DOC-221280` ссылками не становятся. Рендер + только через React-элементы: текст экранирует React, `href` — после проверки схемы; `dangerouslySetInnerHTML` в + модуле нет (проверка — тестом поиска по исходникам модуля). +6. **Отказоустойчивость.** `WikiText` — собственная маленькая граница ошибок (класс с `getDerivedStateFromError`, по + образцу `shell/ErrorBoundary.tsx`, но с фолбэком «исходник», а не «Страница сломалась»), внутри — разбор и рендер: + при исключении блок показывается исходником (`
    `), остальная карточка работает. Разборщик
    +   не бросает на нормальном пути; граница — страховка.
    +7. **Переключатель.** Компонент `SourceToggle` — кнопка `.link` с текстом «исходник» (в режиме рендера) / «текст» (в
    +   режиме исходника), `aria-pressed`. Состояние — `useState(false)` внутри `Description` и внутри компонента одного
    +   комментария (`Comment`, ключ — `comment_id`); `IssueModal` монтирует карточку заново при смене задачи
    +   (``), поэтому выбор не переживает переход по связи и закрытие окна. Без localStorage.
    +8. **Свёртка в режиме рендера.** `-webkit-line-clamp` не работает на контейнере с блочными детьми (списки, таблицы),
    +   поэтому свёрнутое описание ограничивается высотой: `max-height` = 8 строк (`calc(8 * 1.5em)` при `line-height:
    +   1.5`) и `overflow: hidden`, признак «длинное» в режиме рендера — только измерение `scrollHeight > clientHeight + 1`
    +   после рендера (без счёта строк исходника: шаблон ошибок с пустыми строками между пунктами иначе показал бы
    +   «развернуть» без нужды). В режиме исходника свёртка и признак прежние (больше 8 строк исходника или измерение). Переключение режима не сбрасывает «развёрнуто».
    +9. **Вид.** Отрендеренный текст — в том же «листе», что и сейчас (`.description`: фон `var(--panel)`, рамка, отступ),
    +   шрифт интерфейса; заголовки — 15/14/13 px полужирным, отступы абзацев и списков компактные; таблица — рамки
    +   `var(--line)`, заголовочные ячейки — фоном; `{code}` / `{noformat}` / `{{…}}` — `var(--font-mono)`; цитата — левая полоса
    +   `var(--line)`; широкая таблица или `pre` — горизонтальная прокрутка внутри блока, окно не расширяется.
    +10. **Тесты.** Разборщик — таблица случаев «текст → дерево» в `parse.test.ts` (все конструкции спеки, границы
    +    выделения, ложные срабатывания `data_type`, даты, `a*b*c`, числа с ` - `, незакрытые блоки, опасные схемы, ключи
    +    задач, тексты по образцу реальных AURORA). Компонент — `WikiText.test.tsx` (теги, ссылки, `IssueLink`, неизвестное
    +    текстом, фолбэк при ошибке). Карточка — `IssueCard.test.tsx` (кнопки у описания и у каждого комментария,
    +    независимость, сброс при новом монтировании, свёртка в обоих режимах). e2e — сценарий «Описание со структурой» и
    +    «Переключение на исходник» в `web/e2e/issues.spec.ts`. Фикстуры — синтетические тексты по образцу реальных, без
    +    данных заказчика (R-1).
    +
    +## Risks / Trade-offs
    +
    +- Собственный разборщик расходится с Jira в краевых случаях (вложенные таблицы, смешанные списки, макросы). Смягчение:
    +  неразобранное — текстом, исходник — одной кнопкой; расхождение видно и чинится точечно.
    +- Высота свёртки в режиме рендера — приблизительно 8 строк (заголовок или таблица выше строки текста).
    +
    +## Decisions made without discussion
    +
    +| Маркер | Дата | Решение | Обоснование | Цена ошибки |
    +|---|---|---|---|---|
    +| R-1 | 2026-10-17 | Тестовые тексты — синтетические по образцу реальных описаний AURORA, без копирования текстов задач в репозиторий | В задачах — названия организаций и цифры заказчика; конструкции разметки воспроизводятся без них | Пропущенная редкая конструкция реального текста; видна в карточке, чинится тестом |
    +| R-2 | 2026-10-17 | Ссылками становятся только ключи проекта `project_key` из настроек хаба (`AURORA-123`), открывают карточку в окне хаба, а не в Jira | Jira делает ссылками ключи существующих проектов; хаб знает только свой проект, а общий шаблон `[A-Z]+-\d+` ловит `UTF-8`, `SHA-256`, `DOC-221280` (builder check); переход в Jira есть в шапке | Ключи других проектов Jira — текстом; задача AURORA вне зеркала — «нет в зеркале» |
    +| R-3 | 2026-10-17 | `[~логин]` показывается как «@логин»: отображаемых имён пользователей в хабе нет | Jira показывает имя, но словаря логин → имя в зеркале нет | Логин вместо ФИО |
    +| R-4 | 2026-10-17 | Пустая строка между пунктами списка одного вида не рвёт список | Так выглядит шаблон ошибок AURORA (` * пункт`, пустая строка, ` * пункт`); один список читается лучше трёх | Два задуманных отдельными списка сливаются в один |
    +| R-5 | 2026-10-17 | Свёрнутое описание в режиме рендера ограничивается высотой ≈ 8 строк, а не числом строк | `-webkit-line-clamp` не работает на блочном содержимом | Свёрнутая часть немного выше или ниже 8 строк |
    +| R-6 | 2026-10-17 | Подсветка синтаксиса `{code}`, макросы `{panel}`, `{color}`, картинки, эмодзи, типографика `--` — вне change | Утверждённый объём; неразобранное видно текстом | Эти конструкции видны исходником |
    +| R-7 | 2026-10-17 | Открывающий маркер выделения, за которым тот же символ (`**`, `__`, `--`), не считается маркером | Markdown `**жирный**` из описаний, написанных через MCP, при буквальном применении правил Jira разобрался бы в «жирное `*x` и `*`» — хуже исходника; Jira-разметка двойных маркеров не использует | `**x**` видно со звёздочками (как и в Markdown-исходнике) |
    +| R-8 | 2026-10-17 | Блочные макросы `{code}` / `{noformat}` / `{quote}` распознаются только с открывающим тегом в начале строки; закрывающий — где угодно дальше; остаток строки после него — обычный текст | Однострочный `{quote}` посреди фразы в задачах AURORA не встречался; построчный разбор проще и предсказуемее | Такой `{quote}` виден исходником |
    +| R-9 | 2026-10-17 | Селектор `.issue .comments ul` (список комментариев без маркеров, flex) сужен до `.issue .comments>ul` | Иначе списки разметки внутри комментариев теряли маркеры и раскладывались flex-ом | Нет: список комментариев — прямой потомок раздела |
    +| R-10 | 2026-10-17 | Тесты свёртки по измерению подменяют `scrollHeight`/`clientHeight` (`vi.spyOn(Element.prototype, …)`) | В jsdom высоты нулевые; прежний тест свёртки опирался на счёт строк исходника, а в режиме рендера признак — только измерение (design #8) | Нет: поведение в браузере проверяет e2e |
    +| R-11 | 2026-10-17 | В e2e узкого экрана проверяется ширина окна карточки, а не `document.scrollWidth`; таблица разметки на телефоне — отдельный e2e на 375 px (после I-1) | Страница под модальным окном шире экрана и до этого change (931 > 600 px), прокрутка под окном заблокирована | Ширина страницы под окном не проверяется — прежнее поведение |
    +| R-12 | 2026-10-17 | Перемер переполнения описания — `ResizeObserver` на контейнере и его первом ребёнке, пока описание свёрнуто (C-1) | Карточка из кэша монтируется до `showModal()` с нулевыми размерами; ресайз окна и догрузка шрифтов тоже меняют высоту | Без `ResizeObserver` (старый браузер) — прежний однократный замер |
    +| R-13 | 2026-10-17 | Подпись ссылки `[подпись|адрес]` разбирается без ссылок, голых адресов и ключей задач (находка ревью M-1, повышена до I-2) | `[AURORA-1445|https://…/browse/AURORA-1445]` — обычная запись Jira; иначе `` в `` и щелчок открывал карточку вместо адреса | Ключ в подписи не кликается отдельно — как в Jira |
    diff --git a/openspec/changes/wiki-render/proposal.md b/openspec/changes/wiki-render/proposal.md
    new file mode 100644
    index 0000000..af1c224
    --- /dev/null
    +++ b/openspec/changes/wiki-render/proposal.md
    @@ -0,0 +1,35 @@
    +## Why
    +
    +Описание и комментарии в карточке задачи веб-приложения показываются исходником вики-разметки Jira: `h3. Контекст`,
    +`# пункт`, ` * пункт`, `*жирный*` видны как символы, длинные структурированные описания (контекст, шаги
    +воспроизведения, критерии готовности) читаются плохо. Хаб хранит эти поля как есть (`description`, `body` — wiki,
    +требование «Таблица issue» data-model), разбора разметки в приложении нет — так записано в требовании «Карточка задачи».
    +
    +Решения владельца 17.10.2026 (brainstorming, jira-hub#53):
    +- показывать описание и комментарии **так, как их показывает Jira** — рендер вики-разметки Jira, а не Markdown;
    +  Markdown-конструкции в наших описаниях (обратные кавычки, `- пункт`) выглядят так же, как в Jira;
    +- исходник остаётся доступен: **кнопка у каждого блока** — у «Описания» и у каждого комментария отдельно.
    +
    +## What Changes
    +
    +- **Веб-приложение, карточка задачи:**
    +  - «Описание» и текст каждого комментария показываются отрендеренной вики-разметкой Jira: заголовки `h1.`–`h6.`,
    +    `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}`, списки `*` / `#` / `-` с вложенностью,
    +    таблицы `||заголовок||` / `|ячейка|`, блоки `{code}`, `{noformat}`, `{quote}`, `bq.`, ссылки `[текст|url]`, `[url]`
    +    и голые URL, упоминания `[~логин]`, ключи задач проекта (`AURORA-123`, открывают карточку в окне), `----`, разрыв строки `\\`;
    +  - всё, что разборщик не знает (`{panel}`, `{color}`, картинки `!файл!` и прочее), выводится обычным текстом как есть;
    +  - у «Описания» (в заголовке раздела) и у каждого комментария (в строке автора) — кнопка «исходник» / «текст»;
    +    исходник выглядит как сейчас — текст с сохранёнными переносами; при каждом открытии карточки всё показано
    +    отрендеренным, выбор не запоминается;
    +  - свёртка длинного описания («развернуть» / «свернуть») работает в обоих режимах.
    +- Разметка разбирается в браузере собственным модулем на TypeScript в React-элементы; `innerHTML` /
    +  `dangerouslySetInnerHTML` не используются, новых зависимостей нет.
    +
    +## Non-Goals
    +
    +- Хаб (API, данные, MCP) и агент не меняются; MCP `issue` по-прежнему отдаёт исходник.
    +- Markdown не разбирается.
    +- Полный набор макросов Jira (`{panel}`, `{color}`, `{anchor}`, `{toc}`, вложения-картинки, эмодзи `:)`, типографика
    +  `--` / `---`) — нет; неразобранное видно текстом.
    +- Подсветка синтаксиса в `{code}` — нет.
    +- Рендер разметки вне карточки (список «Задачи», доска, «Служебные заметки») — нет.
    diff --git a/openspec/changes/wiki-render/specs/web-app/spec.md b/openspec/changes/wiki-render/specs/web-app/spec.md
    new file mode 100644
    index 0000000..4f3b13d
    --- /dev/null
    +++ b/openspec/changes/wiki-render/specs/web-app/spec.md
    @@ -0,0 +1,131 @@
    +## MODIFIED Requirements
    +
    +### Requirement: Карточка задачи
    +Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
    +которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
    +загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
    +адрес (новая вкладка, закладка) SHALL
    +показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
    +отпущенное за краем окна, окно не закрывает) и
    +возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
    +закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
    +браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
    +прокручивается.
    +
    +Содержимое окна:
    +- шапка: ключ, статус Jira, статус по факту (цветом класса из `status_labels`), «Открыть в Jira», ✕, тема; строка
    +  «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
    +  если он раньше сегодняшней даты и класс статуса по факту не `fact-done` и не `fact-cancelled`; эпик — темой
    +  (`epic_summary`), иначе ключом;
    +- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
    +  завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
    +  «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
    +  в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в
    +  зеркале»; пустые группы не показываются, без связей — «связей нет»;
    +- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в
    +  заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания
    +  раздела нет;
    +- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст,
    +  отрендеренный по требованию «Разметка Jira в карточке»; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL
    +  запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»;
    +- «Служебные заметки» (требование «Служебные заметки»);
    +- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям;
    +- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут
    +  при открытии; если команд не было — текст «команд по задаче не было».
    +На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой;
    +уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно
    +SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные
    +остаются.
    +
    +#### Scenario: Связанная задача
    +- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе»
    +- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу
    +  меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620
    +
    +#### Scenario: Входящий блокер
    +- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500
    +- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от»
    +
    +#### Scenario: Задача без команд
    +- **WHEN** по AURORA-1620 не было команд
    +- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет
    +
    +#### Scenario: Закрытие после переходов
    +- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc
    +- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку
    +
    +#### Scenario: Прямой адрес
    +- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕
    +- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку
    +
    +#### Scenario: Скрытые комментарии
    +- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка
    +- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
    +
    +## ADDED Requirements
    +
    +### Requirement: Разметка Jira в карточке
    +Описание задачи и текст каждого комментария в карточке SHALL показываться отрендеренной вики-разметкой Jira (хаб
    +хранит их как есть, в wiki) так, как их показывает Jira. Разбираются:
    +- блоки: заголовки `h1.`–`h6.` в начале строки; списки — строки, начинающиеся (после необязательных пробелов) с
    +  маркеров `*`, `#` и пробела (уровень вложенности — числом маркеров, `*` — маркированный, `#` — нумерованный,
    +  смешанная вложенность `*#` — по последнему маркеру) или с одного `-` и пробела (маркированный, первый уровень);
    +  `--`, `*-` и прочие сочетания с `-` — не список; таблицы — строки, начинающиеся с
    +  `|`, ячейки `||заголовок||` и `|ячейка|`; `{code}` / `{code:язык}` … `{code}` и `{noformat}` … `{noformat}` —
    +  моноширинный блок без разбора разметки внутри; `{quote}` … `{quote}` и строка `bq. ` — цитата; открывающий тег
    +  блока — в начале строки, закрывающий — в любом месте той же или последующей строки, текст после закрывающего тега
    +  разбирается дальше как обычные строки; тег не в начале строки — текст как есть; строка `----` —
    +  горизонтальная черта; прочие строки — абзацы, разделённые пустыми строками, одиночный перенос внутри абзаца —
    +  перенос строки;
    +- внутри строки: `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}`
    +  (`{{…}}` — без разбора внутри); маркер выделения действует, только если открывающий стоит в начале текста или после
    +  пробела либо знака препинания и за ним не пробел и не тот же символ (`**x**` — текст как есть), а закрывающий стоит после не-пробела и за ним не буква и не
    +  цифра (`data_type`, `2026-10-15`, `счёт-фактура`, `a*b*c` — обычный текст); `\\` — перенос строки; `\` перед
    +  символом разметки — сам символ;
    +- ссылки: `[текст|адрес]`, `[адрес]` и голые адреса `http://`, `https://` — ссылка в новой вкладке, только для схем
    +  `http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида
    +  `ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне
    +  (`UTF-8`, `SHA-256` и ключи других проектов — текст);
    +  `[~логин]` — «@логин».
    +Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!`, незакрытые маркеры и блоки и прочее), SHALL
    +показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`,
    +`dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер
    +блока завершился ошибкой, блок SHALL показываться исходником.
    +
    +Кнопка «исходник» у описания и у каждого комментария SHALL переключать этот блок на исходник — текст с сохранёнными
    +переносами строк, как без разбора разметки, — и обратно кнопкой «текст»; остальные блоки не переключаются. При каждом
    +открытии карточки (в том числе при переходе по связи) все блоки показываются отрендеренными, выбор не запоминается.
    +
    +#### Scenario: Описание со структурой
    +- **WHEN** описание AURORA-1620 — `h3. Контекст`, строка `Шаги:`, затем `# Загрузить ОСВ` и `# Открыть документ`,
    +  затем пустая строка и `*Итог:* ошибка в {{data_type}}`
    +- **THEN** в «Описании» заголовок «Контекст», абзац «Шаги:», нумерованный список из двух пунктов и абзац, где «Итог:» —
    +  жирным, «data_type» — моноширинным; символов `h3.`, `#`, `*`, `{{` не видно
    +
    +#### Scenario: Переключение на исходник
    +- **WHEN** в карточке AURORA-1620 у описания нажато «исходник»
    +- **THEN** описание показано исходником с переносами строк (видны `h3. Контекст` и `# Загрузить ОСВ`), кнопка — «текст»;
    +  комментарии остаются отрендеренными; после «текст» описание снова отрендерено
    +
    +#### Scenario: Исходник у одного комментария
    +- **WHEN** у второго комментария нажато «исходник», затем открыта связанная задача и возврат «назад»
    +- **THEN** сразу после нажатия исходником показан только второй комментарий; после возврата все комментарии
    +  отрендерены
    +
    +#### Scenario: Ключ задачи в тексте
    +- **WHEN** комментарий — `П. 1 ведётся в AURORA-1445; AURORA-1645 снята как дубль.`
    +- **THEN** «AURORA-1445» и «AURORA-1645» — ссылки; щелчок по первой меняет содержимое окна на AURORA-1445
    +
    +#### Scenario: Неизвестная разметка и опасный адрес
    +- **WHEN** описание — `{color:red}важно{color}`, `!схема.png!` и `[нажми|javascript:alert(1)]`
    +- **THEN** все три показаны текстом исходника как есть, ссылки и картинки нет, скрипт не выполняется
    +
    +#### Scenario: Списки с отступом и пустыми строками
    +- **WHEN** описание — `Шаги воспроизведения:`, ` * Загрузить ОСВ.`, пустая строка, ` * Дождаться обработки.`
    +- **THEN** абзац «Шаги воспроизведения:» и маркированные пункты «Загрузить ОСВ.» и «Дождаться обработки.» без
    +  звёздочек
    +
    +#### Scenario: Обычный текст не портится
    +- **WHEN** описание — `Поле data_type = null, дата 2026-10-15, оборот 18 803 903,65 - 9 351 102,89, a*b*c, UTF-8,
    +  **важно**`
    +- **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок
    diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts
    index fb56137..912aa74 100644
    --- a/web/e2e/issues.spec.ts
    +++ b/web/e2e/issues.spec.ts
    @@ -39,12 +39,12 @@ test("переход по связи и «назад»; ✕ и щелчок по
       await page.getByRole("link", { name: "Задачи", exact: true }).click();
       await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
       const d = page.getByRole("dialog");
    -  await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
    +  await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click();
       await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
       await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
       await page.goBack();
       await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
    -  await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
    +  await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click();
       await d.getByRole("button", { name: "Закрыть" }).click();
       await expect(page).toHaveURL(/\/app\/issues$/);
       await expect(page.getByRole("dialog")).toHaveCount(0);
    @@ -75,12 +75,12 @@ test("карточка: описание свёрнуто, комментари
       await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
       await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
       await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
    -  await expect(d.locator("pre.description")).toHaveClass(/clamped/);          // свёрнуто построчным обрезанием (текст в DOM остаётся)
    -  const hClamped = (await d.locator("pre.description").boundingBox())!.height;
    -  expect(await d.locator("pre.description").evaluate((e) => e.scrollHeight > e.clientHeight)).toBe(true);
    +  await expect(d.locator("div.description")).toHaveClass(/clamped/);          // свёрнуто по высоте (текст в DOM остаётся)
    +  const hClamped = (await d.locator("div.description").boundingBox())!.height;
    +  expect(await d.locator("div.description").evaluate((e) => e.scrollHeight > e.clientHeight)).toBe(true);
       await d.getByRole("button", { name: "развернуть" }).click();
    -  await expect(d.locator("pre.description")).not.toHaveClass(/clamped/);
    -  expect((await d.locator("pre.description").boundingBox())!.height).toBeGreaterThan(hClamped);
    +  await expect(d.locator("div.description")).not.toHaveClass(/clamped/);
    +  expect((await d.locator("div.description").boundingBox())!.height).toBeGreaterThan(hClamped);
       await expect(d.getByText("Конец описания.")).toBeVisible();
       await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
       await d.getByText("Команды моста (2)").click();
    @@ -92,6 +92,57 @@ test("карточка: описание свёрнуто, комментари
       await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
     });
     
    +test("разметка Jira: описание и комментарии отрендерены, «исходник» у каждого блока, ключ задачи — ссылка", async ({ page }) => {
    +  await login(page, "/app/issues/AURORA-9001");
    +  const d = page.getByRole("dialog");
    +  const desc = d.locator("section", { has: page.getByRole("heading", { name: "Описание" }) });
    +  await expect(desc.getByRole("heading", { level: 3, name: "Контекст" })).toBeVisible();
    +  await expect(desc.getByRole("listitem").first()).toHaveText("Таблицы.");
    +  await expect(desc.getByText("КФО", { exact: true })).toHaveJSProperty("tagName", "STRONG");
    +  await expect(desc).not.toContainText("h3.");
    +  await desc.getByRole("button", { name: "исходник" }).click();
    +  await expect(desc.locator("pre.description")).toContainText("h3. Контекст");
    +  await expect(desc.locator("pre.description")).toContainText("# Таблицы.");
    +  await desc.getByRole("button", { name: "текст" }).click();
    +  await expect(desc.getByRole("heading", { level: 3, name: "Контекст" })).toBeVisible();
    +
    +  const items = d.getByTestId("comments").getByRole("listitem");
    +  const first = items.filter({ hasText: "Первый комментарий e2e" });
    +  await expect(first.getByText("Первый комментарий e2e")).toHaveJSProperty("tagName", "STRONG");
    +  await first.getByRole("button", { name: "исходник" }).click();
    +  await expect(first.locator("pre.body")).toContainText("*Первый комментарий e2e*");
    +  await expect(d.locator("pre.body")).toHaveCount(1);
    +
    +  await items.filter({ hasText: "Схему согласовали" }).getByRole("link", { name: "AURORA-9002" }).click();
    +  await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
    +  await page.goBack();
    +  await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
    +  await expect(d.locator("pre.body, pre.description")).toHaveCount(0);   // после перехода всё снова отрендерено
    +});
    +
    +test("C-1: повторное открытие из кэша — длинное описание свёрнуто с кнопкой «развернуть»", async ({ page }) => {
    +  await login(page, "/app/issues");
    +  await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
    +  const d = page.getByRole("dialog");
    +  await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
    +  await page.keyboard.press("Escape");
    +  await expect(page.getByRole("dialog")).toHaveCount(0);
    +  await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
    +  await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
    +});
    +
    +test("I-1: телефон 375 px — таблица описания прокручивается внутри, заголовки таблицы без чужого стиля", async ({ page }) => {
    +  await page.setViewportSize({ width: 375, height: 800 });
    +  await login(page, "/app/issues/AURORA-9001");
    +  const d = page.getByRole("dialog");
    +  await d.getByRole("button", { name: "развернуть" }).click();
    +  const th = d.getByRole("columnheader", { name: "Поле", exact: true });
    +  await expect(th).toBeAttached();
    +  expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
    +  expect(await th.evaluate((el) => getComputedStyle(el).textTransform)).toBe("none");
    +  expect(await d.getByRole("cell", { name: "data_type" }).evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);   // слово не рвётся (M-3)
    +});
    +
     test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
       await login(page, "/app/issues");
       await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
    @@ -118,4 +169,7 @@ test("узкий экран: окно на всю ширину, заметки 
       const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
       expect(notesY).toBeLessThan(linksY);
       expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
    +  await d.getByRole("button", { name: "развернуть" }).click();             // широкая таблица описания прокручивается внутри
    +  await expect(d.getByRole("columnheader", { name: "Источник" })).toBeAttached();
    +  expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
     });
    diff --git a/web/scripts/seed-extras.json b/web/scripts/seed-extras.json
    index 38d6f01..75de19d 100644
    --- a/web/scripts/seed-extras.json
    +++ b/web/scripts/seed-extras.json
    @@ -1,10 +1,10 @@
     {
       "issues": {
         "AURORA-9001": {
    -      "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
    +      "description": "h3. Контекст\nЦель: схема свода для *КФО*, поле {{data_type}}.\n\nh3. Шаги\n# Таблицы.\n# Связи.\n# Индексы.\n# Миграции.\n# Проверки.\n\n||Поле||Тип||Описание поля, достаточно длинное, чтобы таблица была шире узкого экрана||Источник||\n|data_type|varchar|вид данных строки: количество, валюта, налоговый учёт или бухгалтерский учёт|ОСВ|\n\nКонец описания.",
           "comments": [
    -        {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
    -        {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
    +        {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "*Первый комментарий e2e*\nвторая строка"},
    +        {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали, см. AURORA-9002."},
             {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
           ]
         }
    diff --git a/web/src/api/types.ts b/web/src/api/types.ts
    index da86f18..544932a 100644
    --- a/web/src/api/types.ts
    +++ b/web/src/api/types.ts
    @@ -16,6 +16,7 @@ export interface HubSettings {
       kind_labels?: Record;
       link_types?: Record;
       status_labels?: Record;
    +  project_key?: string;
       [key: string]: unknown;
     }
     
    diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx
    index af13998..e6c25c9 100644
    --- a/web/src/issues/IssueCard.test.tsx
    +++ b/web/src/issues/IssueCard.test.tsx
    @@ -6,17 +6,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
     import { qk } from "../api/queries.ts";
     import { makeQueryClient } from "../api/queryClient.ts";
     import { setToken } from "../auth/token.ts";
    -import settings from "../board/__fixtures__/settings.json";
    +import baseSettings from "../board/__fixtures__/settings.json";
     import { json, mockFetch } from "../test/fetchMock.ts";
     import { issue, note } from "../test/issueFixtures.ts";
     import { IssueCard } from "./IssueCard.tsx";
     
    +const settings = { ...baseSettings, project_key: "AURORA" };
     const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
     
     function CardAt() {
       const { key = "" } = useParams();
       const navigate = useNavigate();
    -  return  void navigate("/board")} />;
    +  // key — как в IssueModal: другая задача монтирует карточку заново
    +  return  void navigate("/board")} />;
     }
     
     function open(path: string, client = makeQueryClient()) {
    @@ -28,6 +30,13 @@ function open(path: string, client = makeQueryClient()) {
       return router;
     }
     
    +// высоты элементов в jsdom нулевые: для проверок свёртки по измерению — подмена
    +function overflow(scroll: number, client: number) {
    +  const s = vi.spyOn(Element.prototype, "scrollHeight", "get").mockReturnValue(scroll);
    +  const c = vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(client);
    +  return () => { s.mockRestore(); c.mockRestore(); };
    +}
    +
     describe("карточка задачи", () => {
       beforeEach(() => setToken("tok"));
     
    @@ -68,23 +77,113 @@ describe("карточка задачи", () => {
         expect(await screen.findByText("связей нет")).toBeInTheDocument();
       });
     
    -  it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => {
    +  it("длинное описание в рендере свёрнуто по высоте, «развернуть»/«свернуть»; «исходник» не сбрасывает «развёрнуто»", async () => {
    +    const restore = overflow(500, 180);
    +    const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n");
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
    +    open("/issues/AURORA-9001");
    +    const body = (await screen.findByText("абзац 1")).closest(".description")!;
    +    expect(body.tagName).toBe("DIV");
    +    expect(body).toHaveClass("clamped");
    +    await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
    +    expect(screen.getByText("абзац 1").closest(".description")).not.toHaveClass("clamped");
    +    await userEvent.click(screen.getByRole("button", { name: "исходник" }));
    +    const pre = document.querySelector("pre.description")!;
    +    expect(pre).not.toHaveClass("clamped");
    +    expect(screen.getByRole("button", { name: "свернуть" })).toBeInTheDocument();
    +    await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
    +    expect(document.querySelector("pre.description")).toHaveClass("clamped");
    +    restore();
    +  });
    +
    +  it("C-1: описание смонтировано скрытым (диалог ещё закрыт) — «развернуть» появляется, когда размеры изменились", async () => {
    +    const observers: (() => void)[] = [];
    +    vi.stubGlobal("ResizeObserver", class { constructor(cb: () => void) { observers.push(cb); } observe() {} disconnect() {} });
    +    let restore = overflow(0, 0);
    +    const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n");
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
    +    open("/issues/AURORA-9001");
    +    await screen.findByText("абзац 1");
    +    expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
    +    restore();
    +    restore = overflow(500, 180);
    +    act(() => observers.forEach((cb) => cb()));
    +    expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
    +    restore();
    +    vi.unstubAllGlobals();
    +  });
    +
    +  it("исходник длиннее 8 строк свёрнут построчно", async () => {
         const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n");
         mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
         open("/issues/AURORA-9001");
    -    const pre = await screen.findByText(long, { normalizer: (s) => s });
    +    await userEvent.click(await screen.findByRole("button", { name: "исходник" }));
    +    const pre = screen.getByText(long, { normalizer: (s) => s });
         expect(pre).toHaveClass("description", "clamped");
    -    await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
    -    expect(pre).not.toHaveClass("clamped");
    -    await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
    -    expect(pre).toHaveClass("clamped");
    +    expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
    +  });
    +
    +  it("список через пустые строки, который помещается, — в рендере без «развернуть» (design #8)", async () => {
    +    const restore = overflow(120, 180);
    +    const d = " * один\n\n * два\n\n * три\n\n * четыре\n\n * пять";
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
    +    open("/issues/AURORA-9001");
    +    expect(await screen.findByText("пять")).toBeInTheDocument();
    +    expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
    +    restore();
    +  });
    +
    +  it("описание отрендерено; «исходник» — исходник только у описания, «текст» — обратно", async () => {
    +    const d = "h3. Контекст\n# Загрузить ОСВ\n# Открыть документ";
    +    const comments = [{ comment_id: "1", author: "Иванов Иван", created: null, updated: null, body: "*важно*" }];
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d, comments })));
    +    open("/issues/AURORA-9001");
    +    expect(await screen.findByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
    +    const section = screen.getByRole("heading", { name: "Описание" }).closest("section")!;
    +    await userEvent.click(within(section).getByRole("button", { name: "исходник" }));
    +    expect(section.querySelector("pre.description")).toHaveTextContent("h3. Контекст");
    +    expect(screen.queryByRole("heading", { level: 3, name: "Контекст" })).toBeNull();
    +    expect(screen.getByText("важно").tagName).toBe("STRONG");
    +    await userEvent.click(within(section).getByRole("button", { name: "текст" }));
    +    expect(screen.getByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
    +  });
    +
    +  it("сценарий «Исходник у одного комментария»: переключается только он, после перехода всё отрендерено", async () => {
    +    const comments = [
    +      { comment_id: "2", author: "Петров", created: null, updated: null, body: "*b*" },
    +      { comment_id: "1", author: "Иванов", created: null, updated: null, body: "*a*" }];
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
    +    const router = open("/issues/AURORA-9001");
    +    await screen.findByRole("heading", { name: "Комментарии (2)" });
    +    const items = () => within(screen.getByTestId("comments")).getAllByRole("listitem");
    +    await userEvent.click(within(items()[1]).getByRole("button", { name: "исходник" }));
    +    expect(items()[1].querySelector("pre.body")).toHaveTextContent("*a*");
    +    expect(within(items()[0]).getByText("b").tagName).toBe("STRONG");
    +    await act(() => router.navigate("/issues/AURORA-9002"));
    +    await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9002" });
    +    await act(() => router.navigate(-1));
    +    await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
    +    await waitFor(() => expect(within(items()[1]).getByText("a").tagName).toBe("STRONG"));
    +    expect(document.querySelector("pre.body")).toBeNull();
    +  });
    +
    +  it("ключ задачи проекта в комментарии — ссылка на карточку", async () => {
    +    const comments = [{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "П. 1 ведётся в AURORA-9002, не UTF-8" }];
    +    mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
    +    const router = open("/issues/AURORA-9001");
    +    const comment = within(await screen.findByTestId("comments"));
    +    expect(comment.queryByRole("link", { name: "UTF-8" })).toBeNull();
    +    await userEvent.click(comment.getByRole("link", { name: "AURORA-9002" }));
    +    expect(router.state.location.pathname).toBe("/issues/AURORA-9002");
       });
     
       it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
         let d: string | null = "строка 1\nстрока 2";
         mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
         open("/issues/AURORA-9001");
    -    expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
    +    const body = (await screen.findByText(/строка 1/)).closest(".description")!;
    +    expect(body).toHaveTextContent("строка 1строка 2");
    +    expect(body.querySelector("br")).not.toBeNull();
         expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
         d = null;
         cleanup();
    @@ -102,7 +201,8 @@ describe("карточка задачи", () => {
         expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
         const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
         expect(items[0]).toHaveTextContent("Петров Пётр");
    -    expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument();
    +    expect(items[0].querySelector(".body")).toHaveTextContent("новыйвторая строка");
    +    expect(items[0].querySelector(".body br")).not.toBeNull();
         await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
         expect(screen.queryByText("старый")).toBeNull();
         expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
    diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx
    index 8296dac..b719366 100644
    --- a/web/src/issues/IssueCard.tsx
    +++ b/web/src/issues/IssueCard.tsx
    @@ -1,4 +1,4 @@
    -import { useLayoutEffect, useRef, useState } from "react";
    +import { type RefObject, useLayoutEffect, useRef, useState } from "react";
     import { ApiError } from "../api/client.ts";
     import { useIssue, useSettings } from "../api/queries.ts";
     import type { IssueComment, IssueDetail } from "../api/types.ts";
    @@ -9,6 +9,7 @@ import { useCommentsHidden } from "./commentsPref.ts";
     import { IssueLink } from "./IssueLink.tsx";
     import { Notes } from "./Notes.tsx";
     import { dueOf } from "./options.ts";
    +import { WikiText } from "./wiki/WikiText.tsx";
     import "./issues.css";
     
     const DESC_LINES = 8;
    @@ -62,8 +63,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
           
    } /> - {i.description && } - + {i.description && } +
    @@ -101,25 +102,61 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record(null); +// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7) +function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) { + return ; +} + +function Description({ text, projectKey }: { text: string; projectKey?: string }) { + const ref = useRef(null); const [open, setOpen] = useState(false); + const [source, setSource] = useState(false); const [overflow, setOverflow] = useState(false); useLayoutEffect(() => { const el = ref.current; - if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1); - }, [text, open]); - const long = text.split("\n").length > DESC_LINES || overflow; + if (!el || open) return; + const measure = () => setOverflow(el.scrollHeight > el.clientHeight + 1); + measure(); + // карточка из кэша монтируется до showModal (размеры нулевые), окно меняет ширину, догружаются шрифты — перемер (C-1) + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.firstElementChild) ro.observe(el.firstElementChild); + return () => ro.disconnect(); + }, [text, open, source]); + // в рендере «длинное» — только по измерению: пустые строки исходника высоты не дают (design #8) + const long = (source && text.split("\n").length > DESC_LINES) || overflow; + const cls = "description" + (open ? "" : " clamped"); return (
    -

    Описание

    -
    {text}
    +
    +

    Описание

    + setSource(!source)} /> +
    + {source + ?
    } className={cls}>{text}
    + :
    } className={cls}>
    } {(long || open) && }
    ); } -function Comments({ items }: { items: IssueComment[] }) { +function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) { + const [source, setSource] = useState(false); + return ( +
  • +
    + {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""} + setSource(!source)} /> +
    + {source + ?
    {c.body}
    + :
    } +
  • + ); +} + +function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) { const [hidden, setHidden] = useCommentsHidden(); return (
    @@ -130,12 +167,7 @@ function Comments({ items }: { items: IssueComment[] }) { {!items.length &&

    комментариев нет

    } {items.length > 0 && !hidden && (
      - {items.map((c) => ( -
    • -
      {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
      -
      {c.body}
      -
    • - ))} + {items.map((c) => )}
    )}
    diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index 8b1acda..f768b07 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -40,13 +40,18 @@ .issue .links .tag{font-size:12px;color:var(--muted);min-width:76px} .issue .links .sum{flex:1 1 200px;min-width:0} .issue .links .absent{color:var(--muted);font-style:italic} -.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px} -.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden; +.issue .description{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px} +.issue pre.description{white-space:pre-wrap;font:inherit} +.issue pre.description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden; /* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */ padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)} -.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px} -.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px} -.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0} +.issue .comments>ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px} +.issue div.description.clamped{max-height:12em;overflow:hidden; + /* рендер: блочное содержимое line-clamp не режет — ограничение высотой ≈ 8 строк (line-height 1.5, design #8, R-5) */ + padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)} +.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px;display:flex;justify-content:space-between;gap:8px} +.issue .comments .who .link{font-size:12px} +.issue .comments pre.body{white-space:pre-wrap;font:inherit;margin:0} .issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0} .issue .fields dt{font-size:12px;color:var(--muted)} .issue .fields dd{margin:0;white-space:pre-wrap} diff --git a/web/src/issues/wiki/WikiText.fallback.test.tsx b/web/src/issues/wiki/WikiText.fallback.test.tsx new file mode 100644 index 0000000..8456914 --- /dev/null +++ b/web/src/issues/wiki/WikiText.fallback.test.tsx @@ -0,0 +1,13 @@ +import { render } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { WikiText } from "./WikiText.tsx"; + +vi.mock("./parse.ts", () => ({ parseWiki: () => { throw new Error("сбой разбора"); } })); + +it("ошибка разбора — блок показывается исходником", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const { container } = render(); + const pre = container.querySelector("pre.wiki-source"); + expect(pre?.textContent).toBe("h3. A\nB"); + err.mockRestore(); +}); diff --git a/web/src/issues/wiki/WikiText.test.tsx b/web/src/issues/wiki/WikiText.test.tsx new file mode 100644 index 0000000..e9620ed --- /dev/null +++ b/web/src/issues/wiki/WikiText.test.tsx @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { render, screen, within } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, it } from "vitest"; +import { WikiText } from "./WikiText.tsx"; + +function show(text: string, projectKey?: string) { + const router = createMemoryRouter([{ path: "*", element: }]); + return render(); +} + +describe("WikiText", () => { + it("сценарий «Описание со структурой»", () => { + const { container } = show("h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка в {{data_type}}"); + expect(screen.getByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument(); + expect(within(screen.getByRole("list")).getAllByRole("listitem")).toHaveLength(2); + expect(screen.getByText("Итог:").tagName).toBe("STRONG"); + expect(screen.getByText("data_type").tagName).toBe("CODE"); + expect(container.textContent).not.toContain("h3."); + expect(container.textContent).not.toContain("{{"); + expect(container.firstElementChild).toHaveClass("wiki"); + }); + + it("таблица", () => { + show("||Поле||Значение||\n|a|b|\n|c|d|"); + expect(screen.getAllByRole("columnheader")).toHaveLength(2); + expect(screen.getAllByRole("cell")).toHaveLength(4); + }); + + it("внешняя ссылка — в новой вкладке", () => { + show("[Jira|https://x.ru/]"); + const a = screen.getByRole("link", { name: "Jira" }); + expect(a).toHaveAttribute("href", "https://x.ru/"); + expect(a).toHaveAttribute("target", "_blank"); + expect(a).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("ключ задачи проекта — ссылка на карточку; без projectKey — текст", () => { + const { unmount } = show("см. AURORA-1445", "AURORA"); + expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/issues/AURORA-1445"); + unmount(); + show("см. AURORA-1445"); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("упоминание — @логин", () => { + show("[~ivanov] посмотри"); + expect(screen.getByText(/@ivanov/)).toBeInTheDocument(); + }); + + it("сценарий «Неизвестная разметка и опасный адрес»", () => { + const { container } = show("{color:red}важно{color}\n!схема.png!\n[нажми|javascript:alert(1)]"); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.queryByRole("img")).toBeNull(); + expect(container.textContent).toContain("{color:red}важно{color}"); + expect(container.textContent).toContain("[нажми|javascript:alert(1)]"); + }); + + it("HTML в тексте — просто текст", () => { + const { container } = show(" и x"); + expect(container.querySelector("script, b")).toBeNull(); + expect(container.textContent).toContain(""); + }); + + it("в модуле нет innerHTML", () => { + const dir = join(process.cwd(), "src/issues/wiki"); + for (const f of readdirSync(dir).filter((n) => /\.tsx?$/.test(n) && !n.includes(".test."))) { + const src = readFileSync(join(dir, f), "utf8"); + expect(src, f).not.toMatch(/innerHTML|dangerouslySetInnerHTML/); + } + }); +}); diff --git a/web/src/issues/wiki/WikiText.tsx b/web/src/issues/wiki/WikiText.tsx new file mode 100644 index 0000000..c70f367 --- /dev/null +++ b/web/src/issues/wiki/WikiText.tsx @@ -0,0 +1,74 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { IssueLink } from "../IssueLink.tsx"; +import type { Inline } from "./inline.ts"; +import { type Block, type ListBlock, parseWiki } from "./parse.ts"; +import "./wiki.css"; + +interface Props { text: string; projectKey?: string } + +// вики-разметка Jira только React-элементами, HTML строкой не вставляется (design wiki-render #2, #5); при ошибке — исходник (#6) +export function WikiText({ text, projectKey }: Props) { + return ; +} + +class Fallback extends Component<{ text: string; children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка рендера разметки Jira", error, info.componentStack); } + render() { + return this.state.failed ?
    {this.props.text}
    : this.props.children; + } +} + +function Rendered({ text, projectKey }: Props) { + return
    {renderBlocks(parseWiki(text, { projectKey }))}
    ; +} + +function renderBlocks(blocks: Block[]): ReactNode[] { + return blocks.map((b, n) => { + switch (b.t) { + case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children)}; } + case "p": return

    {renderInlines(b.children)}

    ; + case "list": return renderList(b, n); + case "table": + return ( + + {b.rows.map((r, i) => ( + {r.map((c, j) => c.header ? : )} + ))} +
    {renderInlines(c.children)}{renderInlines(c.children)}
    + ); + case "pre": return
    {b.text}
    ; + case "quote": return
    {renderBlocks(b.children)}
    ; + case "hr": return
    ; + } + }); +} + +function renderList(list: ListBlock, key: number): ReactNode { + const L = list.ordered ? "ol" : "ul"; + return ( + + {list.items.map((it, i) =>
  • {renderInlines(it.children)}{it.lists.map(renderList)}
  • )} +
    + ); +} + +function renderInlines(nodes: Inline[]): ReactNode[] { + return nodes.map((x, n) => { + switch (x.t) { + case "text": return x.text; + case "br": return
    ; + case "strong": return {renderInlines(x.children)}; + case "em": return {renderInlines(x.children)}; + case "del": return {renderInlines(x.children)}; + case "ins": return {renderInlines(x.children)}; + case "sup": return {renderInlines(x.children)}; + case "sub": return {renderInlines(x.children)}; + case "code": return {x.text}; + case "link": return
    {renderInlines(x.children)}; + case "issue": return {x.children.length ? renderInlines(x.children) : undefined}; + case "mention": return @{x.login}; + } + }); +} diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts new file mode 100644 index 0000000..ad03b36 --- /dev/null +++ b/web/src/issues/wiki/inline.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { type Inline, parseInline } from "./inline.ts"; + +const P = { projectKey: "AURORA" }; +const t = (text: string): Inline => ({ t: "text", text }); +const wrap = (kind: "strong" | "em" | "del" | "ins" | "sup" | "sub", ...children: Inline[]): Inline => ({ t: kind, children }); +const issue = (key: string, ...children: Inline[]): Inline => ({ t: "issue", key, children }); +const link = (href: string, ...children: Inline[]): Inline => ({ t: "link", href, children }); + +describe("parseInline: выделение", () => { + it.each<[string, Inline[]]>([ + ["*Итог:* ошибка", [wrap("strong", t("Итог:")), t(" ошибка")]], + ["_к_", [wrap("em", t("к"))]], + ["-з-", [wrap("del", t("з"))]], + ["+п+", [wrap("ins", t("п"))]], + ["^в^", [wrap("sup", t("в"))]], + ["~н~", [wrap("sub", t("н"))]], + ["*_x_*", [wrap("strong", wrap("em", t("x")))]], + ["(*x*)", [t("("), wrap("strong", t("x")), t(")")]], + ["a *b c* d", [t("a "), wrap("strong", t("b c")), t(" d")]], + ["{{data_type}}", [{ t: "code", text: "data_type" }]], + ["{{*x*}} и", [{ t: "code", text: "*x*" }, t(" и")]], + ])("%s", (src, tree) => expect(parseInline(src, P)).toEqual(tree)); +}); + +describe("parseInline: обычный текст не портится", () => { + it.each([ + "data_type = null", "2026-10-15", "счёт-фактура", "a*b*c", "18 803 903,65 - 9 351 102,89", "**важно**", + "* одинокая", "*незакрытый", "[без конца", "*x*y", "{{без конца", "UTF-8", "SHA-256", "DOC-221280", "XAURORA-1", + "AURORA-12x", "", + ])("%s", (src) => expect(parseInline(src, P)).toEqual(src ? [t(src)] : [])); +}); + +describe("parseInline: экранирование и перенос", () => { + it("\\* — сам символ", () => expect(parseInline("\\*не жирный\\*", P)).toEqual([t("*не жирный*")])); + it("\\\\ — перенос", () => expect(parseInline("a\\\\b", P)).toEqual([t("a"), { t: "br" }, t("b")])); +}); + +describe("parseInline: ссылки", () => { + it.each<[string, Inline[]]>([ + ["[Jira|https://x.ru/a?b=1]", [link("https://x.ru/a?b=1", t("Jira"))]], + ["[https://x.ru]", [link("https://x.ru", t("https://x.ru"))]], + ["[mail|MAILTO:a@b.ru]", [link("MAILTO:a@b.ru", t("mail"))]], + ["[*Жирная* ссылка|http://x.ru]", [link("http://x.ru", wrap("strong", t("Жирная")), t(" ссылка"))]], + ["см. https://x.ru/p, далее", [t("см. "), link("https://x.ru/p", t("https://x.ru/p")), t(", далее")]], + ["(https://x.ru/p).", [t("("), link("https://x.ru/p", t("https://x.ru/p")), t(").")]], + ["[~ivanov]", [{ t: "mention", login: "ivanov" }]], + ])("%s", (src, tree) => expect(parseInline(src, P)).toEqual(tree)); + + it("в подписи ссылки ключи и адреса — текст (I-2: без вложенных ссылок)", () => { + expect(parseInline("[AURORA-1445|https://j.x/browse/AURORA-1445]", P)).toEqual([link("https://j.x/browse/AURORA-1445", t("AURORA-1445"))]); + expect(parseInline("[см. https://a.ru|https://b.ru]", P)).toEqual([link("https://b.ru", t("см. https://a.ru"))]); + expect(parseInline("[*AURORA-1*|AURORA-2]", P)).toEqual([issue("AURORA-2", wrap("strong", t("AURORA-1")))]); + }); + + it.each(["[нажми|javascript:alert(1)]", "[x|ftp://h]", "[javascript:alert(1)]", "[просто текст]"])( + "опасная или неизвестная цель — текст как есть: %s", + (src) => expect(parseInline(src, P)).toEqual([t(src)]), + ); +}); + +describe("parseInline: ключи задач", () => { + it("ключи проекта в тексте", () => + expect(parseInline("ведётся в AURORA-1445; AURORA-1645 снята", P)).toEqual([ + t("ведётся в "), issue("AURORA-1445"), t("; "), issue("AURORA-1645"), t(" снята"), + ])); + it("[ключ] и [текст|ключ]", () => { + expect(parseInline("[AURORA-1]", P)).toEqual([issue("AURORA-1")]); + expect(parseInline("[см.|AURORA-2]", P)).toEqual([issue("AURORA-2", t("см."))]); + }); + it("не внутри {{…}} и адреса", () => { + expect(parseInline("{{AURORA-1}}", P)).toEqual([{ t: "code", text: "AURORA-1" }]); + expect(parseInline("https://j/browse/AURORA-1", P)).toEqual([link("https://j/browse/AURORA-1", t("https://j/browse/AURORA-1"))]); + }); + it("без projectKey — текст", () => expect(parseInline("AURORA-1445")).toEqual([t("AURORA-1445")])); +}); diff --git a/web/src/issues/wiki/inline.ts b/web/src/issues/wiki/inline.ts new file mode 100644 index 0000000..94873d7 --- /dev/null +++ b/web/src/issues/wiki/inline.ts @@ -0,0 +1,136 @@ +// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. +// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. + +export interface WikiOptions { projectKey?: string } + +type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub"; + +export type Inline = + | { t: "text"; text: string } + | { t: Mark; children: Inline[] } + | { t: "code"; text: string } + | { t: "link"; href: string; children: Inline[] } + | { t: "issue"; key: string; children: Inline[] } + | { t: "mention"; login: string } + | { t: "br" }; + +const MARKS: Record = { "*": "strong", _: "em", "-": "del", "+": "ins", "^": "sup", "~": "sub" }; +const ESCAPABLE = new Set([..."*_-+^~{}[]|!\\"]); +const WORD = /[\p{L}\p{N}]/u; +const SPACE = /\s/; +const URL = /https?:\/\/[^\s\]|<>"]+/y; +const URL_TAIL = /[.,;:!?)]+$/; + +// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет +function safeHref(raw: string): string | null { + const href = raw.trim(); + return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null; +} + +function keyPattern(projectKey: string | undefined): RegExp | null { + if (!projectKey) return null; + const escaped = projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}-\\d+(?![\\p{L}\\p{N}_])`, "uy"); +} + +// label — подпись ссылки: без ссылок, адресов и ключей внутри, чтобы не было в (I-2) +export function parseInline(line: string, opts: WikiOptions = {}, label = false): Inline[] { + const key = label ? null : keyPattern(opts.projectKey); + const isKey = (s: string) => !!key && new RegExp(`^${key.source}$`, "u").test(s); + const out: Inline[] = []; + const text = (s: string) => { + if (!s) return; + const last = out[out.length - 1]; + if (last?.t === "text") last.text += s; + else out.push({ t: "text", text: s }); + }; + const prevOk = (i: number) => i === 0 || !WORD.test(line[i - 1]); + + let i = 0; + while (i < line.length) { + const c = line[i]; + + if (c === "\\") { + const next = line[i + 1]; + if (next === "\\") { out.push({ t: "br" }); i += 2; continue; } + if (next !== undefined && ESCAPABLE.has(next)) { text(next); i += 2; continue; } + text(c); i += 1; continue; + } + + if (c === "{" && line[i + 1] === "{") { + const end = line.indexOf("}}", i + 2); + if (end > i + 2) { out.push({ t: "code", text: line.slice(i + 2, end) }); i = end + 2; continue; } + text(c); i += 1; continue; + } + + if (c === "[" && !label) { + const end = line.indexOf("]", i + 1); + const node = end > i + 1 ? bracket(line.slice(i + 1, end), opts, isKey) : null; + if (node) { out.push(node); i = end + 1; continue; } + if (end > i) { text(line.slice(i, end + 1)); i = end + 1; continue; } + text(c); i += 1; continue; + } + + if (c === "h" && !label && prevOk(i)) { + URL.lastIndex = i; + const m = URL.exec(line); + if (m) { + const href = m[0].replace(URL_TAIL, ""); + if (safeHref(href)) { out.push({ t: "link", href, children: [{ t: "text", text: href }] }); i += href.length; continue; } + } + } + + if (key && (i === 0 || !/[\p{L}\p{N}_-]/u.test(line[i - 1]))) { + key.lastIndex = i; + const m = key.exec(line); + if (m) { out.push({ t: "issue", key: m[0], children: [] }); i += m[0].length; continue; } + } + + const mark = MARKS[c]; + if (mark) { + const close = closing(line, i); + if (close > 0) { + out.push({ t: mark, children: parseInline(line.slice(i + 1, close), opts, label) }); + i = close + 1; + continue; + } + } + + text(c); + i += 1; + } + return out; +} + +// позиция закрывающего маркера для открывающего в i или -1: границы — спека «Разметка Jira в карточке», R-7 +function closing(line: string, i: number): number { + const c = line[i]; + const next = line[i + 1]; + const prev = line[i - 1]; + if (prev !== undefined && (WORD.test(prev) || prev === c)) return -1; + if (next === undefined || SPACE.test(next) || next === c) return -1; + for (let k = i + 2; k < line.length; k++) { + if (line[k] !== c) continue; + const before = line[k - 1]; + const after = line[k + 1]; + if (SPACE.test(before) || before === c) continue; + if (after !== undefined && (WORD.test(after) || after === c)) continue; + return k; + } + return -1; +} + +// содержимое [...]: упоминание, ключ задачи, ссылка с проверенной схемой; иначе null — текст как есть +function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean): Inline | null { + if (body.startsWith("~")) { + const login = body.slice(1).trim(); + return login && !SPACE.test(login) ? { t: "mention", login } : null; + } + const bar = body.indexOf("|"); + const label = bar >= 0 ? body.slice(0, bar) : ""; + const target = (bar >= 0 ? body.slice(bar + 1) : body).trim(); + if (isKey(target)) return { t: "issue", key: target, children: label ? parseInline(label, opts, true) : [] }; + const href = safeHref(target); + if (!href) return null; + return { t: "link", href, children: label ? parseInline(label, opts, true) : [{ t: "text", text: target }] }; +} diff --git a/web/src/issues/wiki/parse.test.ts b/web/src/issues/wiki/parse.test.ts new file mode 100644 index 0000000..f6e57bf --- /dev/null +++ b/web/src/issues/wiki/parse.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { Inline } from "./inline.ts"; +import { type Block, type ListBlock, type ListItem, parseWiki } from "./parse.ts"; + +const P = { projectKey: "AURORA" }; +const t = (text: string): Inline => ({ t: "text", text }); +const br: Inline = { t: "br" }; +const p = (...children: Inline[]): Block => ({ t: "p", children }); +const item = (text: string, ...lists: ListBlock[]): ListItem => ({ children: text ? [t(text)] : [], lists }); +const ul = (...items: ListItem[]): ListBlock => ({ t: "list", ordered: false, items }); +const ol = (...items: ListItem[]): ListBlock => ({ t: "list", ordered: true, items }); +const h = (level: 1 | 2 | 3 | 4 | 5 | 6, text: string): Block => ({ t: "heading", level, children: [t(text)] }); + +const STRUCTURE = "h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка в {{data_type}}"; + +describe("parseWiki: абзацы и заголовки", () => { + it("сценарий «Описание со структурой»", () => + expect(parseWiki(STRUCTURE, P)).toEqual([ + h(3, "Контекст"), + p(t("Шаги:")), + ol(item("Загрузить ОСВ"), item("Открыть документ")), + p({ t: "strong", children: [t("Итог:")] }, t(" ошибка в "), { t: "code", text: "data_type" }), + ])); + it("CRLF даёт то же дерево", () => { + const tree = parseWiki(STRUCTURE.replace(/\n/g, "\r\n"), P); + expect(tree).toEqual(parseWiki(STRUCTURE, P)); + expect(JSON.stringify(tree)).not.toContain("\\r"); + }); + it("абзац из двух строк — через br", () => expect(parseWiki("a\nb")).toEqual([p(t("a"), br, t("b"))])); + it("пустые строки разделяют абзацы, пустых абзацев нет", () => expect(parseWiki("a\n\n\n\nb")).toEqual([p(t("a")), p(t("b"))])); + it.each(["", " \n ", "\r\n"])("пустой текст %j → []", (src) => expect(parseWiki(src)).toEqual([])); + it.each([1, 2, 3, 4, 5, 6] as const)("h%i.", (n) => expect(parseWiki(`h${n}. Заголовок`)).toEqual([h(n, "Заголовок")])); + it.each(["h7. x", "h3.без пробела"])("%s — абзац", (src) => expect(parseWiki(src)).toEqual([p(t(src))])); + it("----", () => expect(parseWiki("a\n----\nb")).toEqual([p(t("a")), { t: "hr" }, p(t("b"))])); +}); + +describe("parseWiki: списки", () => { + it("сценарий «Списки с отступом и пустыми строками» (R-4)", () => + expect(parseWiki("Шаги воспроизведения:\n * Загрузить ОСВ.\n\n * Дождаться обработки.")).toEqual([ + p(t("Шаги воспроизведения:")), ul(item("Загрузить ОСВ."), item("Дождаться обработки.")), + ])); + it("вложенность и смешанные маркеры", () => + expect(parseWiki("* a\n** b\n*# c\n* d")).toEqual([ul(item("a", ul(item("b")), ol(item("c"))), item("d"))])); + it("- пункты", () => expect(parseWiki("- x\n- y")).toEqual([ul(item("x"), item("y"))])); + it.each(["-- x", "*- x", "-# x"])("%s — не список", (src) => expect(parseWiki(src)).toEqual([p(t(src))])); + it("## Заголовок — второй уровень нумерованного", () => + expect(parseWiki("## Заголовок")).toEqual([ol(item("", ol(item("Заголовок"))))])); + it("строка без маркера после списка — абзац", () => expect(parseWiki("* a\nтекст")).toEqual([ul(item("a")), p(t("текст"))])); + it("смена вида на первом уровне — новый список", () => expect(parseWiki("* a\n# b")).toEqual([ul(item("a")), ol(item("b"))])); + it("*жирный* в начале строки — не список", () => + expect(parseWiki("*Уточнение.* текст")).toEqual([p({ t: "strong", children: [t("Уточнение.")] }, t(" текст"))])); +}); + +describe("parseWiki: таблицы", () => { + it("заголовки, ячейки, | внутри ссылки", () => + expect(parseWiki("||Поле||Значение||\n|data_type|null|\n|ссылка|[a|https://x.ru]|", P)).toEqual([{ + t: "table", + rows: [ + [{ header: true, children: [t("Поле")] }, { header: true, children: [t("Значение")] }], + [{ header: false, children: [t("data_type")] }, { header: false, children: [t("null")] }], + [{ header: false, children: [t("ссылка")] }, { header: false, children: [{ t: "link", href: "https://x.ru", children: [t("a")] }] }], + ], + }])); + it("смешанная строка ||h|c|", () => + expect(parseWiki("||h|c|")).toEqual([{ t: "table", rows: [[{ header: true, children: [t("h")] }, { header: false, children: [t("c")] }]] }])); +}); + +describe("parseWiki: блоки-макросы", () => { + it("{code:sql}", () => expect(parseWiki("{code:sql}\nselect *\nfrom t\n{code}")).toEqual([{ t: "pre", text: "select *\nfrom t" }])); + it("{noformat} в одной строке", () => expect(parseWiki("{noformat}*x*{noformat}")).toEqual([{ t: "pre", text: "*x*" }])); + it("незакрытый {code} — текст", () => expect(parseWiki("{code}\nx")).toEqual([p(t("{code}"), br, t("x"))])); + it("{quote} и хвост после закрывающего тега", () => + expect(parseWiki("{quote}\nh3. Внутри\nтекст\n{quote}хвост")).toEqual([ + { t: "quote", children: [h(3, "Внутри"), p(t("текст"))] }, p(t("хвост")), + ])); + it("bq.", () => expect(parseWiki("bq. цитата")).toEqual([{ t: "quote", children: [p(t("цитата"))] }])); + it("{quote} не в начале строки — текст (R-8)", () => expect(parseWiki("см. {quote}x{quote}")).toEqual([p(t("см. {quote}x{quote}"))])); + it.each(["{color:red}важно{color}", "!схема.png!", "{panel}x{panel}"])("неизвестное %s — текст как есть", (src) => + expect(parseWiki(src)).toEqual([p(t(src))])); +}); diff --git a/web/src/issues/wiki/parse.ts b/web/src/issues/wiki/parse.ts new file mode 100644 index 0000000..f3d8c6b --- /dev/null +++ b/web/src/issues/wiki/parse.ts @@ -0,0 +1,164 @@ +// Блочная вики-разметка Jira (design wiki-render #3): построчный разбор в дерево блоков, строки — parseInline. +// Разбор не бросает исключений; нераспознанное остаётся абзацем с текстом как есть. +import { type Inline, parseInline, type WikiOptions } from "./inline.ts"; + +export interface ListBlock { t: "list"; ordered: boolean; items: ListItem[] } +export interface ListItem { children: Inline[]; lists: ListBlock[] } +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[] } + | ListBlock + | { t: "table"; rows: Cell[][] } + | { t: "pre"; text: string } + | { t: "quote"; children: Block[] } + | { t: "hr" }; + +const MACRO = /^\s*\{(code(?::[^}]*)?|noformat|quote)\}/; +const HEADING = /^\s*h([1-6])\.\s+(.*)$/; +const BQ = /^\s*bq\.\s+(.*)$/; +const HR = /^\s*----\s*$/; +const TABLE = /^\s*\|/; +// `*`/`#` любой глубины или ровно один `-` (спека: `--`, `*-`, `-#` — не список) +const LIST = /^\s*([*#]+|-)\s+(.*)$/; + +export function parseWiki(text: string, opts: WikiOptions = {}): Block[] { + return blocks(text.replace(/\r\n?/g, "\n").split("\n"), opts); +} + +function blocks(src: string[], opts: WikiOptions): Block[] { + const lines = [...src]; + const out: Block[] = []; + let para: string[] = []; + const flush = () => { + if (!para.length) return; + out.push({ t: "p", children: para.flatMap((l, n) => [...(n ? [{ t: "br" } as Inline] : []), ...parseInline(l, opts)]) }); + para = []; + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (!line.trim()) { flush(); i++; continue; } + + const macro = MACRO.exec(line); + if (macro) { + const found = closeMacro(lines, i, macro); + if (found) { + flush(); + out.push(found.name === "quote" ? { t: "quote", children: blocks(found.body, opts) } : { t: "pre", text: found.body.join("\n") }); + if (found.after.trim()) { lines[found.end] = found.after; i = found.end; } else i = found.end + 1; + continue; + } + para.push(line); i++; continue; + } + + if (HR.test(line)) { flush(); out.push({ t: "hr" }); i++; continue; } + + const heading = HEADING.exec(line); + if (heading) { + flush(); + out.push({ t: "heading", level: Number(heading[1]) as 1 | 2 | 3 | 4 | 5 | 6, children: parseInline(heading[2], opts) }); + i++; continue; + } + + const bq = BQ.exec(line); + if (bq) { flush(); out.push({ t: "quote", children: [{ t: "p", children: parseInline(bq[1], opts) }] }); i++; continue; } + + if (TABLE.test(line)) { + flush(); + const rows: Cell[][] = []; + while (i < lines.length && TABLE.test(lines[i])) rows.push(row(lines[i++], opts)); + out.push({ t: "table", rows }); + continue; + } + + if (LIST.test(line)) { + flush(); + const entries: { markers: string; text: string }[] = []; + const first = LIST.exec(line)![1][0]; + while (i < lines.length) { + const m = LIST.exec(lines[i]); + if (m) { entries.push({ markers: m[1], text: m[2] }); i++; continue; } + if (lines[i].trim()) break; + // пустые строки между пунктами того же вида список не рвут (R-4) + let j = i; + while (j < lines.length && !lines[j].trim()) j++; + const next = j < lines.length ? LIST.exec(lines[j]) : null; + if (!next || next[1][0] !== first) break; + i = j; + } + out.push(...buildLists(entries, opts)); + continue; + } + + para.push(line); + i++; + } + flush(); + return out; +} + +// закрывающий тег макроса в той же строке после открывающего или в последующих; null — не закрыт +function closeMacro(lines: string[], start: number, open: RegExpExecArray): { name: string; body: string[]; end: number; after: string } | null { + const name = open[1].split(":")[0]; + const tag = `{${name}}`; + const rest = lines[start].slice(open[0].length); + const body: string[] = []; + for (let k = start; k < lines.length; k++) { + const text = k === start ? rest : lines[k]; + const at = text.indexOf(tag); + if (at < 0) { if (k !== start || text) body.push(text); continue; } + if (text.slice(0, at)) body.push(text.slice(0, at)); + return { name, body, end: k, after: text.slice(at + tag.length) }; + } + return null; +} + +function row(line: string, opts: WikiOptions): Cell[] { + const s = line.trim(); + const cells: Cell[] = []; + let pos = 0; + while (pos < s.length && s[pos] === "|") { + const header = s.startsWith("||", pos); + pos += header ? 2 : 1; + let j = pos; + let depth = 0; + // `|` внутри [ссылки|адрес] ячейку не режет + while (j < s.length && (s[j] !== "|" || depth > 0)) { + if (s[j] === "[") depth++; + else if (s[j] === "]" && depth > 0) depth--; + j++; + } + const content = s.slice(pos, j); + if (j >= s.length && !content.trim()) break; + cells.push({ header, children: parseInline(content.trim(), opts) }); + pos = j; + } + return cells; +} + +// стек по глубине маркеров; вид — по последнему маркеру; недостающие уровни — пустые пункты (как в Jira) +function buildLists(entries: { markers: string; text: string }[], opts: WikiOptions): ListBlock[] { + const roots: ListBlock[] = []; + const stack: ListBlock[] = []; + const attach = (level: number, list: ListBlock) => { + if (level === 1) roots.push(list); + else stack[level - 2].items[stack[level - 2].items.length - 1].lists.push(list); + stack[level - 1] = list; + stack.length = level; + }; + for (const { markers, text } of entries) { + const depth = markers.length; + stack.length = Math.min(stack.length, depth); + for (let level = 1; level < depth; level++) { + if (!stack[level - 1]) attach(level, { t: "list", ordered: markers[level - 1] === "#", items: [{ children: [], lists: [] }] }); + } + const ordered = markers[depth - 1] === "#"; + const current = stack[depth - 1]; + const item: ListItem = { children: parseInline(text, opts), lists: [] }; + if (current && current.ordered === ordered) current.items.push(item); + else attach(depth, { t: "list", ordered, items: [item] }); + } + return roots; +} diff --git a/web/src/issues/wiki/wiki.css b/web/src/issues/wiki/wiki.css new file mode 100644 index 0000000..133d5d5 --- /dev/null +++ b/web/src/issues/wiki/wiki.css @@ -0,0 +1,22 @@ +/* вики-разметка Jira в карточке (design wiki-render #9); селекторы div.wiki … сильнее общих .issue h3 и т. п. */ +div.wiki{line-height:1.5;overflow-wrap:anywhere;min-width:0} +div.wiki>:first-child{margin-top:0} +div.wiki>:last-child{margin-bottom:0} +div.wiki p{margin:0 0 6px} +div.wiki :is(h1,h2,h3,h4,h5,h6){font-weight:600;color:inherit;text-transform:none;letter-spacing:normal;line-height:1.3;margin:10px 0 4px} +div.wiki :is(h1,h2){font-size:15px} +div.wiki :is(h3,h4){font-size:14px} +div.wiki :is(h5,h6){font-size:13px} +div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px} +div.wiki li>:is(ul,ol){margin:2px 0 0} +div.wiki table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px} +div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word} +/* глобальные table/th из board.css (min-width 520px, прописные 11px) здесь не действуют (I-1) */ +div.wiki th{background:var(--soft);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit} +div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px} +div.wiki code{background:var(--soft);border-radius:3px;padding:0 3px} +div.wiki pre{background:var(--soft);border-radius:4px;padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal} +div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--line);color:var(--muted)} +div.wiki hr{border:0;border-top:1px solid var(--line);margin:8px 0} +div.wiki .mention{color:var(--accent)} +pre.wiki-source{white-space:pre-wrap;font:inherit;margin:0}