import {
docToHtml,
docToMarkdown,
docToPlainText,
editorSchema,
extractOutline,
OutlineEntry,
extractWikilinkSlugs,
} from '@dorfteich/shared';
import { Node } from 'prosemirror-model';
import { prosemirrorJSONToYXmlFragment, 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, web, and collab (#35) must
* all agree on this or Yjs states become unreadable across them.
*/
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 docFromState(state: Uint8Array): Node {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, state);
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
} catch (error) {
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
} finally {
ydoc.destroy();
}
}
/**
* Encode a ProseMirror document as the initial Yjs state a page is created
* with. Used both for a fresh empty page and for importing a converted
* document (#63) as a page's starting content — the editor binds to the same
* {@link FRAGMENT_NAME}, so an opening client sees exactly this document.
*/
export function docToState(doc: Node): Uint8Array {
const ydoc = new Y.Doc();
try {
const fragment = ydoc.getXmlFragment(FRAGMENT_NAME);
prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment);
// Copy into a plain ArrayBuffer-backed view — yjs's own return type is
// the wider `Uint8Array`, which Prisma's Bytes input
// (`Uint8Array`) does not accept directly.
return new Uint8Array(Y.encodeStateAsUpdate(ydoc));
} finally {
ydoc.destroy();
}
}
/** A fresh Yjs state containing a single empty paragraph. */
export function emptyPageState(): Uint8Array {
return docToState(editorSchema.node('doc', null, [editorSchema.node('paragraph')]));
}
export interface DerivedPageContent {
plainText: string;
markdown: string;
html: string;
outline: OutlineEntry[];
/** fileIds of every `image` node currently embedded in the document
* (issue #31) — `PagesService.saveState` uses this to keep
* `Attachment.pageId` pointed at whichever page's content actually
* embeds the file, which is what the trash-purge job uses to find a
* purged page's files. */
imageFileIds: string[];
/** Outgoing wikilink target slugs (issue #117): pages created through the
* api (imports, phantom-create) seed their `page_links` rows from this —
* collab, the content writer, rewrites them on every later save. */
wikilinkSlugs: 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;
}
/**
* Decodes a page's Yjs state into the derived representations stored in
* `page_content_cache` (issue #23). The collab server (#35) will decode
* the same way and call the same shared derivation functions (#24).
*/
export function deriveContent(state: Uint8Array): DerivedPageContent {
const doc = docFromState(state);
return {
plainText: docToPlainText(doc),
markdown: docToMarkdown(doc),
html: docToHtml(doc),
outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc),
wikilinkSlugs: extractWikilinkSlugs(doc),
};
}