Merge pull request 'Карточка задачи: вики-разметка Jira в описании и комментариях, кнопка «исходник» (#53)' (#54) from feat/53-wiki-render into main

This commit is contained in:
aep 2026-10-17 08:22:57 +03:00
commit d3f6c704d7
19 changed files with 1393 additions and 42 deletions

View File

@ -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>-<цифры>`, `project_key` из настроек хаба (`GET /settings`); без него ключи — текст.
- Тексты кнопок ровно: «исходник», «текст»; «развернуть», «свернуть» — как сейчас.
- Фикстуры — синтетические тексты по образцу реальных, без данных заказчика (R-1).
- Проверка ветки: `cd web && npm run lint && npm test` зелёные; e2e — `cd web && npm run e2e`.
## Review Focus
1. Текст с `\r\n` (Jira отдаёт CRLF — так в реальных описаниях) — разбирается как с `\n`, `\r` нигде не виден.
Тест — Task 2.
2. `|` внутри ссылки `[текст|url]` в ячейке таблицы — не режет ячейку. Тест — Task 2.
3. Незакрытые маркеры и блоки (`*жирный` без пары, `{code}` без конца, `[ссылка` без `]`) — текст как есть, ничего не
пропадает. Тесты — Task 1, Task 2.
4. Пустая строка и строка из одних пробелов в описании / комментарии (`body: ""`) — без падения, без пустых абзацев.
Тест — Task 2, Task 4.
5. Длинный адрес или широкая таблица — прокрутка внутри блока, окно карточки не расширяется по горизонтали. Тест —
Task 5 (e2e, узкий экран уже есть — дополнить).
---
### Task 1: Строчный разборщик `parseInline`
**Files:**
- Create: `web/src/issues/wiki/inline.ts`
- Test: `web/src/issues/wiki/inline.test.ts`
**Interfaces:**
- Produces:
```ts
export interface WikiOptions { projectKey?: string }
export type Inline =
| { t: "text"; text: string }
| { t: "strong" | "em" | "del" | "ins" | "sup" | "sub"; children: Inline[] }
| { t: "code"; text: string } // {{…}}
| { t: "link"; href: string; children: Inline[] } // проверенная схема
| { t: "issue"; key: string; children: Inline[] } // children пустые → показывать key
| { t: "mention"; login: string } // [~login]
| { t: "br" }; // \\
export function parseInline(line: string, opts?: WikiOptions): Inline[];
```
Вход — одна строка без `\n`. Соседние `text` сливаются, пустых `text` нет.
- [ ] **Step 1: Тесты** — `inline.test.ts`, табличный `it.each` «вход → дерево» (`toEqual`), `P = { projectKey: "AURORA" }`:
- `*Итог:* ошибка` → `[strong[text "Итог:"], text " ошибка"]`; `_к_`, `-з-`, `+п+`, `^в^`, `~н~` → em/del/ins/sup/sub;
- вложенность `*_x_*` → `strong[em[text x]]`;
- `{{data_type}}` → `code "data_type"`; `{{*x*}}` → `code "*x*"` (без разбора);
- обычный текст без изменений (один `text`): `data_type = null`, `2026-10-15`, `счёт-фактура`, `a*b*c`,
`18 803 903,65 - 9 351 102,89`, `**важно**`, `* одинокая`, `*незакрытый`, `[без конца`;
- граница закрывающего: `*x*y` → текст как есть; `(*x*)` → `text "(" , strong[x], text ")"`;
- `\*не жирный\*` → `text "*не жирный*"`; `a\\b` → `text a, br, text b`;
- `[Jira|https://x.ru/a?b=1]` → `link href "https://x.ru/a?b=1" [text Jira]`; `[https://x.ru]` → link с текстом адреса;
`[mail|MAILTO:a@b.ru]` → link; `[нажми|javascript:alert(1)]` и `[x|ftp://h]` → один `text` с исходником;
- голый адрес `см. https://x.ru/p, далее` → `text "см. "`, `link "https://x.ru/p"`, `text ", далее"`; хвост `.,;:!?)`
не входит в адрес;
- `[~ivanov]` → `mention "ivanov"`;
- ключи с `P`: `ведётся в AURORA-1445; AURORA-1645 снята` → `issue AURORA-1445`, `issue AURORA-1645` с пустыми
children; `[AURORA-1]` → `issue AURORA-1 []`; `[см.|AURORA-2]` → `issue AURORA-2 [text "см."]`;
`UTF-8`, `SHA-256`, `DOC-221280`, `XAURORA-1`, `AURORA-12x` → текст; ключ внутри `{{…}}` и внутри адреса
`https://j/browse/AURORA-1` → не issue;
- без `projectKey`: `AURORA-1445` → текст.
- [ ] **Step 2:** `cd web && npx vitest run src/issues/wiki/inline.test.ts` — FAIL (модуля нет).
- [ ] **Step 3: Реализация** `parseInline` — однопроходный сканер по позиции (design #4, #5). Границы маркеров
`* _ - + ^ ~`: открывающий — в начале строки или после символа не `\p{L}\p{N}`, за ним не пробел и не тот же символ
(R-7); закрывающий — тот же символ, перед ним не пробел, за ним конец или не `\p{L}\p{N}`; ищется ближайший
подходящий в пределах строки; содержимое — рекурсивно `parseInline`. Ключ — `new RegExp("(?<![\\p{L}\\p{N}_-])" +
escape(projectKey) + "-\\d+(?![\\p{L}\\p{N}_])", "u")`. Проверка схемы — отдельная функция `safeHref(raw): string |
null`, экспортировать для Task 3 не нужно.
- [ ] **Step 4:** тот же запуск — PASS; `npm run lint` — без ошибок.
- [ ] **Step 5: Коммит** `feat(web): строчный разборщик вики-разметки Jira (#53)`.
### Task 2: Блочный разборщик `parseWiki`
**Files:**
- Create: `web/src/issues/wiki/parse.ts`
- Test: `web/src/issues/wiki/parse.test.ts`
**Interfaces:**
- Consumes: `parseInline`, `Inline`, `WikiOptions` из `./inline.ts`.
- Produces:
```ts
export interface ListBlock { t: "list"; ordered: boolean; items: ListItem[] }
export interface ListItem { children: Inline[]; lists: ListBlock[] } // lists — вложенные списки пункта
export interface Cell { header: boolean; children: Inline[] }
export type Block =
| { t: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; children: Inline[] }
| { t: "p"; children: Inline[] } // строки абзаца через { t: "br" }
| ListBlock
| { t: "table"; rows: Cell[][] }
| { t: "pre"; text: string } // {code}, {code:язык}, {noformat}
| { t: "quote"; children: Block[] } // {quote}…{quote}, bq.
| { t: "hr" };
export function parseWiki(text: string, opts?: WikiOptions): Block[];
```
- [ ] **Step 1: Тесты** — `parse.test.ts`, `toEqual` на деревьях (строчные части — через `parseInline` в ожиданиях
можно не повторять: сравнивать `children` с `[{t:"text",text:"…"}]` для простых строк):
- сценарий спеки «Описание со структурой»: `h3. Контекст\nШаги:\n# Загрузить ОСВ\n# Открыть документ\n\n*Итог:* ошибка
в {{data_type}}` → `heading 3`, `p "Шаги:"`, `list ordered 2 пункта`, `p [strong, text, code]`;
- абзац из двух строк → `p [text, br, text]`; две пустые строки подряд → два абзаца, пустых `p` нет; `""` и `" \n "` →
`[]`;
- CRLF: тот же текст с `\r\n` даёт то же дерево, `\r` нигде нет;
- сценарий «Списки с отступом и пустыми строками»: `Шаги воспроизведения:\n * Загрузить ОСВ.\n\n * Дождаться обработки.`
→ `p`, один `list` (unordered) из двух пунктов (R-4);
- вложенность: `* a\n** b\n*# c\n* d` → список из `a` (вложенные: unordered `[b]`, ordered `[c]`) и `d`; `- x\n- y` →
unordered из 2; `-- x`, `*- x` → абзацы; `----` → `hr`; `## Заголовок` → `list ordered` из одного пункта с пустыми `children` и вложенным ordered-списком `[Заголовок]` (как в Jira — второй уровень);
- список, затем строка без маркера → список и абзац;
- таблица `||Поле||Значение||\n|data_type|null|\n|ссылка|[a|https://x.ru]|` → 3 строки, первая — header-ячейки,
в третьей 2 ячейки, вторая — `link` (Review Focus 2);
- `{code:sql}\nselect *\nfrom t\n{code}` → `pre "select *\nfrom t"`; `{noformat}*x*{noformat}` → `pre "*x*"`;
`{code}` без закрывающего → строки абзацем как есть (`{code}` виден текстом);
- `{quote}\nh3. Внутри\nтекст\n{quote}хвост` → `quote [heading, p]`, затем `p "хвост"`; `bq. цитата` → `quote [p]`;
`см. {quote}x{quote}` → один `p` с исходником (R-8);
- заголовки `h1.`–`h6.`; `h7. x` и `h3.без пробела` → абзац;
- неизвестное `{color:red}важно{color}`, `!схема.png!`, `{panel}x{panel}` → абзацы с исходником как есть.
- [ ] **Step 2:** `npx vitest run src/issues/wiki/parse.test.ts` — FAIL.
- [ ] **Step 3: Реализация** `parseWiki` — нормализация `\r\n`/`\r` → `\n`, затем построчный проход (design #3):
классификация строки, сборка абзацев, списков (стек по глубине маркеров, вид — по последнему маркеру; пустые строки
между пунктами одного списка пропускаются, если следующая непустая строка — пункт), таблиц (ячейки — разбиение по
`||` / `|` вне `[…]`), блоков-макросов с поиском закрывающего тега, рекурсивный `parseWiki` для `{quote}`.
- [ ] **Step 4:** PASS; `npm run lint`.
- [ ] **Step 5: Коммит** `feat(web): блочный разборщик вики-разметки Jira (#53)`.
### Task 3: Компонент `WikiText`
**Files:**
- Create: `web/src/issues/wiki/WikiText.tsx`, `web/src/issues/wiki/wiki.css`
- Test: `web/src/issues/wiki/WikiText.test.tsx`, `web/src/issues/wiki/WikiText.fallback.test.tsx`
**Interfaces:**
- Consumes: `parseWiki`, `Block`, `Inline`, `WikiOptions`; `IssueLink` (`../IssueLink.tsx`, нужен роутер).
- Produces: `export function WikiText({ text, projectKey }: { text: string; projectKey?: string }): JSX.Element` —
корень `<div className="wiki">`; при ошибке разбора или рендера — `<pre className="wiki-source">{text}</pre>`.
- [ ] **Step 1: Тесты** `WikiText.test.tsx` (рендер внутри `createMemoryRouter`, как в `IssueCard.test.tsx`):
- структура сценария «Описание со структурой»: `getByRole("heading", {level: 3, name: "Контекст"})`, `getByRole("list")`
с 2 `listitem`, «Итог:» внутри `strong`, «data_type» внутри `code`; `container.textContent` не содержит `h3.`, `{{`;
- таблица: `getAllByRole("columnheader")` — 2, `getAllByRole("cell")` — 4;
- внешняя ссылка: `getByRole("link", {name: "Jira"})` — `href` `https://x.ru/`, `target` `_blank`, `rel`
`noopener noreferrer`;
- ключ: `AURORA-1445` с `projectKey="AURORA"` → ссылка с `href` `/issues/AURORA-1445`; без `projectKey` — не ссылка;
- `[~ivanov]` → текст `@ivanov`;
- сценарий «Неизвестная разметка и опасный адрес»: `queryByRole("link")` и `queryByRole("img")` — `null`, текст
`[нажми|javascript:alert(1)]` и `{color:red}важно{color}` виден;
- `<script>alert(1)</script>` и `<b>x</b>` в тексте → видны как текст, `container.querySelector("script, b")` — `null`;
- исходники модуля (`fs.readFileSync` всех `*.ts(x)` в `src/issues/wiki/`, кроме тестов) не содержат
`dangerouslySetInnerHTML` и `innerHTML`.
- [ ] **Step 2:** `WikiText.fallback.test.tsx`: `vi.mock("./parse.ts", () => ({ parseWiki: () => { throw new Error("x"); } }))`;
`render(<WikiText text={"h3. A\nB"} />)` → `container.querySelector("pre.wiki-source")` с `textContent` `"h3. A\nB"`.
- [ ] **Step 3:** `npx vitest run src/issues/wiki/` — новые тесты FAIL.
- [ ] **Step 4: Реализация** — `WikiText` = граница ошибок-класс (по образцу `shell/ErrorBoundary.tsx`, фолбэк —
исходник, `console.error` в `componentDidCatch`) вокруг внутреннего компонента, который вызывает `parseWiki` и
рендерит дерево (design #2). `wiki.css` — вид по design #9: компактные отступы `p`/`ul`/`ol` (первый/последний без
внешних отступов), заголовки 15/14/13 px полужирные, таблица с рамками `var(--line)` и `display:block;overflow-x:auto`,
`pre`/`code` — `var(--font-mono)`, `pre` с `overflow-x:auto`, `blockquote` — левая полоса `var(--line)`, длинные слова и
адреса — `overflow-wrap:anywhere`; `.wiki { line-height: 1.5 }`.
- [ ] **Step 5:** PASS; `npm run lint`.
- [ ] **Step 6: Коммит** `feat(web): WikiText — рендер вики-разметки без innerHTML, исходник при ошибке (#53)`.
### Task 4: Карточка — рендер и переключатель «исходник» / «текст»
**Files:**
- Modify: `web/src/issues/IssueCard.tsx` (`Description` ~102–119, `Comments` ~121–144), `web/src/issues/issues.css:43-50`,
`web/src/api/types.ts:15-20` (`project_key?: string` в `HubSettings`)
- Test: `web/src/issues/IssueCard.test.tsx`
**Interfaces:**
- Consumes: `WikiText` (Task 3), `useSettings().data?.project_key`.
- Produces (внутри `IssueCard.tsx`): `SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void })` —
`<button className="link" type="button" aria-pressed={source}>{source ? "текст" : "исходник"}</button>`;
`Description({ text, projectKey })`; `Comment({ c, projectKey })`.
- [ ] **Step 1: Тесты** в `IssueCard.test.tsx` (настройки в моке — `{ ...settings, project_key: "AURORA" }`); существующие
тесты описания, опирающиеся на `<pre class="description">` и точный текст `"строка 1\nстрока 2"`, переписать под
режим рендера:
- по умолчанию описание отрендерено: `h3. Контекст` → `heading {level: 3, name: "Контекст"}` внутри раздела
«Описание»; кнопка `getByRole("button", {name: "исходник"})` в заголовке раздела;
- «исходник» у описания → `pre.description` с текстом исходника (`h3. Контекст` виден), кнопка — «текст»; комментарии
остаются отрендеренными; «текст» → снова заголовок;
- сценарий «Исходник у одного комментария»: два комментария `*a*` и `*b*`; «исходник» у второго (кнопки в `.who`,
`within(items[1])`) → второй — `pre.body` с `*b*`, первый — `strong` «a»; затем `router.navigate("/issues/AURORA-9002")`
и обратно → оба отрендерены (карточка монтируется заново; в тесте — смена `issueKey` через маршрут, при
необходимости обернуть `CardAt` в `key={key}`, как делает `IssueModal`);
- ключ в комментарии `П. 1 ведётся в AURORA-9002` → ссылка; щелчок → карточка AURORA-9002 (`router.state.location.pathname`);
- свёртка в режиме рендера: описание из 20 абзацев, `scrollHeight`/`clientHeight` замоканы через
`Object.defineProperty(HTMLElement.prototype, …)` как в существующем тесте свёртки → видна «развернуть», контейнер
`div.description.clamped`; «развернуть» → без `clamped`, «свернуть»; «исходник» не сбрасывает «развёрнуто»;
- описание из 3 пунктов ` * x` через пустые строки (5 строк исходника) с замоканным `scrollHeight == clientHeight` → в
режиме рендера кнопки «развернуть» нет (design #8);
- комментарий с `body: ""` — без падения, автор виден (существующий тест — сохранить зелёным).
- [ ] **Step 2:** `npx vitest run src/issues/IssueCard.test.tsx` — новые тесты FAIL.
- [ ] **Step 3: Реализация.** `Description`: заголовок раздела — `.sec-head` (как у комментариев) с `h3` «Описание» и
`SourceToggle`; состояния `source`, `open`, `overflow`; тело — `source ? <pre ref className={"description" + (open ?
"" : " clamped")}>{text}</pre> : <div ref className={"description" + (open ? "" : " clamped")}><WikiText text
projectKey /></div>`; признак «длинное» — в режиме исходника прежний, в режиме рендера только измерение
(`useLayoutEffect` с зависимостями `[text, open, source]`). `Comment` — `<li>` с `.who` (автор, время, `SourceToggle`)
и телом `pre.body` / `div.body > WikiText`. CSS: правило `-webkit-line-clamp` — только для `pre.description.clamped`;
для `div.description.clamped` — `max-height: 12em` (8 строк × `line-height: 1.5`) и `overflow: hidden` с тем же
приёмом нижней рамки; `div.description` и `div.body` — без `white-space: pre-wrap`; `.who` — flex с кнопкой справа.
Подключить `./wiki/wiki.css` через импорт в `WikiText.tsx` (Task 3) — здесь не повторять.
- [ ] **Step 4:** `npm test` — весь набор фронта PASS; `npm run lint`.
- [ ] **Step 5: Коммит** `feat(web): карточка — описание и комментарии в разметке Jira, кнопка «исходник» (#53)`.
### Task 5: e2e на тестовом хабе и проверка ветки
**Files:**
- Modify: `web/scripts/seed-extras.json` (описание и комментарии AURORA-9001), `web/e2e/issues.spec.ts:72-93` и тест
узкого экрана (~111)
**Interfaces:**
- Consumes: всё из Task 1–4; тестовый хаб `bash web/scripts/test-hub.sh` (поднимает Playwright по конфигу).
- [ ] **Step 1: Фикстура.** Описание AURORA-9001 — вики-разметка не короче 12 строк исходника и заведомо выше 8 строк
в рендере, с `h3. Контекст`, нумерованным списком, `*жирным*`, `{{моно}}`, таблицей шириной больше узкого экрана
(Review Focus 5) и последней строкой `Конец описания.`; комментарий 502 — `Схему согласовали, см. AURORA-9002.`;
комментарий 501 — `*Первый комментарий e2e*\nвторая строка`. Тексты синтетические (R-1).
- [ ] **Step 2: e2e.** В тесте карточки: локаторы `pre.description` → `.description`; добавить: заголовок «Контекст»
виден, `# …` не виден текстом; «исходник» у описания → `pre.description` содержит `h3. Контекст`, «текст» —
обратно; «исходник» у комментария 501 показывает `*Первый комментарий e2e*` только в нём; щелчок по `AURORA-9002`
в комментарии → заголовок окна «API свода», «назад» → AURORA-9001 с отрендеренными блоками. В тесте узкого экрана —
после «развернуть» у описания `document.documentElement.scrollWidth <= innerWidth` и у диалога нет горизонтальной
прокрутки (таблица прокручивается внутри).
- [ ] **Step 3:** `cd web && npm run e2e` — PASS (весь набор).
- [ ] **Step 4:** `make check` из корня worktree — PASS (pytest + lint + тесты фронта).
- [ ] **Step 5: Коммит** `test(e2e): разметка Jira и кнопка «исходник» в карточке (#53)`.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-17

View File

@ -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` выводит текст в `<pre class="description">` со свёрткой
до 8 строк (`-webkit-line-clamp` на `.description.clamped`, признак «длинное» — больше 8 строк или
`scrollHeight > clientHeight`, кнопка «развернуть» / «свернуть»); `Comments` выводит `<pre class="body">` в каждом
`<li>`, над ним `.who` (автор, время); «Скрыть» / «Показать» раздела — `useCommentsHidden` (localStorage).
Стили — `web/src/issues/issues.css`, токены цветов — `var(--panel)`, `var(--line)`, `var(--muted)` и др.
- **Ссылка на карточку** — `IssueLink` (`web/src/issues/IssueLink.tsx`): `<Link to="/issues/{key}"
state={openCardState(loc)}>`, открывает задачу в том же окне с сохранением страницы под ним.
- **Реальные тексты 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` — компонент `<WikiText text=… />`, превращающий дерево в 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`,
`<a target="_blank" rel="noopener noreferrer">`, `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<project_key>-\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`, но с фолбэком «исходник», а не «Страница сломалась»), внутри — разбор и рендер:
при исключении блок показывается исходником (`<pre class="wiki-source">`), остальная карточка работает. Разборщик
не бросает на нормальном пути; граница — страховка.
7. **Переключатель.** Компонент `SourceToggle` — кнопка `.link` с текстом «исходник» (в режиме рендера) / «текст» (в
режиме исходника), `aria-pressed`. Состояние — `useState(false)` внутри `Description` и внутри компонента одного
комментария (`Comment`, ключ — `comment_id`); `IssueModal` монтирует карточку заново при смене задачи
(`<ErrorBoundary key={issueKey}>`), поэтому выбор не переживает переход по связи и закрытие окна. Без 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; иначе `<a>` в `<a>` и щелчок открывал карточку вместо адреса | Ключ в подписи не кликается отдельно — как в Jira |

View File

@ -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}` — нет.
- Рендер разметки вне карточки (список «Задачи», доска, «Служебные заметки») — нет.

View File

@ -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** текст показан как есть, без курсива, зачёркивания, жирного и ссылок

View File

@ -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);
});

View File

@ -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": "[мост] служебный комментарий"}
]
}

View File

@ -16,6 +16,7 @@ export interface HubSettings {
kind_labels?: Record<string, string>;
link_types?: Record<string, string>;
status_labels?: Record<string, string>;
project_key?: string;
[key: string]: unknown;
}

View File

@ -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 = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
// key — как в IssueModal: другая задача монтирует карточку заново
return <IssueCard key={key} issueKey={key} onClose={() => 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");

View File

@ -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
<div className="cols">
<div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} />}
<Comments items={i.comments ?? []} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
</div>
<div className="side">
<Notes key={i.key} issue={i} />
@ -101,25 +102,61 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
);
}
function Description({ text }: { text: string }) {
const ref = useRef<HTMLPreElement>(null);
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) {
return <button className="link" type="button" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</button>;
}
function Description({ text, projectKey }: { text: string; projectKey?: string }) {
const ref = useRef<HTMLElement>(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 (
<section>
<h3>Описание</h3>
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
<div className="sec-head">
<h3>Описание</h3>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} /></div>}
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
</section>
);
}
function Comments({ items }: { items: IssueComment[] }) {
function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
const [source, setSource] = useState(false);
return (
<li>
<div className="who">
<span>{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</span>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre className="body">{c.body}</pre>
: <div className="body"><WikiText text={c.body} projectKey={projectKey} /></div>}
</li>
);
}
function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) {
const [hidden, setHidden] = useCommentsHidden();
return (
<section className="comments" data-testid="comments">
@ -130,12 +167,7 @@ function Comments({ items }: { items: IssueComment[] }) {
{!items.length && <p className="hint">комментариев нет</p>}
{items.length > 0 && !hidden && (
<ul>
{items.map((c) => (
<li key={c.comment_id}>
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
<pre className="body">{c.body}</pre>
</li>
))}
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} />)}
</ul>
)}
</section>

