Merge pull request 'Карточка задачи: вики-разметка Jira в описании и комментариях, кнопка «исходник» (#53)' (#54) from feat/53-wiki-render into main
This commit is contained in:
commit
d3f6c704d7
248
docs/superpowers/plans/2026-10-17-wiki-render.md
Normal file
248
docs/superpowers/plans/2026-10-17-wiki-render.md
Normal 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)`.
|
||||
2
openspec/changes/wiki-render/.openspec.yaml
Normal file
2
openspec/changes/wiki-render/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-17
|
||||
105
openspec/changes/wiki-render/design.md
Normal file
105
openspec/changes/wiki-render/design.md
Normal 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 |
|
||||
35
openspec/changes/wiki-render/proposal.md
Normal file
35
openspec/changes/wiki-render/proposal.md
Normal 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}` — нет.
|
||||
- Рендер разметки вне карточки (список «Задачи», доска, «Служебные заметки») — нет.
|
||||
131
openspec/changes/wiki-render/specs/web-app/spec.md
Normal file
131
openspec/changes/wiki-render/specs/web-app/spec.md
Normal 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** текст показан как есть, без курсива, зачёркивания, жирного и ссылок
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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": "[мост] служебный комментарий"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
13
web/src/issues/wiki/WikiText.fallback.test.tsx
Normal file
13
web/src/issues/wiki/WikiText.fallback.test.tsx
Normal 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();
|
||||
});
|
||||
73
web/src/issues/wiki/WikiText.test.tsx
Normal file
73
web/src/issues/wiki/WikiText.test.tsx
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
web/src/issues/wiki/WikiText.tsx
Normal file
74
web/src/issues/wiki/WikiText.tsx
Normal 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>;
|
||||
}
|
||||
});
|
||||
}
|
||||
76
web/src/issues/wiki/inline.test.ts
Normal file
76
web/src/issues/wiki/inline.test.ts
Normal 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")]));
|
||||
});
|
||||
136
web/src/issues/wiki/inline.ts
Normal file
136
web/src/issues/wiki/inline.ts
Normal 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 }] };
|
||||
}
|
||||
80
web/src/issues/wiki/parse.test.ts
Normal file
80
web/src/issues/wiki/parse.test.ts
Normal 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))]));
|
||||
});
|
||||
164
web/src/issues/wiki/parse.ts
Normal file
164
web/src/issues/wiki/parse.ts
Normal 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;
|
||||
}
|
||||
22
web/src/issues/wiki/wiki.css
Normal file
22
web/src/issues/wiki/wiki.css
Normal 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}
|
||||
Loading…
x
Reference in New Issue
Block a user