import { docToHtml, docToMarkdown, docToPlainText, editorSchema, extractOutline, extractMentionUserIds, extractWikilinkSlugs, type OutlineEntry, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import * as Y from 'yjs'; /** * The Yjs XmlFragment name the editor binds to — TipTap's collaboration * extension defaults to "default" (#25). api (`apps/api/src/pages/yjs-content.ts`), * web, and collab must all agree on this or Yjs states become unreadable across * them. This file deliberately mirrors the api's derivation (the shared * functions come from `@dorfteich/shared`, #24); the two were kept separate on * purpose rather than abstracted prematurely (see the M3 handoff). */ const FRAGMENT_NAME = 'default'; /** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ export class InvalidPageStateError extends Error {} function docFromDoc(ydoc: Y.Doc): Node { try { return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema); } catch (error) { throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state'); } } export interface DerivedPageContent { plainText: string; markdown: string; html: string; outline: OutlineEntry[]; /** fileIds of every `image` node embedded in the document — keeps * `Attachment.pageId` pointed at the page that embeds the file (issue #31), * mirroring the api's REST save path. */ imageFileIds: string[]; /** Distinct target slugs of every `[[wikilink]]`, for the `page_links` * index (issue #47). */ wikilinkSlugs: string[]; /** Distinct resolved user ids of every `@mention`, for the * `page_mentions` index and the mention notifications (issue #151). */ mentionUserIds: string[]; } function imageFileIdsOf(doc: Node): string[] { const ids: string[] = []; doc.descendants((node) => { if (node.type.name === 'image' && typeof node.attrs.fileId === 'string') { ids.push(node.attrs.fileId); } }); return ids; } /** * Decode a live Yjs document into the derived representations stored in * `page_content_cache` (issue #23/#35), using the shared editor schema (#24) * so the cache matches exactly what the api derives from the same state. */ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent { const doc = docFromDoc(ydoc); return { plainText: docToPlainText(doc), markdown: docToMarkdown(doc), html: docToHtml(doc), outline: extractOutline(doc), imageFileIds: imageFileIdsOf(doc), wikilinkSlugs: extractWikilinkSlugs(doc), mentionUserIds: extractMentionUserIds(doc), }; }