View File

@ -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}

View File

@ -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(<WikiText text={"h3. A\nB"} />);
const pre = container.querySelector("pre.wiki-source");
expect(pre?.textContent).toBe("h3. A\nB");
err.mockRestore();
});

View File

@ -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: <WikiText text={text} projectKey={projectKey} /> }]);
return render(<RouterProvider router={router} />);
}
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("<script>alert(1)</script> и <b>x</b>");
expect(container.querySelector("script, b")).toBeNull();
expect(container.textContent).toContain("<script>alert(1)</script>");
});
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/);
}
});
});

View File

@ -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 <Fallback text={text}><Rendered text={text} projectKey={projectKey} /></Fallback>;
}
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 ? <pre className="wiki-source">{this.props.text}</pre> : this.props.children;
}
}
function Rendered({ text, projectKey }: Props) {
return <div className="wiki">{renderBlocks(parseWiki(text, { projectKey }))}</div>;
}
function renderBlocks(blocks: Block[]): ReactNode[] {
return blocks.map((b, n) => {
switch (b.t) {
case "heading": { const H = `h${b.level}` as const; return <H key={n}>{renderInlines(b.children)}</H>; }
case "p": return <p key={n}>{renderInlines(b.children)}</p>;
case "list": return renderList(b, n);
case "table":
return (
<table key={n}><tbody>
{b.rows.map((r, i) => (
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children)}</th> : <td key={j}>{renderInlines(c.children)}</td>)}</tr>
))}
</tbody></table>
);
case "pre": return <pre key={n}>{b.text}</pre>;
case "quote": return <blockquote key={n}>{renderBlocks(b.children)}</blockquote>;
case "hr": return <hr key={n} />;
}
});
}
function renderList(list: ListBlock, key: number): ReactNode {
const L = list.ordered ? "ol" : "ul";
return (
<L key={key}>
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children)}{it.lists.map(renderList)}</li>)}
</L>
);
}
function renderInlines(nodes: Inline[]): ReactNode[] {
return nodes.map((x, n) => {
switch (x.t) {
case "text": return x.text;
case "br": return <br key={n} />;
case "strong": return <strong key={n}>{renderInlines(x.children)}</strong>;
case "em": return <em key={n}>{renderInlines(x.children)}</em>;
case "del": return <del key={n}>{renderInlines(x.children)}</del>;
case "ins": return <ins key={n}>{renderInlines(x.children)}</ins>;
case "sup": return <sup key={n}>{renderInlines(x.children)}</sup>;
case "sub": return <sub key={n}>{renderInlines(x.children)}</sub>;
case "code": return <code key={n}>{x.text}</code>;
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children)}</a>;
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children) : undefined}</IssueLink>;
case "mention": return <span key={n} className="mention">@{x.login}</span>;
}
});
}

View File

@ -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")]));
});

View File

@ -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<string, Mark> = { "*": "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 — подпись ссылки: без ссылок, адресов и ключей внутри, чтобы не было <a> в <a> (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 }] };
}

View File

@ -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))]));
});

View File

@ -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;
}

View File

@ -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}