Files
gitmost/apps/client/src/features/ai-chat/utils/markdown.ts
T
agent_coder 5d8083f8ff refactor(client): вставка markdown через канонический @docmost/prosemirror-markdown + удаление md-слоя editor-ext (#347)
Третий шаг #345: клиентская вставка markdown переезжает с marked-слоя editor-ext
(не знал канона — ^[…], <!--img {…}-->, <!--subpages--> при вставке не
распознавались) на канонический пакет. По завершении слой удалён целиком. closes #347

- Browser-entry пакета (инъекция DOM-парсера): jsdom только в Node-пути.
  dom-parser.ts — 2 слота инъекции (HtmlDocumentParser для HTML→Document,
  GenerateJsonFn для @tiptap/html), без импорта DOM. dom-parser.node.ts
  регистрирует jsdom + @tiptap/html/server; dom-parser.browser.ts — нативный
  DOMParser + @tiptap/html (browser), экспонирован через exports-условие
  "browser" + сабпас "./browser". markdown-to-prosemirror.ts: убраны статический
  импорт jsdom и module-level global.window-шим. Клиент ВСЕГДА импортирует явный
  сабпас /browser — не полагается на порядок условий. Node-потребители (mcp/
  server) идут по "." → default → index.js → jsdom, не затронуты.
- markdown-clipboard.ts: конвертация через browser-entry (markdownToProseMirror
  → PM-JSON → HTML через живую схему редактора DOMSerializer → НЕИЗМЕНЁННЫЙ
  downstream-шов normalizeTableColumnWidths→parseSlice→canonicalizePastedFootnotes
  →dispatch). Эвристики/fragment-insertion не тронуты. Конвертер async → handle
  Paste захватывает диапазон, забирает событие, диспатчит на резолве; и success,
  и fail-open ветки защищены guard'ом doc!==startDoc (не диспатчить по устаревшему
  диапазону). clipboardTextSerializer (copy PM→md) — через convertProseMirror
  ToMarkdown.
- Удалён packages/editor-ext/src/lib/markdown/ целиком (+ marked из package.json).
  Мигрированы ВСЕ потребители markdownToHtml/htmlToMarkdown: ai-chat/utils/
  markdown.ts (→ новый markdownToProseMirrorSync + DOMSerializer), use-generate-
  page-title.ts / page-header-menu.tsx (→ convertProseMirrorToMarkdown(getJSON)),
  серверный spec. Grep: осиротевших импортов нет, editor-ext = только схема/
  расширения. Turndown ушёл из бандла (был в старом htmlToMarkdown).
- AI-чат теперь рендерит markdown через схему редактора (li в <p>); добавлен
  .markdown li p{margin:0} (CSS-модуль, скоуп только чата) — визуально плотно.

Проверка: pmd tsc + vitest 744; client build УСПЕШЕН, grep бандла на
JSDOM/parse5/happy-dom/turndown — 0 (утечки нет); клиентский suite + paste-тесты
зелёные (34); editor-ext 196; node-потребители (mcp/server/git-sync) зелёные.
Юнит-тесты: dual-path parity (jsdom==DOMParser), канон-формы == серверный импорт,
негативы ($5/==/[^1] не корёжатся), async-paste (claim→convert→dispatch, fail-open).

