feat(editor-ext): share wikilink and database-view node schemas

Add WikilinkNode and DatabaseView schema-only nodes to @docmost/editor-ext
and register them in the Hocuspocus server tiptapExtensions list.

Without the shared schema, jsonToNode on the server hit a RangeError for
those node types and stripUnknownNodes dropped them on every collab save,
so wikilinks disappeared on page reload and database-view embeds lost
their config and rendered as empty placeholders.
This commit is contained in:
Corentin JOGUET 2026-05-11 12:28:12 +00:00
parent dbd79cc17c
commit b802f1d647
4 changed files with 193 additions and 0 deletions

View file

@ -38,6 +38,8 @@ import {
Columns,
Column,
Status,
WikilinkNode,
DatabaseView,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
@ -101,6 +103,8 @@ export const tiptapExtensions = [
Columns,
Column,
Status,
WikilinkNode,
DatabaseView,
] as any;
export function jsonToHtml(tiptapJson: any) {

View file

@ -30,4 +30,6 @@ export * from "./lib/columns";
export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/resizable-nodeview";
export * from "./lib/wikilink";
export * from "./lib/database-view";

View file

@ -0,0 +1,88 @@
import { Node, mergeAttributes } from "@tiptap/core";
export interface DatabaseViewAttrs {
tableId: string;
viewId: string;
viewType: string;
bridgeUrl?: string | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
databaseView: {
insertDatabaseView: (attrs: DatabaseViewAttrs) => ReturnType;
};
}
}
/**
* Shared DatabaseView node (schema only, no NodeView).
*
* Registered on the Hocuspocus server so embedded Baserow views survive
* collab saves. The client extends this node to attach the React renderer.
*/
export const DatabaseView = Node.create({
name: "database-view",
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
tableId: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-table-id") ?? "",
renderHTML: (attrs) => ({ "data-table-id": attrs.tableId }),
},
viewId: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-view-id") ?? "",
renderHTML: (attrs) => ({ "data-view-id": attrs.viewId }),
},
viewType: {
default: "grid",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-view-type") ?? "grid",
renderHTML: (attrs) => ({ "data-view-type": attrs.viewType }),
},
bridgeUrl: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-bridge-url") ?? null,
renderHTML: (attrs) =>
attrs.bridgeUrl ? { "data-bridge-url": attrs.bridgeUrl } : {},
},
};
},
parseHTML() {
return [{ tag: "div[data-node-type=database-view]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-node-type": "database-view" }),
];
},
addCommands() {
return {
insertDatabaseView:
(attrs: DatabaseViewAttrs) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: {
tableId: attrs.tableId,
viewId: attrs.viewId,
viewType: attrs.viewType,
bridgeUrl: attrs.bridgeUrl ?? null,
},
}),
};
},
});
export default DatabaseView;

View file

@ -0,0 +1,99 @@
import { Node } from "@tiptap/core";
export interface WikilinkAttrs {
pageId: string | null;
slugId?: string | null;
spaceSlug?: string | null;
title: string;
alias: string | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
wikilink: {
insertWikilink: (attrs: WikilinkAttrs) => ReturnType;
};
}
}
/**
* Shared Wikilink node (schema only, no NodeView).
*
* Lives in @docmost/editor-ext so both the Hocuspocus server and the React
* client share the exact same schema. The client extends this node to add
* the React NodeView, input rule, and suggestion plugin.
*
* Without registering this node on the server, Hocuspocus' jsonToNode strips
* wikilink nodes on save (unknown node type).
*/
export const WikilinkNode = Node.create({
name: "wikilink",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-page-id"),
renderHTML: (attrs) =>
attrs.pageId ? { "data-page-id": attrs.pageId } : {},
},
slugId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-slug-id"),
renderHTML: (attrs) =>
attrs.slugId ? { "data-slug-id": attrs.slugId } : {},
},
spaceSlug: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-space-slug"),
renderHTML: (attrs) =>
attrs.spaceSlug ? { "data-space-slug": attrs.spaceSlug } : {},
},
title: {
default: "",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-title") ?? el.textContent ?? "",
renderHTML: (attrs) => ({ "data-title": attrs.title }),
},
alias: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-alias"),
renderHTML: (attrs) =>
attrs.alias ? { "data-alias": attrs.alias } : {},
},
};
},
parseHTML() {
return [{ tag: "span[data-wikilink]" }];
},
renderHTML({ HTMLAttributes, node }) {
const display = node.attrs.alias ?? node.attrs.title ?? "?";
const isBroken = !node.attrs.pageId;
return [
"span",
{
"data-wikilink": "true",
...HTMLAttributes,
class: isBroken ? "wikilink wikilink--broken" : "wikilink",
},
`[[${display}]]`,
];
},
addCommands() {
return {
insertWikilink:
(attrs: WikilinkAttrs) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
};
},
});
export default WikilinkNode;