5d8083f8ff
Третий шаг #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>
144 lines
6.2 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|