Ручная paste-QA полного клиентского round-trip (footnote/callout/math/image-comment;
вставка из VSCode/Obsidian/GitHub в список/таблицу/callout) и Docker-сборка клиента
(#333-класс) — за пределами автостенда, оставлено на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:11:19 +03:00

144 lines
6.2 KiB
TypeScript

import {
markdownToProseMirrorSync,
docmostExtensions,
} from "@docmost/prosemirror-markdown/browser";
import { getSchema } from "@tiptap/core";
import { Node as PMNode, DOMSerializer } from "@tiptap/pm/model";
import DOMPurify from "dompurify";
// The Docmost editor schema, built once. Chat markdown is rendered through the
// SAME schema the editor/import use (issue #347), so chat output matches how the
// page would render the same markdown.
const chatSchema = getSchema(docmostExtensions);
/**
* Markdown -> HTML for chat display, via the canonical converter. We serialize
* the ProseMirror doc with `DOMSerializer` into a real element and read its
* `innerHTML` (rather than `@tiptap/html`'s `generateHTML`, whose browser path
* uses `XMLSerializer` and stamps a `xmlns` on every block) so the markup is
* clean HTML. `li > p` wrapping is inherent to the schema (listItem content is
* `paragraph+`); the chat CSS zeroes those paragraph margins so lists still
* render tight.
*/
function markdownToChatHtml(markdown: string): string {
const doc = markdownToProseMirrorSync(markdown);
const node = PMNode.fromJSON(chatSchema, doc);
const div = document.createElement("div");
DOMSerializer.fromSchema(chatSchema).serializeFragment(
node.content,
{ document },
div,
);
return div.innerHTML;
}
export interface RenderChatMarkdownOptions {
/**
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
* Used by the anonymous public share: the assistant's answer can contain
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
* neutralized too. Defaults to false — the internal chat keeps internal links
* clickable for authenticated users.
*/
neutralizeInternalLinks?: boolean;
}
/**
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
* anonymous reader to follow. A link qualifies only if it is absolute
* `http(s)://` AND its host differs from the app's own origin
* (`window.location.host`): absolute links back to our OWN host (e.g.
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
* parse) is treated as internal/unsafe and neutralized — fail closed.
*/
function isExternalHttpUrl(href: string): boolean {
const value = href.trim();
if (!/^https?:\/\//i.test(value)) return false;
try {
// External only if it points at a DIFFERENT host than the app's own origin.
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
// internal and must be neutralized, same as relative `/p/...` links.
return new URL(value).host !== window.location.host;
} catch {
return false; // unparseable -> treat as internal/unsafe, neutralize
}
}
/**
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
* for the duration of a single sanitize call (added then removed in
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
*/
function neutralizeInternalLinksHook(node: Element): void {
if (node.nodeName !== "A") return;
const href = node.getAttribute("href");
if (href !== null && isExternalHttpUrl(href)) {
// Genuinely external link: keep it, but force a safe rel/target.
node.setAttribute("rel", "noopener noreferrer nofollow");
node.setAttribute("target", "_blank");
return;
}
// Internal/relative/fragment link (or no href): make it inert text. Drop the
// href and any target so it is no longer clickable; the visible text stays.
node.removeAttribute("href");
node.removeAttribute("target");
}
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
* canonical converter (issue #347): markdown -> ProseMirror JSON (the SAME
* `markdownToProseMirrorSync` the editor paste/import path uses, so chat output
* matches the editor's markdown flavor) -> HTML via `markdownToChatHtml`
* (DOMSerializer), then sanitize with DOMPurify — LLM output is untrusted, so it
* must never reach the DOM unsanitized.
*
* Stays SYNCHRONOUS: both callers render inside React (a memo and a useMemo),
* so the whole pipeline must resolve without awaiting. The converter's sync
* entry makes that possible; on any conversion error we return "" so the caller
* falls back to raw text (the same fallback the old Promise-guard produced).
*/
export function renderChatMarkdown(
markdown: string,
options: RenderChatMarkdownOptions = {},
): string {
if (!markdown) return "";
let html: string;
try {
// markdown -> canonical PM JSON -> HTML (native DOMParser in the browser;
// jsdom is never bundled — see @docmost/prosemirror-markdown/browser).
html = markdownToChatHtml(markdown);
} catch {
// Malformed/unsupported markdown must not crash the chat render; fall back
// to raw text (empty return -> caller shows the plain-text branch).
return "";
}
if (!options.neutralizeInternalLinks) {
// Internal chat: unchanged behavior, no hook registered.
return DOMPurify.sanitize(html);
}
// Public share: register the neutralization hook only for THIS sanitize call,
// then remove it immediately so it can never affect the internal chat (hooks
// are global on the shared DOMPurify instance).
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
try {
return DOMPurify.sanitize(html);
} finally {
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
// robust to any other afterSanitizeAttributes hook registered in future.
DOMPurify.removeHook(
"afterSanitizeAttributes",
neutralizeInternalLinksHook,
);
}
}