dorfteich/apps/web/src/editor/document-extensions.test.ts
Claude Sonnet 5 076883a9a6
All checks were successful
CD / Build and push images (push) Successful in 2m0s
CI / Lint, typecheck, test (push) Successful in 1m42s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Add TipTap page editor with REST persistence (#25)
TipTap is bound to the canonical ProseMirror schema (packages/shared,
#24) via a generic bridge (spec-utils.ts) that re-derives every
node/mark's attrs/parseDOM/toDOM from editorSchema instead of
duplicating them, so the editor's schema stays byte-for-byte identical
to what the api decodes Yjs states against — guarded by a schema-
fidelity + real Yjs round-trip test (@tiptap/y-tiptap client encoding
against y-prosemirror server decoding).

Route /p/:pondSlug/:pageSlug (RequireAuth) resolves the page via a new
GET /ponds/:pondId/pages/:slug endpoint, binds a local Y.Doc via
@tiptap/extension-collaboration (fragment "default"), and offers a
view/edit mode toggle (sidebar auto-hides in edit mode via a small
AppLayout context). Page state saves debounced to PUT /pages/:id/state
with a truthful saving/saved/error(retrying) indicator; title saves
separately via PATCH /pages/:id.

Toolbar covers headings, marks, lists, blockquote, code block, hr,
table (insert/row/column/header ops via prosemirror-tables), a minimal
link mark, and an image placeholder (real upload is #27/#28).

Closes #25

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 11:08:43 +02:00

141 lines
4.9 KiB
TypeScript

// @vitest-environment jsdom
import { docToMarkdown, docToPlainText, editorSchema } from '@dorfteich/shared';
import { Editor, getSchema } from '@tiptap/core';
import { Collaboration } from '@tiptap/extension-collaboration';
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { documentExtensions } from './document-extensions';
/**
* `editorSchema` (packages/shared, #24) is the one document schema the api
* decodes Yjs states against (`apps/api/src/pages/yjs-content.ts`, #23). The
* TipTap editor (#25) cannot reuse that `Schema` instance directly — TipTap
* always builds its own from extensions — so these tests guard the bridge in
* `spec-utils.ts`/`nodes/*`/`marks.ts` against silent drift.
*/
describe('web editor schema matches editorSchema (issue #25)', () => {
const builtSchema = getSchema(documentExtensions);
const nodeNames = Object.keys(editorSchema.spec.nodes.toObject());
const markNames = Object.keys(editorSchema.spec.marks.toObject());
it('defines exactly the same node types', () => {
expect(Object.keys(builtSchema.nodes).sort()).toEqual([...nodeNames].sort());
});
it('defines exactly the same mark types', () => {
expect(Object.keys(builtSchema.marks).sort()).toEqual([...markNames].sort());
});
it.each(nodeNames)('node "%s" has the same content expression, group, and attrs', (name) => {
const canonical = editorSchema.spec.nodes.get(name)!;
const built = builtSchema.nodes[name]!.spec;
expect(built.content ?? undefined).toBe(canonical.content ?? undefined);
expect(built.group ?? undefined).toBe(canonical.group ?? undefined);
expect(Object.keys(built.attrs ?? {}).sort()).toEqual(
Object.keys(canonical.attrs ?? {}).sort(),
);
});
it.each(markNames)('mark "%s" has the same attrs', (name) => {
const canonical = editorSchema.spec.marks.get(name)!;
const built = builtSchema.marks[name]!.spec;
expect(Object.keys(built.attrs ?? {}).sort()).toEqual(
Object.keys(canonical.attrs ?? {}).sort(),
);
});
});
describe('Yjs round-trip between the web editor and the api decoder (issues #23/#25)', () => {
/** Drives a real headless TipTap `Editor` (the exact client code path,
* `@tiptap/extension-collaboration` + `@tiptap/y-tiptap`) and decodes the
* resulting Yjs state the same way the api does (`y-prosemirror` against
* `editorSchema`) — proving the two independently-built bindings agree on
* the wire format for the node/mark set this schema actually uses. */
it('preserves headings, lists, task items, marks, and tables', () => {
const ydoc = new Y.Doc();
const editor = new Editor({
element: document.createElement('div'),
extensions: [
...documentExtensions,
Collaboration.configure({ document: ydoc, field: 'default' }),
],
});
editor.commands.setContent({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
{
type: 'paragraph',
content: [
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' and a ' },
{
type: 'text',
text: 'link',
marks: [{ type: 'link', attrs: { href: 'https://example.org' } }],
},
],
},
{
type: 'bullet_list',
content: [
{
type: 'list_item',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item one' }] }],
},
],
},
{
type: 'task_list',
content: [
{
type: 'task_item',
attrs: { checked: true },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }],
},
],
},
{
type: 'table',
content: [
{
type: 'table_row',
content: [
{
type: 'table_cell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] }],
},
],
},
],
},
],
});
const state = Y.encodeStateAsUpdate(ydoc);
editor.destroy();
const decodeDoc = new Y.Doc();
Y.applyUpdate(decodeDoc, state);
const decoded = yXmlFragmentToProseMirrorRootNode(
decodeDoc.getXmlFragment('default'),
editorSchema,
);
decodeDoc.destroy();
expect(decoded.textContent).toContain('Title');
expect(decoded.textContent).toContain('item one');
expect(decoded.textContent).toContain('done');
expect(decoded.textContent).toContain('cell');
const markdown = docToMarkdown(decoded);
expect(markdown).toContain('## Title');
expect(markdown).toContain('**bold**');
expect(docToPlainText(decoded)).toContain('bold and a link');
});
});