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